Skip to content

Count discount usage once per request in limitation checks - #969

Open
KrzysztofPajak wants to merge 2 commits into
developfrom
perf/discount-limitation-queries
Open

KrzysztofPajak wants to merge 2 commits into
developfrom
perf/discount-limitation-queries

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

What

A storefront listing validates every applicable discount for every product (DiscountHandlerService.GetAllowedDiscounts). For a discount limited with NTimes or NTimesPerUser, each check sent GetDiscountUsageHistoryQuery with PageSize = 1. That query goes through PagedAsync, so each check cost a sorted find plus a count. A discount assigned to all products therefore cost 48 queries on a page of 24 products.

Changes in DiscountValidationService:

  • The usage history is counted with one CountAsync instead of PagedAsync.
  • The count is kept for the rest of the scope, keyed by discount and customer. GetProductOverviewHandler prepares product overviews concurrently (Task.WhenAll), so the count Task is stored in a ConcurrentDictionary and concurrent checks share one query.
  • ExistsCodeInDiscount uses AnyAsync instead of loading the matching coupons into a list.
  • The service takes IRepository<DiscountUsageHistory> instead of IMediator. It already used IRepository<DiscountCoupon> the same way.

The limit semantics are unchanged: the count still includes canceled usages, and NTimesPerUser still filters by the customer.

Nothing is cached between requests. Within one request the count is read once, so a usage written later in that same request is not seen. Order placement validates before it inserts the usage history, so it is not affected.

The DiscountUsageHistory indexes (DiscountId, CustomerId) are already created by the installer, so no migration is needed.

Measured

Local MongoDB, 100,000 usage history documents, one limited discount with 1,000 usages, median of 7 runs, database queries only:

page of 24 products, before after
NTimes 47.9 ms (48 queries) 3.3 ms (1 query)
NTimesPerUser 30.8 ms (48 queries) 2.3 ms (1 query)

These are localhost timings. With network latency to the database, the 47 round trips saved are worth more.

Breaking changes

  • The DiscountValidationService constructor takes IRepository<DiscountUsageHistory> instead of IMediator. This only affects code that constructs or subclasses the service directly; DI resolves it as before.

Testing

  1. dotnet test on Grand.Business.Catalog.Tests (653 passed, 1 skipped) and Grand.Business.Checkout.Tests (292 passed). New tests cover:
    • the NTimes limit, both reached and below
    • NTimesPerUser counting only that customer's usages
    • one count per scope
    • concurrent checks sharing one count
    • ExistsCodeInDiscount
  2. PluginDependencyCycleTests (3 passed). The tests needed a mock of the new repository dependency.
  3. dotnet build GrandNode.slnx: 0 errors.

A listing validates every applicable discount for every product. For an
NTimes or NTimesPerUser discount each check sent GetDiscountUsageHistoryQuery
with PageSize = 1, which costs a sorted find plus a count: 48 queries on a
page of 24 products.

DiscountValidationService now counts the usage history with one CountAsync
and keeps the count task for the rest of the scope, keyed by discount and
customer. Product overviews are prepared concurrently, so concurrent checks
share one query. ExistsCodeInDiscount uses AnyAsync instead of loading a list.
Copilot AI balanced review requested due to automatic review settings October 21, 2026 15:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

GetOrAdd could run the count factory once per racing thread, so a
concurrent listing still sent duplicate queries; the count is now a
Lazy task. A faulted count is removed from the scope instead of
failing every later validation of that discount.

The concurrency test now counts repository calls, and a new test
covers the retry after a failed count.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants