Describe your bug or feature request.

Config Entities are not statically cached. When pages are loaded with multiple products, and prices are show, the price calculation triggers multiple "getTaxTypes()" in the TaxOrderProcessor.

See Drupal\Core\Config\Entity\ConfigEntityType and the protected $static_cache property.

TaxOrderProcessor::process()
TaxOrderProcessor::getTaxTypes()

The getTaxTypes call does a ConfigEntityStorage::loadByProperties and then sort, which calls getPlugin() and then getPluginCollection().

  /**
   * {@inheritdoc}
   */
  public static function sort(ConfigEntityInterface $a, ConfigEntityInterface $b) {
    /** @var \Drupal\commerce_tax\Entity\TaxTypeInterface $a */
    /** @var \Drupal\commerce_tax\Entity\TaxTypeInterface $b */
    $a_plugin = $a->getPlugin();
    $b_plugin = $b->getPlugin();
    $a_weight = $a_plugin ? $a_plugin->getWeight() : 0;
    $b_weight = $b_plugin ? $b_plugin->getWeight() : 0;
    if ($a_weight == $b_weight) {
      $a_label = $a->label();
      $b_label = $b->label();
      return strnatcasecmp($a_label, $b_label);
    }
    return ($a_weight < $b_weight) ? -1 : 1;
  }

 /**
   * {@inheritdoc}
   */
  public function getPlugin() {
    return $this->getPluginCollection()->get($this->plugin);
  }
  
  /**
   * Gets the plugin collection that holds the tax type plugin.
   *
   * Ensures the plugin collection is initialized before returning it.
   *
   * @return \Drupal\commerce\CommerceSinglePluginCollection
   *   The plugin collection.
   */
  protected function getPluginCollection() {
    if (!$this->pluginCollection) {
      $plugin_manager = \Drupal::service('plugin.manager.commerce_tax_type');
      $this->pluginCollection = new CommerceSinglePluginCollection($plugin_manager, $this->plugin, $this->configuration, $this);
    }
    return $this->pluginCollection;
  }

Because the ConfigEntityStorage does not cache the Config Entity classes, the getPluginCollection() has to run again and again and instantiate all kinds of stuff behind it. Like profiles and stuff like that, for instance the buildCustomerProfile in TaxTypeBase.

/**
   * Builds a customer profile for the given order.
   *
   * Constructed only for the purposes of tax calculation, never saved.
   * The address comes one of the saved profiles, with the following priority:
   * - Shipping profile
   * - Billing profile
   * - Store profile (if the tax type is display inclusive)
   * The tax number comes from the billing profile, if present.
   *
   * @param \Drupal\commerce_order\Entity\OrderInterface $order
   *   The order.
   *
   * @return \Drupal\profile\Entity\ProfileInterface|null
   *   The customer profile, or NULL if not available yet.
   */
  protected function buildCustomerProfile(OrderInterface $order) {
    $order_id = $order->id();
    if (!isset($this->profiles[$order_id])) {
      $order_profiles = $order->collectProfiles();
      $address = NULL;
      foreach (['shipping', 'billing'] as $scope) {
        if (isset($order_profiles[$scope])) {
          $address_field = $order_profiles[$scope]->get('address');
          if (!$address_field->isEmpty()) {
            $address = $address_field->getValue();
            break;
          }
        }
      }
      if (!$address && ($this->isDisplayInclusive() || $this->forceEarlyTaxCalculation())) {
        // Customer is still unknown, but prices are displayed tax-inclusive
        // (VAT scenario), better to show the store's default tax than nothing.
        $address = $order->getStore()->getAddress();
      }
      if (!$address) {
        // A customer profile isn't usable without an address. Stop here.
        return NULL;
      }

      $tax_number = NULL;
      if (isset($order_profiles['billing'])) {
        $tax_number = $order_profiles['billing']->get('tax_number')->getValue();
      }
      $profile_storage = $this->entityTypeManager->getStorage('profile');
      $this->profiles[$order_id] = $profile_storage->create([
        'type' => 'customer',
        'uid' => 0,
        'address' => $address,
        'tax_number' => $tax_number,
      ]);
    }

Solutions.

  • Set the static_cache property in the TaxType entity definition to true
  • Implement a cache property for TaxOrderProcessor::getTaxTypes()

The first has my preference, because the performance gains would extend further than the TaxOrderProcess::getTaxTypes() method.

Issue fork commerce-3301586

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

jefuri created an issue. See original summary.

jefuri’s picture

Component: User experience » Tax

jefuri’s picture

Issue summary: View changes
jefuri’s picture

Status: Active » Needs review
jefuri’s picture

Assigned: Unassigned » jefuri
jsacksick’s picture

I wonder why Core doesn't statically cache config entities by default though.

jsacksick’s picture

Even though tests are passing, it's actually hard to predict what could be the sideeffects of that change.

jefuri’s picture

https://www.drupal.org/project/drupal/issues/1885830#comment-14167413

It's opt-in from the beginning. I know that Roles do have static cache enabled.

The underlying load/loadMultiple/loadByProperty all use the ConfigFactory with all the override logic to determine the correct entity in the static cache.

The only reason I can think of that this might be an issue is that during a request your context changes that a configuration entity has different overrides.

For instance, early in the page request your current location resolves to EU somewhere, and taxtypes are loaded. But later in some kind of eventDispatcher it is set to something different, and taxtypes is loaded again (but it is cached in the old context). Than it would not work correctly anymore. To fix this somebody should reset the static cache as wel of the tax types.

But I checked it within commerce, this does not happen in this module. Custom modules, or other contribs might...
I have checked and tested it with https://www.drupal.org/project/commerce_store_domain and it keeps working.

  • jsacksick committed b89d868 on 8.x-2.x authored by jefuri
    Issue #3301586 by jefuri: getTaxTypes performance issue'
    

  • jsacksick committed ffe8b0b on 3.0.x authored by jefuri
    Issue #3301586 by jefuri: getTaxTypes performance issue'
    
jsacksick’s picture

Committed, thanks!

jsacksick’s picture

Status: Needs review » Fixed

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.