Repository navigation
Cache the total and filter options of a storefront product listing - #973
Merged
Merged
Conversation
A category with tens of thousands of products spent ~3 s per request counting the matches and aggregating their filterable specification options, on every page and sort order. Both depend on the filters only, so a storefront listing now reads them from a cache entry (5 minutes, key = hash of every filter incl. store and customer groups) while the page itself is still queried live. A short page keeps an exact total. Staff lists (ShowHidden) are not cached. Product, category/collection mapping, specification mapping and tag writes clear the listing entries.
added 2 commits
October 22, 2026 07:37
- Listing pages opt in through GetSearchProductsQuery.CacheListingSummary (category, brand, collection, vendor, tag). Sitemap, autocomplete, search, featured and new products count live again, and keywords, specification options, a price range or a fractional rating never reach the cache. - A full page never reports fewer products than it proves exist. - Summary keys carry a version that clearing the listing pattern drops, so a summary computed across an invalidation is stored where no request reads it. - The shared summary no longer runs under the first request's token; every waiter stops on its own cancellation. - Key fields are length-prefixed and specifications counted by the options that resolved, so no tag or id can run into the next field. - One WhenBoth helper runs the page and summary queries for both paths.
- Caching depends on the opt-in and ShowHidden only; the key keeps every filter, so no list of excluded filters is needed. - The key is a JSON array of the filters: a separator inside a tag or an id is escaped, with no hand-made length prefixes. - FilteredSpecs go into the key as requested, so the specification filter methods keep their signatures. - No key version: a summary computed across an invalidation lags by at most its 5-minute lifetime, the same bound as any write that does not clear it. - An empty page past the end reports at most skip as its total.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type: feature
Issue
On a large catalog every category page is slow, whatever the page or sort order. Measured on a test database with 50,073 products, 47,532 of them visible in one category (Release build, warm cache, one client, p50):
Next to the page itself (11–18 ms),
GetSearchProductsQueryHandlerruns two queries that walk every matching product: the count for the pager (~0.85 s) and the aggregation of filterable specification option ids (~2.8 s). They ran on every request, although neither depends on the page index or the order. Rewriting the aggregation ($filter/$map, a different grouping, a forced collection scan) did not get it below ~2.5 s: the cost is the number of documents, not the shape of the query.Solution
A storefront listing page opts in with
GetSearchProductsQuery.CacheListingSummary(category, brand, collection, vendor and product tag pages). It reads its total and filter options from a cache entry and still queries the page live:CacheKey.PRODUCTS_LISTING_SUMMARY_KEY=Grand.product.listing-{hash}. The hash is taken over a JSON array of every request value that changes which products match: categories, featured flag, brand, collection, store, vendor, warehouse, product type, visibility/new/home-page/published flags, price, rating, tag, the allowed customer groups, filtered specs, specification options, keywords and the search mode. The page and the order are left out. Sets are sorted, and JSON escapes any separator inside a value and formats numbers invariantly.ListingSummaryCacheMinutes).ShowHidden), search, autocomplete, the sitemap, featured and new products.Invalidation:
PRODUCTS_LISTING_PATTERN_KEYis cleared byProductService.UpdateProduct/UnPublishProduct, by product category/collection mapping insert/update/delete, by product specification attribute mapping insert/update/delete and by product tag attach/detach. Insert/delete product andUpdateProductFieldalready clear the wholeGrand.product.prefix. Every other write (stock, bulk imports, availability dates passing) is bounded by the 5-minute lifetime, as is a summary computed while an invalidation arrives: until then the pager may show a page too many, or a filter option may yield no products. The products on the page are always current.After (same database and harness):
The pager total and the filter options match the database: 47,532 unfiltered, 47,529 with the filter.
Not addressed here:
skipover 36k documents). They are reachable by crawlers through the server-rendered pager, so they need a separate fix.Breaking changes
GetSearchProductsQueryHandlertakes a newICacheBaseconstructor parameter. DI resolves it, so only code that constructs the handler directly (tests, custom plugins) needs to pass it.GetSearchProductsQuerygetsCacheListingSummary(defaultfalse). Behavior: the total and filter options may lag behind writes that do not clear them, by at most 5 minutes.Testing
dotnet test src/Tests/Grand.Business.Catalog.Tests: all pass. The new tests cover:ShowHiddencount live;