Product Recalculations (price and visibility recalculation and export to Elasticsearch)

Product recalculations in terms of this article mean recalculations of the product visibility, selling denial, and export to Elasticsearch. In Shopsys Platform, these recalculations are done asynchronously, which means that when a product is changed, the recalculations are not done immediately, but rather a message is dispatched to the message broker. This approach has been used since 14.0 version onwards instead of cron modules and allows, among other benefits, also to horizontally scale the recalculations. For a larger catalog, several consumers may be run to handle the recalculations and drastically reduce the time necessary to present the changes of products on the Storefront.

Dispatch recalculations message

When you need to recalculate visibility of product(s), you should use the Shopsys\FrameworkBundle\Model\Product\Recalculation\ProductRecalculationDispatcher service.

class MyService
{
    public function __construct(
        private readonly ProductRecalculationDispatcher $productRecalculationDispatcher,
    ) {
    }

    public function myMethod()
    {
        // ... some work

        $this->productRecalculationDispatcher->dispatchProductIds([1, 2, 3]);
    }
}

This method can be safely called in any context (console, cron, request), and the recalculation will be done properly.

Also, it's not necessary to think about the variants – the dispatcher takes care of recalculating the whole group of variants.
When, for example, the main variant is changed, it's enough to dispatch a message for the main variant (see Recalculation of variants).

When you need to recalculate visibility of all products, you should use the Shopsys\FrameworkBundle\Model\Product\Recalculation\ProductRecalculationDispatcher::dispatchAllProducts() method.

This method dispatches the special message "dispatch all" to the message broker, and this message is then handled by the async handler, which takes care of dispatching all product IDs to recalculation. That way, we may dispatch all products to recalculation during request without worrying about the size of the catalog – it's not necessary to load all products (nor their IDs) from the database, and the user interface is not blocked by this operation.

Dispatch recalculation message when indirect change is made

Sometimes it is necessary to trigger recalculation for product when some indirect change is made. For example, when a category is deleted, when the parameter translation of a product is changed, etc.

For this situation, the \Shopsys\FrameworkBundle\Model\Product\Recalculation\DispatchAffectedProductsSubscriber subscriber is used.

Whenever a change is made in the entity that affects products, the appropriate event is dispatched (e.g. Shopsys\FrameworkBundle\Model\Category\CategoryEvent::UPDATE). The DispatchAffectedProductsSubscriber subscriber listens to these events and dispatches the appropriate message for the message broker.

Recalculation of variants

Because variants are tightly coupled with the main variant, the recalculations have to be always done for the whole group (variants + main variant). But it's no longer necessary to take this into account in a custom code. When, for example, only a single variant is changed, it's enough to dispatch a message for this variant. Recalculation will be done automatically for the whole group to be sure all products are in a proper state (similarly for the main variant).

Recalculation cron module

Cron module ProductRecalculationCronModule is configured to run every day at midnight and dispatches all products to recalculation. This ensures the product is recalculated at the start of a new day to cover scenarios when it is supposed to be visible or hidden from a particular date (see Product::$sellingFrom and $sellingTo properties). Moreover, it is a safety net to make sure all products are recalculated at least once a day to prevent inconsistencies in the catalog due to possible mistakes in the code.

Shopsys\FrameworkBundle\Model\Product\Elasticsearch\ProductRecalculationCronModule:
    tags:
        - {
              name: shopsys.cron,
              hours: '0',
              minutes: '0',
              instanceName: products,
              readableName: 'Dispatches all products to be recalculated and exported',
          }

You may disable this cron module if it doesn't suit your needs.

Handle recalculations in tests

In functional tests, you sometime need to trigger recalculations to be able to test some use-case. For example, when you need to test the change of a product visibility, you need to recalculate it after the change is made, so the data returned from GraphQL are accurate.

You should use the Tests\App\Test\WebTestCase::handleDispatchedRecalculationMessages() method to process all dispatched recalculation messages.

public function testRefreshProductsVisibilityVisibleVariants()
{
    /** @var \App\Model\Product\Product $variant1 */
    $variant1 = $this->getReference(ProductDataFixture::PRODUCT_PREFIX . '53');

    $variant1productData = $this->productDataFactory->createFromProduct($variant1);
    $variant1productData->hidden = true;
    $this->productFacade->edit($variant1->getId(), $variant1productData);

    // recalculations are processed here
    $this->handleDispatchedRecalculationMessages();

    /** @var \App\Model\Product\Product $variant1 */
    $variant1 = $this->getReference(ProductDataFixture::PRODUCT_PREFIX . '53');
    /** @var \App\Model\Product\Product $variant2 */
    $variant2 = $this->getReference(ProductDataFixture::PRODUCT_PREFIX . '54');
    /** @var \App\Model\Product\Product $mainVariant */
    $mainVariant = $this->getReference(ProductDataFixture::PRODUCT_PREFIX . '69');

    $this->assertFalse($variant1->isVisible(), 'first variant should be invisible');
    $this->assertTrue($variant2->isVisible(), 'second variant should be visible');
    $this->assertTrue($mainVariant->isVisible(), 'main product should be visible');
}

In tests, the real queue is not used, the handleDispatchedRecalculationMessages() method only processes the messages dispatched during the test. That way we are sure the code works – the message is truly dispatched, thus the product is recalculated – everything with the same code used in real life.

Important

Calling handleDispatchedRecalculationMessages() method creates a snapshot in Elasticsearch before any changes are exported and restores it afterward.
Tests are not dependent on the changes made or the order of run.

Recalculation scope

In many occasions, to optimize the application, you might need to restrict the scope of a product recalculation and elastic export. Sometimes you do not need to recalculate visibility or selling denied, or you need to export just particular fields into Elasticsearch. For example, when launching a new domain, you need to export just the product URLs into Elasticsearch which takes significantly less time than the full recalculation and export. On the other hand, it might be hard to keep in mind all the dependencies (e.g. you need to know you need to recalculate visibility after you change Product::$sellingFrom, or you need to keep in mind that with a change of product URL, you always need to export hreflang_links into Elasticsearch as well, etc.).

For this purpose, recalculation scopes are defined as an associative array in the Shopsys\FrameworkBundle\Model\Product\Elasticsearch\Scope\ProductExportScopeConfigFacade class. The scopes are represented by instances of the Shopsys\FrameworkBundle\Model\Product\Elasticsearch\Scope\ProductExportScopeRule class and indexed by their names. Each scope rule defines which fields should be exported to Elasticsearch together ($productExportFields property) and whether some actions (recalculations) should be done before the export ($productExportPreconditions property).

You can use ./bin/console shopsys:list:export-scopes command to list and examine all the available scopes.

The scope usage can be seen in action e.g. in Shopsys\FrameworkBundle\Model\Product\Recalculation\DispatchAffectedProductsSubscriber class:

public function dispatchAffectedByBrand(BrandEvent $brandEvent): void
{
    $productIds = $this->affectedProductsFacade->getProductIdsWithBrand($brandEvent->getBrand());

    $this->productRecalculationDispatcher->dispatchProductIds(
        $productIds,
        ProductRecalculationPriorityEnum::REGULAR,
        [ProductExportScopeConfig::SCOPE_BRAND],
    );
}

Thanks to the usage of SCOPE_BRAND here, no visibility or selling denied recalculations are done after a brand is updated, only the brand-related fields (brand ID, name, and URL) are exported to Elasticsearch for the affected products.

There are several methods that allow you to modify the scopes configuration further to suit your project needs.

  • addExportFieldsToExistingScopeRule()
  • addNewExportScopeRule()
  • overwriteExportScopeRule()

You can use the methods in the overridden App\Model\Product\Elasticsearch\Scope\ProductExportScopeConfig::loadProductExportScopeRules().

Note

The export fields restriction is ignored in situations when the product is not present in Elasticsearch (e.g. it was not exported yet) and visibility recalculation needs to be done. In such cases, all the product fields are always exported to Elasticsearch to ensure no data are missing for the product.

Invoke recalculations manually

It's possible to invoke recalculations manually with the ./bin/console shopsys:dispatch:recalculations command.

This command accepts ids of products, that should be dispatched, or --all option to dispatch all products. You can also define the priority and/or scopes of the recalculations using --priority, and/or --scope options.

# dispatch products with ids 1, 2 and 3
./bin/console shopsys:dispatch:recalculations 1 2 3

# dispatch all products
./bin/console shopsys:dispatch:recalculations --all

# dispatch product with id 22 into the high priority queue
./bin/console shopsys:dispatch:recalculations 22 --priority=high

# dispatch all products with the "product_selling_denied_scope" scope
./bin/console shopsys:dispatch:recalculations --all --scope=product_selling_denied_scope