Summary
On every StockMovementEvent the plugin re-indexes the affected product by deleting its documents and then recreating them. This causes two problems.
1. The product briefly disappears from search. The delete and the recreate happen as separate bulk operations (each with refresh), and the update runs as a queued job that is picked up asynchronously. So for a window, in our testing often a few hundred milliseconds, a search that should return the product returns nothing. On a storefront this is a visible bug: an in-stock product flickers out of the results and back in whenever its stock changes, which happens constantly during ordering.
2. It is wasteful. A stock change that does not alter any indexed field (for example 50 to 49, or any movement at all once stock is no longer stored in the index document) still rewrites a byte-for-byte identical document. On a busy catalog that is a lot of unnecessary search-engine writes, job-queue load, and replication traffic, all producing no change in the index.
Where it happens
packages/elasticsearch-plugin/src/plugin.ts: the StockMovementEvent subscriber enqueues an update-variants job for every movement, with no check for whether anything relevant changed.
packages/elasticsearch-plugin/src/indexing/indexer.controller.ts: updateProductsOperations calls deleteProductOperations and then updateProductsOperationsOnly, so a product's documents are removed and rebuilt on every update, again with no diff.
Proposal
Two opt-in options, both defaulting to the current behavior so existing installs are unaffected.
reindexOnStockMovement: 'always' | 'onStockStatusChange' (default 'always'). In 'onStockStatusChange' mode the StockMovementEvent subscriber only enqueues an update when the movement actually flips a variant's inStock or its product's productInStock. A 50 to 49 change never touches search, so no job is created at all. This inspects only the built-in stock booleans, so it is meant for setups that do not map a stock-derived custom field. Those should stay on 'always' and use the second option.
skipUnchangedIndexUpdates: boolean (default false). Before the delete-then-recreate, the indexer builds the product's target documents, compares them against what is currently indexed, and skips the write when they are identical. Because it compares the whole document it stays correct for any mapping configuration, including stock-derived custom mappings, and it removes the flicker for any no-op update regardless of what triggered it. A full reindex is never skipped.
The two are complementary. The first avoids even creating a job for stock movements that cannot change search results. The second is a general safety net that prevents a redundant delete-then-recreate from any trigger.
Backwards compatibility
- Both options default to today's behavior, so upgrading and changing nothing behaves exactly as before.
- No changes to the
SearchClientAdapter interface, so custom adapters keep working. The comparison read goes through the existing search method.
- No index mapping or schema changes.
- The stock-status check uses Vendure's
ProductVariantService.getSaleableStockLevel, and the document comparison goes through the adapter, so both features behave identically on Elasticsearch and OpenSearch.
Impact if resolved
- A stock change that does not affect search membership no longer removes the product from the index even momentarily, so the flicker is gone.
- Ordinary stock churn stops generating redundant writes and reindex jobs.
I have a PR ready that implements both options, with unit tests for the document comparison and e2e tests for the behavior on both backends. Happy to adjust the option names or defaults to your preference.
Summary
On every
StockMovementEventthe plugin re-indexes the affected product by deleting its documents and then recreating them. This causes two problems.1. The product briefly disappears from search. The delete and the recreate happen as separate bulk operations (each with
refresh), and the update runs as a queued job that is picked up asynchronously. So for a window, in our testing often a few hundred milliseconds, a search that should return the product returns nothing. On a storefront this is a visible bug: an in-stock product flickers out of the results and back in whenever its stock changes, which happens constantly during ordering.2. It is wasteful. A stock change that does not alter any indexed field (for example 50 to 49, or any movement at all once stock is no longer stored in the index document) still rewrites a byte-for-byte identical document. On a busy catalog that is a lot of unnecessary search-engine writes, job-queue load, and replication traffic, all producing no change in the index.
Where it happens
packages/elasticsearch-plugin/src/plugin.ts: theStockMovementEventsubscriber enqueues anupdate-variantsjob for every movement, with no check for whether anything relevant changed.packages/elasticsearch-plugin/src/indexing/indexer.controller.ts:updateProductsOperationscallsdeleteProductOperationsand thenupdateProductsOperationsOnly, so a product's documents are removed and rebuilt on every update, again with no diff.Proposal
Two opt-in options, both defaulting to the current behavior so existing installs are unaffected.
reindexOnStockMovement: 'always' | 'onStockStatusChange'(default'always'). In'onStockStatusChange'mode theStockMovementEventsubscriber only enqueues an update when the movement actually flips a variant'sinStockor its product'sproductInStock. A 50 to 49 change never touches search, so no job is created at all. This inspects only the built-in stock booleans, so it is meant for setups that do not map a stock-derived custom field. Those should stay on'always'and use the second option.skipUnchangedIndexUpdates: boolean(defaultfalse). Before the delete-then-recreate, the indexer builds the product's target documents, compares them against what is currently indexed, and skips the write when they are identical. Because it compares the whole document it stays correct for any mapping configuration, including stock-derived custom mappings, and it removes the flicker for any no-op update regardless of what triggered it. A full reindex is never skipped.The two are complementary. The first avoids even creating a job for stock movements that cannot change search results. The second is a general safety net that prevents a redundant delete-then-recreate from any trigger.
Backwards compatibility
SearchClientAdapterinterface, so custom adapters keep working. The comparison read goes through the existingsearchmethod.ProductVariantService.getSaleableStockLevel, and the document comparison goes through the adapter, so both features behave identically on Elasticsearch and OpenSearch.Impact if resolved
I have a PR ready that implements both options, with unit tests for the document comparison and e2e tests for the behavior on both backends. Happy to adjust the option names or defaults to your preference.