Execute repository queries on the driver instead of blocking a thread - #776
Merged
Conversation
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>
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.
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: feature
Issue
IRepository<T>.Tableis anIQueryableover the MongoDB driver. Calling.ToList(),.FirstOrDefault(),.Any()or.Count()on it issues a blocking round trip and holds athread pool thread for its whole duration. The services wrapped exactly that in
Task.FromResult(...), which produces a method that looks asynchronous, awaits nothing, andblocks anyway.
About 30 of these sat inside a
_cacheBase.GetAsync(...)acquire function, where the cache holdsa 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/PagedAsynconIRepositoryand wrote the ruledown in
.ai/knowledge/async.md("Never execute a query built onTablesynchronously"), but itonly 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 livesin the same files and often the same methods (
OrderServicehad three of each). Both are inscope here, so the invariant this PR establishes is greppable and does not depend on what a local
happens to be called: no
Task.FromResultover 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.
IRepositorygainsFirstOrDefaultAsyncandAnyAsync, 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.GetOneAsyncis not a substitute: it takes only a predicate overTand returnsT, so itcannot express ordering (
LoyaltyPointsService,SlugService.GetBySlug), projections(
GetActiveSlugreturnsstring), or composed ACL/store filters (KnowledgebaseService,PermissionService).(await ToListAsync(q)).FirstOrDefault()was rejected outright — it tradesa blocked thread for pulling the collection into memory.
Task<T>withoutasync(GetOrderByNumber,GetOrderByGuid,GetOrderNote,GetShipmentNote,GetMerchandiseReturnById, …) becomeasync. Signatures areunchanged, so interfaces and callers are untouched.
.ToList().ToListAsyncreturnsIList<T>, whichwould silently change the generic argument inferred for
ICacheBase.GetAsync<T>fromList<T>to
IList<T>. That copy runs on an already materialised sequence and costs nothing on the wire.GetAllCountriesstill re-sorts in memory bytranslated name,
CategoryServicestill filters throughIAclServiceafter the fetch. Neitheris translatable to a driver query.
Deliberately not touched:
IQueryableon purpose —GetCustomerQueryHandler,GetOrderQueryHandler,GetGiftVoucherQueryHandler,GetMerchandiseReturnQueryHandler,GetPaymentTransactionQueryHandler,Grand.Module.Api/GetGenericQueryHandler. Each was checkedfor an executing operator; the caller materialises, e.g.
CustomerServiceruns it throughPagedAsync.Grand.Data/LiteDb— an in-process provider with no async API, whereTask.FromResultis honestand already carries a comment saying so.
Task.FromResultwraps a value that is already in memory and the blockingcall is a separate statement above it (
SendNotificationsToSubscribersCommandHandler,KnowledgebaseService168/518,ScheduleTaskService.GetTaskByName). Those belong to thefollow-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 byMongoRepository<T>andLiteDBRepository<T>, both updated here. No plugin insrc/Pluginsimplements it. Every service signature, view model, route, cache key, permission and localization
key is unchanged; the only public-surface edits are
Task<T>methods gainingasync, which isnot observable to callers.
Out-of-tree code implementing
IRepository<T>directly would need the two new members — no suchimplementation exists in this repository.
Testing
Full suite, no test modified, weakened or deleted:
localhost:27017(the business test projects run against a realdriver, via
MongoDBRepositoryTest<T>— this is what makes the sweep verifiable: a conversionthat stopped being a server-side query throws instead of quietly working).
Grand.Webfails with MSB3021 on locked DLLs.dotnet test GrandNode.slnExpected: 21/21 projects green, 1856 passed, 0 failed, 5 skipped.
dotnet build src/Business/Grand.Business.Catalog/Grand.Business.Catalog.csprojdotnet test src/Tests/Grand.Business.Catalog.TestsNote that
Customers/Marketing/Messageshave 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)
var x = _repo.Table.Where(...).ToList();with noTask.FromResultat all (EmailAccountService.cs:113,CourseService,QueuedEmailService,AuctionService.GetLatestBid). Same defect, different shape; several of the enclosing methodsare not
async, and it needs ~6 Moq-based tests adapted —EmailAccountServiceTestsstubs onlyTableand will break. Kept separate to keep this PR reviewable..Any()/.FirstOrDefault()across 24Grand.Module.Apicontrollers —the consumer side of the deliberately deferred
GetGenericQueryHandler. The handler is correct;the controllers block on enumeration.
🤖 Generated with Claude Code