Read products by identifier in one query - #770
Open
KrzysztofPajak wants to merge 1 commit into
Open
Conversation
GetProductsByIds looped GetProductById, so the batch method was itself the N+1: a cart, a related-products block or a recently-viewed list cost one round trip per identifier. It is called from personalised, recommended and suggested products, recently viewed, blog post products and two discount rules. The naive fix - one $in query - would have thrown away the per-identifier cache that the loop was at least benefiting from, and ICacheBase offers no way to ask whether a key is present without also supplying a value to store. A lazy shared task gets both: identifiers already cached are served from memory and never reach it, while the first identifier that misses starts a single query covering the request, and every other miss awaits that same task. Warm calls therefore cost nothing and cold calls cost one round trip instead of N. Behaviour is unchanged in the parts callers depend on, and the tests pin them: the order of the identifiers given is the order returned - recently viewed products rely on it - an identifier matching nothing is skipped, and a repeated identifier still yields the product twice. The tests count reads at the repository rather than asserting on the products, because the number of round trips is the point; on the previous implementation the count is zero, since it went through GetByIdAsync per identifier instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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: bugfix
Issue
ProductService.GetProductsByIdsloopedGetProductById, so the batch method was itself the N+1:One database round trip per identifier, sequentially. It is called from personalised products, recommended products, suggested products, recently viewed products, blog post products and two discount rules — so a cart page or a related-products block pays the full N.
Solution
The obvious fix — a single
$inquery — would have thrown away the per-identifier cache that the loop was at least benefiting from, andICacheBaseoffers no way to ask whether a key is present without also supplying a value to store (it hasGetAsync(key, acquire)andSetAsync, no peek).A lazily-started shared task gets both properties without touching that shared interface:
Identifiers already cached are served from memory and never touch the lazy. The first identifier that misses starts one query covering the request; every other miss awaits that same task. So a warm call costs nothing, and a cold call costs one round trip instead of N. A partly warm call also costs one round trip — the query covers all requested identifiers, including the cached ones, which is the small price for not needing a peek API.
A lookup rather than a dictionary, because a caller may repeat an identifier and an identifier may match nothing.
Breaking changes
None. Signature, return type and filtering are unchanged, and the per-identifier cache entries are the same ones
GetProductByIduses, so the two stay consistent in both directions.Behaviour callers depend on is preserved and now pinned by tests:
Note for LiteDB installations: this reads through
IRepository.Table, which under LiteDB materialises the collection before filtering. That is the pre-existing limitation recorded as item 1.2 of the architecture audit and shared with the other 258.Tableuses in the business layer — it is not made worse here, but it is not fixed either.Testing
dotnet build ./GrandNode.sln— clean, 0 warnings.dotnet test src/Tests/Grand.Business.Catalog.Tests— 355 pass, 6 new.Grand.Business.Checkout.Tests231,Grand.Web.Tests9.GetByIdAsyncper identifier instead — so the count is what distinguishes the two.🤖 Generated with Claude Code