Cache invalidation for a price list block on a high-traffic product page
A catalog site on Drupal 10 serves a product page rendered by a custom controller. A sidebar block shows the five cheapest variants and their prices, computed by a ProductPriceListBlock that queries the commerce_product and commerce_price tables directly. Editors update prices through the admin UI a few times a day, and a nightly cron job imports supplier feeds that can touch up to 4,000 products. The page uses the Dynamic Page Cache and the block is not cached per-user. After launch, testers report that price changes take up to an hour to appear, and the block sometimes shows prices for a different product than the one on the page. The block plugin has no cache metadata and its build() returns a render array with #cache absent.
1. Identify the two distinct defects behind the stale prices and the cross-product leakage, and explain the mechanism for each.
Read the worked answer
Defect 1 - missing cache contexts. The block's output depends on the current product, but nothing in the render array declares that dependency. With the Dynamic Page Cache enabled, the rendered page is cached under a URL-independent variation set that doesn't include the product. The block therefore renders once and is reused for other product pages, producing the cross-product leakage. The fix is #cache['contexts'][] = 'url.path' (or a route-based context) so the variation is captured in the cache key.
Defect 2 - missing cache tags. The block queries product and price tables but declares no tags. Drupal invalidates cached renderings by tag, so a price save invalidates only entities whose tags are present in the cache item. With no tags, the cached block survives until the generic max-age or the page cache is cleared - hence the hour-long delay. The fix is to add the relevant entity list tags, e.g. commerce_product_list and commerce_price_list, or tags from the loaded entities. Direct DB queries bypass the entity system's automatic tag bubbling, so the developer must add them by hand.
2. The nightly importer updates 4,000 products in one run. What is the risk of simply adding the product tags as described, and how would you mitigate it?
Read the worked answer
Adding commerce_product_list means every one of the 4,000 saves invalidates that tag. On a busy site, each invalidation can evict a large slice of the Dynamic Page Cache and the render cache, so the importer effectively flushes the cache thousands of times in a short window and the site runs cold for the rest of the night.
Mitigations, in order of preference:
Tag at the right granularity. If the block depends only on a small set of products (the cheapest five), load those entities and let their individual tags bubble rather than the list tag. The block then invalidates only when one of those five changes.
Defer invalidation during the import. Wrap the import in a transaction-like pattern: collect affected IDs and invalidate once at the end, or set a short max-age during the import window so the block self-expires rather than being tag-invalidated 4,000 times.
Use a computed/short-TTL cache. Give the block
#cache['max-age'] = 300and accept up to five minutes of staleness, which is usually fine for a price sidebar and removes the tag churn entirely.Batch the importer with the Batch API or a queue worker so saves are spread out and cache rebuilds are not stampeded.
3. How would you verify the fix in a way that would catch a regression, not just confirm the current behavior?
Read the worked answer
Check the metadata, not the rendered HTML. A test that asserts the price text passes even when caching is broken (because the test request is cold). Instead:
- Render the block in a kernel or functional test and assert that the resulting render array's
#cachecontains the expected contexts and tags. This fails loudly if someone removes them. - Add a functional test that requests product A, then product B, and asserts the prices differ - this catches context regressions.
- Add a test that renders, saves a price via the entity API, then re-renders and asserts the new value appears without a manual cache clear - this catches tag regressions.
- Run with the Dynamic Page Cache and render cache enabled in the test environment; many regressions only manifest with caching on.