Skip to content

Await the product counters and cut recently viewed round trips - #968

Merged
KrzysztofPajak merged 8 commits into
developfrom
perf/product-view-writes
Oct 21, 2026
Merged

KrzysztofPajak merged 8 commits into
developfrom
perf/product-view-writes

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

What

A product page view did more work than it needed to:

  • The view counter was fire-and-forget. ProductController called _ = _productService.IncrementProductField(product, x => x.Viewed, 1) in ProductDetails and in the quick-view action. A failed write was an unobserved task exception that nobody saw. PlaceOrderCommandHandler did the same with the Sold counter.
  • Recently viewed did 3–4 round trips. AddProductToRecentlyViewedList deleted the product's row, inserted it again, queried for rows over the limit and then deleted them.
  • The recently viewed list was sorted in memory. It is read by CustomerId and ordered by CreatedOnUtc, but the only compound index is CustomerId, ProductId, CreatedOnUtc.

Changes:

  • Both Viewed increments are awaited through a CountProductView helper. A failed write is logged as a warning, and the page still renders. It is still one $inc per view; the counter is not buffered.

  • The Sold increment in PlaceOrderCommandHandler is awaited the same way: a failed write is logged and the order is still saved. The write runs after the payment is processed, so it must not stop the order.

  • AddProductToRecentlyViewedList reads the customer's rows once (Id, ProductId, newest first). It then inserts the viewed product and runs one DeleteManyAsync for:

    • the earlier rows of the same product (two concurrent first views can leave more than one);
    • the oldest rows beyond the limit.

    Insert-then-delete means the viewed product is never missing from the list, even when a concurrent view trims its old row. CreatedOnUtc is always set by the repository's audit clock. The list is trimmed on every view, so a list over the limit (lowered RecentlyViewedProductsNumber, or two concurrent new-product views that deleted the same oldest row) shrinks on the next view.

  • New index CustomerId.CreatedOnUtc (CustomerId asc, CreatedOnUtc desc, _id desc) on RecentlyViewedProduct. It is created by the installer and by the 2.5 migration MigrationRecentlyViewedProductCustomerIndex. The existing indexes stay.

IRepository and IRecentlyViewedProductsService are unchanged. ProductController gets an ILogger<ProductController> constructor parameter.

Round trips per product view (recently viewed)

Case Before After
Product already in the list delete + insert + trim query (+ delete) = 3–4 read + insert + delete = 3
New product, list not full delete + insert + trim query = 3 read + insert = 2
New product, list full delete + insert + trim query + delete = 4 read + insert + delete = 3

Behavior changes

  • The view counter is now awaited, which adds one database round trip to the response time of each product view on MongoDB. On LiteDB it was already synchronous.
  • A failed view counter write is now logged instead of being lost; the page is unaffected.
  • A failed Sold write is now logged instead of being lost; order placement is unaffected.
  • Two concurrent views of new products can leave the list one row over the limit until the customer's next view trims it.

Not changed

  • On LiteDB, IncField reads, modifies and writes the document, so concurrent views can still lose increments. Awaiting the call does not change that.

Testing

  1. RecentlyViewedProductsServiceTests: 10/10. Tests cover:
    • a re-viewed product keeps a single row;
    • duplicate rows for one product collapse to one row at the top;
    • a lowered limit trims the list, on a new product and on a re-viewed one.
  2. ProductControllerPreviewTests: 7/7. They cover the view being counted once, and the page still rendering when the counter write fails.
  3. dotnet test src/Tests/Grand.Business.Checkout.Tests: 292/292. The Sold guard has no dedicated test, because reaching SaveOrderDetails needs a full order setup.
  4. Grand.Module.Migration builds.

Not done: the races are not covered by unit tests, the migration was not run against a real database, the storefront was not run, and nothing was timed under load.

Copilot AI balanced review requested due to automatic review settings October 21, 2026 14:29

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 4 commits October 21, 2026 16:57
…ry view

Updating the row in place overwrote CreatedOnUtc outside the audit clock, could leave the product off the list when a concurrent view trimmed its row, and never shrank a list that was over the limit. The product is inserted before the stale rows are deleted, so it is never missing.
The CustomerId.ProductId index cannot serve the list order, so the customer's list was sorted in memory on every product view.
The counter is statistics only; a failed write is logged instead of failing the page.
@KrzysztofPajak KrzysztofPajak changed the title Await the product view counter and cut recently viewed round trips Await the product counters and cut recently viewed round trips Oct 21, 2026
Comment thread src/Web/Grand.Web/Controllers/ProductController.cs Dismissed
The counter is written after the payment is processed and before the order is saved, so a failed write left a paid order unsaved. It is statistics only and is now logged instead.
@KrzysztofPajak
KrzysztofPajak marked this pull request as draft October 21, 2026 15:28
@KrzysztofPajak
KrzysztofPajak marked this pull request as ready for review October 21, 2026 15:28
Comment on lines +995 to +998
catch (Exception exc)
{
_logger.LogWarning(exc, "Could not update the sold count of product {ProductId}", product.Id);
}
@KrzysztofPajak
KrzysztofPajak merged commit 028ebfe into develop Oct 21, 2026
12 of 14 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the perf/product-view-writes branch October 21, 2026 18:26
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