Skip to content

Count discount usage with one query in limitation checks - #969

Merged
KrzysztofPajak merged 3 commits into
developfrom
perf/discount-limitation-queries
Oct 21, 2026
Merged

KrzysztofPajak merged 3 commits into
developfrom
perf/discount-limitation-queries

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Oct 21, 2026 •

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, so each check costs one query instead of two.
  • 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 count is read on every validation and is not kept anywhere. Order placement validates through the same pricing path as a listing, so a count reused across the scope would miss a usage recorded earlier in that scope and let an N-times limit pass again.

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

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, the 24 checks of a page run concurrently as in GetProductOverviewHandler, median of 7 runs per measurement, database queries only. Ranges are over three runs of the benchmark:

page of 24 products before (48 queries) after (24 queries)
NTimes 17.8–26.9 ms 4.5–6.1 ms
NTimesPerUser 8.2–13.9 ms 5.5–6.3 ms

These are localhost timings. With network latency to the database, the 24 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 the discount tests in Grand.Business.Catalog.Tests (51 passed). New tests cover:
    • the NTimes limit, both reached and below
    • NTimesPerUser counting only that customer's usages
    • a usage recorded between two validations in one scope being seen
    • 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.

support added 2 commits October 21, 2026 17:47
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.
The scope-wide count also served order placement, which validates
through the same pricing path as a listing. A usage recorded earlier
in the scope went unseen, so an N-times limit could be passed again.
Every validation now counts the usage history itself.

The tests check that a usage recorded between two validations in one
scope is seen.
@KrzysztofPajak KrzysztofPajak changed the title Count discount usage once per request in limitation checks Count discount usage with one query in limitation checks Oct 21, 2026
@KrzysztofPajak
KrzysztofPajak merged commit 51136ee into develop Oct 21, 2026
9 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the perf/discount-limitation-queries branch October 21, 2026 18:41
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