Skip to content

Execute repository queries on the driver instead of blocking a thread - #776

Merged
KrzysztofPajak merged 11 commits into
developfrom
feature/async-query-materialization
Aug 12, 2026
Merged

Execute repository queries on the driver instead of blocking a thread#776
KrzysztofPajak merged 11 commits into
developfrom
feature/async-query-materialization

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Aug 10, 2026

Copy link
Copy Markdown
Member

Type: feature

Issue

IRepository<T>.Table is an IQueryable over the MongoDB driver. Calling .ToList(),
.FirstOrDefault(), .Any() or .Count() on it issues a blocking round trip and holds a
thread pool thread for its whole duration. The services wrapped exactly that in
Task.FromResult(...), which produces a method that looks asynchronous, awaits nothing, and
blocks anyway.

About 30 of these sat inside a _cacheBase.GetAsync(...) acquire function, where the cache holds
a lock for as long as the acquire runs — so one slow query blocked every caller waiting on that
key, not just its own request.

#771 introduced ToListAsync / CountAsync / PagedAsync on IRepository and wrote the rule
down in .ai/knowledge/async.md ("Never execute a query built on Table synchronously"), but it
only converted the paged paths. This finishes the sweep.

Solution

138 materialisations moved off the thread pool and onto the driver's asynchronous API.

The Task.FromResult(query.ToList()) grep finds 108 hits, but it filters on a variable name,
not on the shape of the defect. A second population — the same call written inline, e.g.
Task.FromResult(_orderRepository.Table.Where(...).ToList()) — accounts for another ~44 and lives
in the same files and often the same methods (OrderService had three of each). Both are in
scope here, so the invariant this PR establishes is greppable and does not depend on what a local
happens to be called: no Task.FromResult over a driver query remains.

Commits are one per business area so each is reviewable on its own, and each was built and tested
before the next started.

  • IRepository gains FirstOrDefaultAsync and AnyAsync, in the same shape as Execute paged queries on the driver instead of blocking a thread #771's methods.
    About 41 call sites end in .FirstOrDefault() / .Any() and had no async equivalent.
    GetOneAsync is not a substitute: it takes only a predicate over T and returns T, so it
    cannot express ordering (LoyaltyPointsService, SlugService.GetBySlug), projections
    (GetActiveSlug returns string), or composed ACL/store filters (KnowledgebaseService,
    PermissionService). (await ToListAsync(q)).FirstOrDefault() was rejected outright — it trades
    a blocked thread for pulling the collection into memory.
  • Methods that were Task<T> without async (GetOrderByNumber, GetOrderByGuid,
    GetOrderNote, GetShipmentNote, GetMerchandiseReturnById, …) become async. Signatures are
    unchanged, so interfaces and callers are untouched.
  • Cache acquire functions keep a trailing .ToList(). ToListAsync returns IList<T>, which
    would silently change the generic argument inferred for ICacheBase.GetAsync<T> from List<T>
    to IList<T>. That copy runs on an already materialised sequence and costs nothing on the wire.
  • Two-stage materialisation is preserved exactly: GetAllCountries still re-sorts in memory by
    translated name, CategoryService still filters through IAclService after the fetch. Neither
    is translatable to a driver query.

Deliberately not touched:

  • The six handlers that return an unmaterialised IQueryable on purpose — GetCustomerQueryHandler,
    GetOrderQueryHandler, GetGiftVoucherQueryHandler, GetMerchandiseReturnQueryHandler,
    GetPaymentTransactionQueryHandler, Grand.Module.Api/GetGenericQueryHandler. Each was checked
    for an executing operator; the caller materialises, e.g. CustomerService runs it through
    PagedAsync.
  • Grand.Data/LiteDb — an in-process provider with no async API, where Task.FromResult is honest
    and already carries a comment saying so.
  • Five sites where Task.FromResult wraps a value that is already in memory and the blocking
    call is a separate statement above it (SendNotificationsToSubscribersCommandHandler,
    KnowledgebaseService 168/518, ScheduleTaskService.GetTaskByName). Those belong to the
    follow-up below; keeping the boundary consistent mattered more than making one grep read empty.

Breaking changes

None.

IRepository<T> gains two members. It is an internal data-access abstraction implemented only by
MongoRepository<T> and LiteDBRepository<T>, both updated here. No plugin in src/Plugins
implements it. Every service signature, view model, route, cache key, permission and localization
key is unchanged; the only public-surface edits are Task<T> methods gaining async, which is
not observable to callers.

Out-of-tree code implementing IRepository<T> directly would need the two new members — no such
implementation exists in this repository.

Testing

Full suite, no test modified, weakened or deleted:

  1. Start a local MongoDB on localhost:27017 (the business test projects run against a real
    driver, via MongoDBRepositoryTest<T> — this is what makes the sweep verifiable: a conversion
    that stopped being a server-side query throws instead of quietly working).
  2. Stop IIS Express, or Grand.Web fails with MSB3021 on locked DLLs.
  3. dotnet test GrandNode.sln
    Expected: 21/21 projects green, 1856 passed, 0 failed, 5 skipped.
  4. Per-area check used while developing, e.g.
    dotnet build src/Business/Grand.Business.Catalog/Grand.Business.Catalog.csproj
    dotnet test src/Tests/Grand.Business.Catalog.Tests
  5. Confirm the invariant holds:
    rg -U --multiline --multiline-dotall \
      'Task\.FromResult\([^;]*?\.(Table|TableCollection<[^>]+>\(\))[^;]*?\.(ToList|FirstOrDefault|Any|Count)\(' \
      --glob '!src/Tests/**' --glob '!src/Plugins/**' --glob '!src/Core/Grand.Data/LiteDb/**' src/
    
    Expected: no output.

Note that Customers / Marketing / Messages have flaked on full parallel runs in the past.
They passed here in one parallel solution-wide run; if they fail for you, re-run them individually
before treating it as a regression.

Follow-ups (found, deliberately out of scope)

  1. 49 naked materialisations in 38 filesvar x = _repo.Table.Where(...).ToList(); with no
    Task.FromResult at all (EmailAccountService.cs:113, CourseService, QueuedEmailService,
    AuctionService.GetLatestBid). Same defect, different shape; several of the enclosing methods
    are not async, and it needs ~6 Moq-based tests adapted — EmailAccountServiceTests stubs only
    Table and will break. Kept separate to keep this PR reviewable.
  2. 128 synchronous .Any() / .FirstOrDefault() across 24 Grand.Module.Api controllers
    the consumer side of the deliberately deferred GetGenericQueryHandler. The handler is correct;
    the controllers block on enumeration.

🤖 Generated with Claude Code

KrzysztofPajak and others added 9 commits August 10, 2026 21:25
The services materialise single-document and existence queries with
query.FirstOrDefault() and query.Any() on Table, which blocks a thread pool
thread for the whole round trip. #771 gave ToListAsync/CountAsync/PagedAsync
the same treatment; these two complete the set.

GetOneAsync is not a substitute: it takes only a predicate over T and returns
T, so it cannot express ordering, projections, or composed ACL and store
filters - the shapes most of these call sites use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task.FromResult(query.ToList()) on a Table query blocks a thread pool thread
for the whole round trip. Most of these sit inside a cache acquire function,
where the cache holds a lock for the duration.

Methods declared Task<List<T>> keep their signature - ToListAsync returns
IList<T>, so the result is copied with ToList() on the already materialised
sequence, which costs nothing on the wire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same change as the Cms sweep, applied to the catalog services.

Cache acquire functions keep the trailing ToList() so the generic argument
inferred for ICacheBase.GetAsync stays List<T> rather than silently becoming
IList<T>. That copy is on an already materialised sequence and costs nothing
on the wire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Methods that were Task<T> without async - GetOrderByNumber, GetOrderByGuid,
GetOrderItemByGuid, GetOrderNote, GetShipmentNote, GetMerchandiseReturnById,
GetMerchandiseReturnNote - become async. Their signatures are unchanged, so
the interfaces and every caller stay as they were.

GetMerchandiseReturnCountQueryHandler forwards the CancellationToken it
already receives, per the mediator handler rule in .ai/knowledge/async.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GetActiveSlug keeps its ?? "" - FirstOrDefaultAsync on a projection to string
returns null when nothing matches, exactly as FirstOrDefault did.

GetAllCountries keeps the second, in-memory sort by translated name: it runs
on the materialised list because GetTranslation is not translatable to a
driver query.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GetCustomerQueryHandler is left alone: it returns the IQueryable
unmaterialised on purpose, and CustomerService runs it through PagedAsync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Table.Count(predicate) and Table.FirstOrDefault(predicate) become the
predicate as a Where() handed to CountAsync / FirstOrDefaultAsync, which is
the same query with the execution moved onto the driver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers Messages, Storage, Authentication, Grand.Web.Common and the scheduled
task module.

The two cache acquire functions here were plain Func rather than async
lambdas; they become async so the query can be awaited, and keep returning
the same concrete type so the generic argument on ICacheBase.GetAsync is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Missed in the Customers pass - the call spans six lines and fell outside the
window the sweep scanned with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 10, 2026 20:03

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.

Eliminated unnecessary .ToList() calls following awaited ToListAsync in multiple service classes. This streamlines the code and avoids redundant list conversions, as ToListAsync already returns a list.
Refactored unit tests to use IList<T> instead of List<T> for cache-related GetAsync method setups and verifications. Ensures consistency with updated method signatures across multiple service test classes.
@KrzysztofPajak
KrzysztofPajak merged commit 81ff8e2 into develop Aug 12, 2026
4 of 5 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the feature/async-query-materialization branch August 12, 2026 15:44
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