Feat: Search all - #3070
Conversation
|
Review task created: https://virtocommerce.atlassian.net/browse/VCST-5411 |
|
Thanks for this — the underlying optimization is genuinely valuable. Replacing the 1. The optimization is applied automatically to every derived service, including ones it isn't safe forThe generic base // SearchServiceExtensions.cs:27
if (searchService is IExtendedSearchService<TCriteria, TResult, TModel> extendedSearchService)
{
return await extendedSearchService.SearchAllAsync(searchCriteria, clone);
}Because the base class implements the interface, every derived service inherits it, and The fast path ( That is correct only if a service's result membership is fully determined by 2. Concrete regression in a shipped module:
|
|
Notes about the "6. Issues worth addressing regardless of the activation decision" comments:
|
Real-life SearchAll examples:ShippingMethod seeding on module post-initNotes: only one field is used Cart items enrichment, config sections-based logicNotes: similar code is used across multiple places; can be replaced with a batched iterations (but performance win is questionable as Dictionary benefits may be lost) or 'limit 1 queries'; only 3-4 fields from nested models are used Setting a wishlist ("project") as defaultNote: can be replaced with a batched iteration; only one field is updated Reading data from product variations, e.g. design placement codeProduct options pre-selection based on catalog dataCategories import (existing data reading)Product inventory data importProduct properties-based logicOther considerationsProducts (main products) can contain 500+ variations (colors X sizes) that are rendered within one UI page - implicit "search all" is possible from there via large |
|
❌ The last analysis has failed. |
|



Description
Problem
SearchAllAsync/SearchBatchesAsyncextension methods read "all data" by pagingSearchAsyncwith growing
Skip. On the relationalSearchServiceevery batch executes:Skiprows (growing per batch);COUNT(*)query over the whole filtered set —SearchIdsNoCacheAsyncforces it on everybatch of a multi-page sweep (
Skip > 0, and the first batch too when the page comes back full).For
Bbatches that is ~2×Bfull evaluations of the search query. Measured on PostgreSQL 17(500k-row table, non-indexed filter matching 1,000 rows,
Take = 100→ 10 batches):~21× slower and exactly 2B× the I/O — and with the default
Take = 20the multiplier is ~50×.Offset paging is also not stable under concurrent writes (rows shifting across page boundaries get
skipped or duplicated between batches), and a configured
MaxResultWindowmakes deep sweeps throwmid-iteration.
Solution
New
IExtendedSearchService<TCriteria, TResult, TModel> : ISearchService<...>with four members —all ignore
Skip/Takeand accept aCancellationToken:SearchAllAsync(criteria, clone, ct)SearchAllIdsAsync(criteria, ct)CountAllAsync(criteria, ct)COUNTqueryExistsAsync(criteria, ct)EXISTS(EFAnyAsync), not count-basedThe generic
SearchService<TCriteria, TResult, TModel, TEntity>implements the interface, so everyderived search service gets the optimization automatically — no module changes required:
SearchAllAsync= one ids query built from the service's ownBuildQuery/GetOrderedQueryAsync(no count, no offsets) → models loaded by ids in batches of
CrudOptions.SearchAllBatchSize(default 500) via the CRUD service (per-id cache preserved) →
ProcessSearchResultAsyncruns perbatch, as in the regular pipeline. Result membership is a snapshot of the ids query — immune to
page drift.
SearchAllIdsAsyncis public with aprotected SearchAllIdsNoCacheAsyncextension point,mirroring the existing
SearchIdsNoCacheAsyncpattern.GenericSearchCachingRegion<TModel>tokens — the same region theSearchAsyncpage cache uses, expired byCrudServiceon every save/delete.The
SearchAllAsyncextension now delegates toIExtendedSearchServicewhen the serviceimplements it, so all existing call sites — including already-compiled module binaries — get the
fast path with no code changes. Services not implementing the interface keep the previous paged
loop. The
SearchBatchesAsyncextension (streaming, batch-by-batch iteration) is intentionallyunchanged.
Compatibility
ISearchServiceis unchanged; all extension method signatures are unchanged → source- andbinary-compatible. Old compiled modules load fine (inherited implementations satisfy the grown
interface closure at type load).
SearchAllAsynccallers only:Skip/Takeare now ignored (previously the initialSkipwas honored andTake = 0returned an empty list). Use
CountAllAsyncfor counting.Rules for derived services
SearchAsyncshould consider overridingthe
SearchAll*/CountAll/Existsmembers to keep them consistent.IExtendedSearchServicemust ship with a default interfaceimplementation, so direct implementers keep compiling and loading.
Out of scope / follow-ups
PriceSearchService/NotificationLayoutSearchService/ etc overrides (seeSearchAsyncoverriding note above ).References
QA-test:
Jira-link:
Artifact URL:
Image tag:
ghcr.io/VirtoCommerce/platform:3.1047.0-pr-3070-de73-search-all-de730ca1