From 003d91a40cb90cfd21836145f2374a7b42f15265 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:16:01 +0200 Subject: [PATCH 1/3] Cache the total and filter options of a storefront product listing A category with tens of thousands of products spent ~3 s per request counting the matches and aggregating their filterable specification options, on every page and sort order. Both depend on the filters only, so a storefront listing now reads them from a cache entry (5 minutes, key = hash of every filter incl. store and customer groups) while the page itself is still queried live. A short page keeps an exact total. Staff lists (ShowHidden) are not cached. Product, category/collection mapping, specification mapping and tag writes clear the listing entries. --- .../Handlers/GetSearchProductsQueryHandler.cs | 96 +++++++++++ .../Categories/ProductCategoryService.cs | 3 + .../Collections/ProductCollectionService.cs | 3 + .../Services/Products/ProductService.cs | 2 + .../Services/Products/ProductTagService.cs | 2 + .../Products/SpecificationAttributeService.cs | 3 + .../Caching/Constants/ProductCacheKey.cs | 14 ++ .../GetSearchProductsQueryHandlerTests.cs | 149 ++++++++++++++++-- ...archProductsQueryHandlerTextSearchTests.cs | 20 ++- .../Categories/CategoryServiceTests.cs | 9 +- 10 files changed, 281 insertions(+), 20 deletions(-) diff --git a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs index d45218416b..1093bddd20 100644 --- a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs +++ b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs @@ -4,9 +4,14 @@ using Grand.Domain; using Grand.Domain.Catalog; using Grand.Domain.Customers; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; using Grand.Infrastructure.Configuration; using Grand.Mediator; using Microsoft.Extensions.Logging; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; namespace Grand.Business.Catalog.Queries.Handlers; @@ -15,7 +20,11 @@ public class GetSearchProductsQueryHandler : IRequestHandler productRepository, ISpecificationAttributeService specificationAttributeService, + ICacheBase cacheBase, CatalogSettings catalogSettings, AccessControlConfig accessControlConfig, DatabaseConfig databaseConfig, @@ -34,6 +44,7 @@ public GetSearchProductsQueryHandler( { _productRepository = productRepository; _specificationAttributeService = specificationAttributeService; + _cacheBase = cacheBase; _catalogSettings = catalogSettings; _accessControlConfig = accessControlConfig; _databaseConfig = databaseConfig; @@ -89,6 +100,12 @@ public GetSearchProductsQueryHandler( // Apply specification filters (query, querySpecification) = await ApplySpecificationFilters(request, query, querySpecification); + // Staff lists stay live. A storefront listing reads its total and filter options from a short-lived cache: + // both depend on the filters only, not on the page or the order, and each costs a pass over every match + if (!request.ShowHidden) + return await SearchWithCachedSummary(request, allowedCustomerGroupsIds, search, query, querySpecification, + cancellationToken); + // Order the results query = OrderByQueryable(request, query); @@ -122,6 +139,85 @@ public GetSearchProductsQueryHandler( return (await productsTask, await filterableTask); } + private async Task<(IPagedList products, IList filterableSpecificationAttributeOptionIds)> + SearchWithCachedSummary(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds, string search, + IQueryable query, IQueryable querySpecification, CancellationToken cancellationToken) + { + var key = string.Format(CacheKey.PRODUCTS_LISTING_SUMMARY_KEY, + ListingSummaryHash(request, allowedCustomerGroupsIds, search)); + + //same normalization as IRepository.PagedAsync + var pageSize = request.PageSize <= 0 ? 1 : request.PageSize; + var skip = (int)Math.Clamp((long)request.PageIndex * pageSize, 0, int.MaxValue); + + using var queriesCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var itemsTask = CancelOnFailure( + _productRepository.ToListAsync(OrderByQueryable(request, query).Skip(skip).Take(pageSize), + queriesCancellation.Token), + queriesCancellation); + + //a miss is computed once; concurrent requests for the same listing wait for it instead of repeating the scan + var summaryTask = CancelOnFailure( + _cacheBase.GetAsync(key, + () => LoadListingSummary(request, query, querySpecification, queriesCancellation.Token), + ListingSummaryCacheMinutes), + queriesCancellation); + + try + { + await Task.WhenAll(itemsTask, summaryTask); + } + catch when (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + throw; + } + + var items = await itemsTask; + var summary = await summaryTask; + + //a short page is the last one and gives the exact total; otherwise the cached total may lag up to its lifetime + var totalCount = items.Count < pageSize && (items.Count > 0 || skip == 0) + ? skip + items.Count + : summary.TotalCount; + + return (new PagedList(items, request.PageIndex, pageSize, totalCount), + summary.FilterableSpecificationAttributeOptionIds.ToList()); + } + + private async Task LoadListingSummary(GetSearchProductsQuery request, IQueryable query, + IQueryable querySpecification, CancellationToken cancellationToken) + { + var countTask = _productRepository.CountAsync(query, cancellationToken); + var filterableTask = ShouldLoadFilterableSpecifications(request) + ? GetFilterableSpecificationAttributeOptionIds(querySpecification, cancellationToken) + : Task.FromResult>(new List()); + + await Task.WhenAll(countTask, filterableTask); + + return new ListingSummary(await countTask, (await filterableTask).ToArray()); + } + + /// + /// Hash of every request value that changes which products match; page and order are left out on purpose + /// + private string ListingSummaryHash(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds, string search) + { + static string Set(IEnumerable values) + { + return values == null ? "" : string.Join(",", values.Order(StringComparer.Ordinal)); + } + + //invariant culture: the request culture follows the customer's language and would split one listing in two + var filters = string.Create(CultureInfo.InvariantCulture, + $"{Set(request.CategoryIds)}|{request.FeaturedProducts}|{request.BrandId}|{request.CollectionId}|{request.StoreId}|{request.VendorId}|{request.WarehouseId}|{request.ProductType}|{request.VisibleIndividuallyOnly}|{request.MarkedAsNewOnly}|{request.ShowOnHomePage}|{request.OverridePublished}|{request.PriceMin}|{request.PriceMax}|{request.Rating}|{request.ProductTag}|{Set(allowedCustomerGroupsIds)}|{Set(request.FilteredSpecs)}|{Set(request.SpecificationOptions)}|{ShouldLoadFilterableSpecifications(request)}|{request.SearchDescriptions}|{request.SearchSku}|{search != null}|{request.Keywords}"); + + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(filters))); + } + + private sealed record ListingSummary(int TotalCount, string[] FilterableSpecificationAttributeOptionIds); + private static async Task CancelOnFailure(Task task, CancellationTokenSource cancellation) { try diff --git a/src/Business/Grand.Business.Catalog/Services/Categories/ProductCategoryService.cs b/src/Business/Grand.Business.Catalog/Services/Categories/ProductCategoryService.cs index 6dba399179..971e7a1ca0 100644 --- a/src/Business/Grand.Business.Catalog/Services/Categories/ProductCategoryService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Categories/ProductCategoryService.cs @@ -103,6 +103,7 @@ public virtual async Task InsertProductCategory(ProductCategory productCategory, //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCATEGORIES_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityInserted(productCategory); @@ -123,6 +124,7 @@ await _productRepository.UpdateCollectionFieldItem(productId, x => x.ProductCate //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCATEGORIES_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityUpdated(productCategory); @@ -142,6 +144,7 @@ public virtual async Task DeleteProductCategory(ProductCategory productCategory, //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCATEGORIES_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityDeleted(productCategory); diff --git a/src/Business/Grand.Business.Catalog/Services/Collections/ProductCollectionService.cs b/src/Business/Grand.Business.Catalog/Services/Collections/ProductCollectionService.cs index 9a2f8bac02..7000437eaa 100644 --- a/src/Business/Grand.Business.Catalog/Services/Collections/ProductCollectionService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Collections/ProductCollectionService.cs @@ -98,6 +98,7 @@ public virtual async Task InsertProductCollection(ProductCollection productColle //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCOLLECTIONS_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityInserted(productCollection); @@ -118,6 +119,7 @@ await _productRepository.UpdateCollectionFieldItem(productId, x => x.ProductColl //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCOLLECTIONS_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityUpdated(productCollection); @@ -137,6 +139,7 @@ public virtual async Task DeleteProductCollection(ProductCollection productColle //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCOLLECTIONS_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityDeleted(productCollection); diff --git a/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs b/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs index 60cae6591f..9bf207bc5c 100644 --- a/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs @@ -363,6 +363,7 @@ public virtual async Task UpdateProduct(Product product) await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_CUSTOMER_PERSONAL_PATTERN); await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_CUSTOMER_GROUP_PATTERN); await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_HOMEPAGE_PATTERN); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_CUSTOMER_TAG_PATTERN); //event notification @@ -429,6 +430,7 @@ public virtual async Task UnPublishProduct(Product product) await _productRepository.UpdateOneAsync(x => x.Id == product.Id, update); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, product.Id)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event await _mediator.Publish(new ProductUnPublishEvent(product)); diff --git a/src/Business/Grand.Business.Catalog/Services/Products/ProductTagService.cs b/src/Business/Grand.Business.Catalog/Services/Products/ProductTagService.cs index de83876e78..de52569e29 100644 --- a/src/Business/Grand.Business.Catalog/Services/Products/ProductTagService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Products/ProductTagService.cs @@ -190,6 +190,7 @@ public virtual async Task AttachProductTag(ProductTag productTag, string product //cache await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTTAG_PATTERN_KEY); //event notification @@ -212,6 +213,7 @@ public virtual async Task DetachProductTag(ProductTag productTag, string product //cache await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTTAG_PATTERN_KEY); //event notification diff --git a/src/Business/Grand.Business.Catalog/Services/Products/SpecificationAttributeService.cs b/src/Business/Grand.Business.Catalog/Services/Products/SpecificationAttributeService.cs index 86b414ebe7..6686a3b645 100644 --- a/src/Business/Grand.Business.Catalog/Services/Products/SpecificationAttributeService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Products/SpecificationAttributeService.cs @@ -230,6 +230,7 @@ await _productRepository.AddToCollectionField(productId, x => x.ProductSpecifica //cache await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityInserted(productSpecificationAttribute); @@ -249,6 +250,7 @@ public virtual async Task UpdateProductSpecificationAttribute( //cache await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityUpdated(productSpecificationAttribute); @@ -268,6 +270,7 @@ public virtual async Task DeleteProductSpecificationAttribute( //clear cache await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityDeleted(productSpecificationAttribute); diff --git a/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs b/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs index f2a5553cbe..dd4becd967 100644 --- a/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs +++ b/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs @@ -15,6 +15,20 @@ public static partial class CacheKey /// public static string PRODUCTS_BY_ID_KEY => "Grand.product.id-{0}"; + /// + /// Key for caching the total count and the filterable specification option ids of a storefront product listing + /// + /// + /// {0} : hash of every listing filter - categories, brand, collection, store, customer groups, specifications, + /// price, rating, keywords, flags + /// + public static string PRODUCTS_LISTING_SUMMARY_KEY => "Grand.product.listing-{0}"; + + /// + /// Key pattern to clear cache + /// + public static string PRODUCTS_LISTING_PATTERN_KEY => "Grand.product.listing"; + /// /// Key for caching /// diff --git a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs index 35db872fa4..6dabd5dd05 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs @@ -6,6 +6,10 @@ using Grand.Domain; using Grand.Domain.Catalog; using Grand.Domain.Customers; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; +using Grand.Infrastructure.Tests.Caching; +using Grand.Mediator; using Microsoft.Extensions.Logging.Abstractions; using Grand.Infrastructure.Configuration; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -16,6 +20,7 @@ namespace Grand.Business.Catalog.Tests.Queries.Handlers; [TestClass] public class GetSearchProductsQueryHandlerTests { + private ICacheBase _cache; private IRepository _repository; private GetSearchProductsQueryHandler handler; @@ -23,7 +28,8 @@ public class GetSearchProductsQueryHandlerTests public void Init() { _repository = new MongoDBRepositoryTest(); - handler = new GetSearchProductsQueryHandler(_repository, new Mock().Object, + _cache = Cache(); + handler = new GetSearchProductsQueryHandler(_repository, new Mock().Object, _cache, new CatalogSettings { IgnoreFilterableSpecAttributeOption = true }, new AccessControlConfig(), new DatabaseConfig(), Mock.Of(), NullLogger.Instance); @@ -100,7 +106,7 @@ public async Task Handle_WhenFilterableSpecificationsRequested_ReturnsDistinctFi { //Arrange var filteringHandler = new GetSearchProductsQueryHandler(_repository, - new Mock().Object, + new Mock().Object, Cache(), new CatalogSettings { IgnoreFilterableSpecAttributeOption = false }, new AccessControlConfig(), new DatabaseConfig(), Mock.Of(), NullLogger.Instance); @@ -143,13 +149,14 @@ public async Task Handle_WhenFilterableSpecificationsRequested_StartsBothQueries var optionsStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var repository = new Mock>(); repository.Setup(x => x.Table).Returns(new List().AsQueryable()); - repository.Setup(x => x.PagedAsync(It.IsAny>(), It.IsAny(), It.IsAny(), - It.IsAny())) + repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) .Returns(async () => { pageStarted.TrySetResult(); await optionsStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); - return new PagedList([new Product { Id = "1" }], 0, 10, 1); + return new List { new() { Id = "1" } }; }); + repository.Setup(x => x.CountAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(1); repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) .Returns(async () => { optionsStarted.TrySetResult(); @@ -157,7 +164,7 @@ public async Task Handle_WhenFilterableSpecificationsRequested_StartsBothQueries return new List { "red" }; }); var filteringHandler = new GetSearchProductsQueryHandler(repository.Object, - new Mock().Object, + new Mock().Object, Cache(), new CatalogSettings { IgnoreFilterableSpecAttributeOption = false }, new AccessControlConfig(), new DatabaseConfig(), Mock.Of(), NullLogger.Instance); @@ -178,8 +185,7 @@ public async Task Handle_WhenPageQueryFails_CancelsTheFilterableQueryAndRethrows var optionsToken = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var repository = new Mock>(); repository.Setup(x => x.Table).Returns(new List().AsQueryable()); - repository.Setup(x => x.PagedAsync(It.IsAny>(), It.IsAny(), It.IsAny(), - It.IsAny())) + repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) .Returns(async () => { //fail once the options query is running await optionsToken.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -212,12 +218,11 @@ public async Task Handle_WhenRequestCancelledAndOtherQueryFaults_ThrowsCancelled using var cancellation = new CancellationTokenSource(); var repository = new Mock>(); repository.Setup(x => x.Table).Returns(new List().AsQueryable()); - repository.Setup(x => x.PagedAsync(It.IsAny>(), It.IsAny(), It.IsAny(), - It.IsAny())) - .Returns(async (IQueryable _, int _, int _, CancellationToken token) => { + repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) + .Returns(async (IQueryable _, CancellationToken token) => { if (Interlocked.Increment(ref started) == 2) await cancellation.CancelAsync(); await Task.Delay(Timeout.Infinite, token); - return new PagedList([], 0, 10, 0); + return new List(); }); repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) .Returns(async (IQueryable _, CancellationToken token) => { @@ -237,15 +242,127 @@ public async Task Handle_WhenRequestCancelledAndOtherQueryFaults_ThrowsCancelled //Act & Assert await Assert.ThrowsAsync(() => filteringHandler.Handle(searchProductsQuery, cancellation.Token).WaitAsync(TimeSpan.FromSeconds(5))); - repository.Verify(x => x.PagedAsync(It.IsAny>(), It.IsAny(), It.IsAny(), - It.IsAny()), Times.Once); + repository.Verify(x => x.ToListAsync(It.IsAny>(), It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task Handle_StorefrontListingWithFullPage_ReusesTheCachedTotal() + { + //Arrange + await InsertVisibleProducts(3); + await handler.Handle(Listing(pageSize: 2), CancellationToken.None); + await InsertVisibleProducts(1); + //Act + var result = await handler.Handle(Listing(pageSize: 2), CancellationToken.None); + //Assert + Assert.AreEqual(3, result.products.TotalCount); + } + + [TestMethod] + public async Task Handle_StorefrontListingWithShortPage_ReturnsTheExactTotal() + { + //Arrange + await InsertVisibleProducts(3); + await handler.Handle(Listing(pageSize: 10), CancellationToken.None); + await InsertVisibleProducts(1); + //Act + var result = await handler.Handle(Listing(pageSize: 10), CancellationToken.None); + //Assert + Assert.AreEqual(4, result.products.TotalCount); + Assert.AreEqual(4, result.products.Count); + } + + [TestMethod] + public async Task Handle_ListingPatternCleared_CountsAgain() + { + //Arrange + await InsertVisibleProducts(3); + await handler.Handle(Listing(pageSize: 2), CancellationToken.None); + await InsertVisibleProducts(1); + await _cache.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); + //Act + var result = await handler.Handle(Listing(pageSize: 2), CancellationToken.None); + //Assert + Assert.AreEqual(4, result.products.TotalCount); + } + + [TestMethod] + public async Task Handle_ShowHidden_CountsLive() + { + //Arrange + await InsertVisibleProducts(3); + await handler.Handle(new GetSearchProductsQuery { ShowHidden = true, PageSize = 2 }, CancellationToken.None); + await InsertVisibleProducts(1); + //Act + var result = await handler.Handle(new GetSearchProductsQuery { ShowHidden = true, PageSize = 2 }, + CancellationToken.None); + //Assert + Assert.AreEqual(4, result.products.TotalCount); + } + + [TestMethod] + public async Task Handle_DifferentCustomerGroups_DoNotShareTheCachedTotal() + { + //Arrange + await InsertVisibleProducts(2); + await _repository.InsertAsync(new Product { + Published = true, VisibleIndividually = true, LimitedToGroups = true, CustomerGroups = ["members"] + }); + //Act + var members = await handler.Handle( + new GetSearchProductsQuery { CustomerGroupIds = ["members"], PageSize = 1 }, CancellationToken.None); + var guests = await handler.Handle( + new GetSearchProductsQuery { CustomerGroupIds = ["guests"], PageSize = 1 }, CancellationToken.None); + //Assert + Assert.AreEqual(3, members.products.TotalCount); + Assert.AreEqual(2, guests.products.TotalCount); + } + + [TestMethod] + public async Task Handle_DifferentCategories_DoNotShareTheCachedTotal() + { + //Arrange + await InsertVisibleProducts(2, "shoes"); + await InsertVisibleProducts(3, "bags"); + //Act + var shoes = await handler.Handle(Listing(pageSize: 1, categoryId: "shoes"), CancellationToken.None); + var bags = await handler.Handle(Listing(pageSize: 1, categoryId: "bags"), CancellationToken.None); + //Assert + Assert.AreEqual(2, shoes.products.TotalCount); + Assert.AreEqual(3, bags.products.TotalCount); + } + + private static GetSearchProductsQuery Listing(int pageSize, string categoryId = null) + { + return new GetSearchProductsQuery { + Customer = new Customer(), PageSize = pageSize, + CategoryIds = categoryId == null ? null : [categoryId] + }; + } + + private async Task InsertVisibleProducts(int count, string categoryId = null) + { + for (var i = 0; i < count; i++) + { + var product = new Product { Published = true, VisibleIndividually = true }; + if (categoryId != null) + product.ProductCategories.Add(new ProductCategory { CategoryId = categoryId }); + await _repository.InsertAsync(product); + } } private static GetSearchProductsQueryHandler FilteringHandler(IRepository repository, DatabaseConfig databaseConfig, IDatabaseContext databaseContext) { - return new GetSearchProductsQueryHandler(repository, new Mock().Object, + return new GetSearchProductsQueryHandler(repository, new Mock().Object, Cache(), new CatalogSettings { IgnoreFilterableSpecAttributeOption = false }, new AccessControlConfig(), databaseConfig, databaseContext, NullLogger.Instance); } -} \ No newline at end of file + + private static ICacheBase Cache() + { + return new MemoryCacheBase(MemoryCacheTest.Get(), Mock.Of(), + new CacheConfig { DefaultCacheTimeMinutes = 1 }); + } +} diff --git a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs index b4b9bb9960..cce107e04e 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs @@ -7,6 +7,9 @@ using Grand.Domain.Catalog; using Grand.Domain.Localization; using Grand.Domain.Customers; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Tests.Caching; +using Grand.Mediator; using Grand.Infrastructure.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -30,7 +33,7 @@ public async Task Init() private GetSearchProductsQueryHandler Handler(bool textSearch = true) { - return new GetSearchProductsQueryHandler(_repository, new Mock().Object, + return new GetSearchProductsQueryHandler(_repository, new Mock().Object, Cache(), new CatalogSettings { IgnoreFilterableSpecAttributeOption = true }, new AccessControlConfig(), new DatabaseConfig { UseProductTextSearch = textSearch }, _databaseContext, NullLogger.Instance); @@ -194,5 +197,20 @@ public override Task> PagedAsync(IQueryable> ToListAsync(IQueryable query, + CancellationToken cancellationToken = default) + { + if (typeof(TResult) == typeof(Product)) + Pipeline = query.ToString(); + return base.ToListAsync(query, cancellationToken); + } + } + + private static ICacheBase Cache() + { + return new MemoryCacheBase(MemoryCacheTest.Get(), Mock.Of(), + new CacheConfig { DefaultCacheTimeMinutes = 1 }); } } diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceTests.cs index c15c82ac32..ff0ef2a279 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceTests.cs @@ -8,6 +8,7 @@ using Grand.Domain.Stores; using Grand.Infrastructure; using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Events; using Grand.SharedKernel.Extensions; @@ -151,8 +152,9 @@ public async Task DeleteProductCategory_InvokreRepositoryAndClearCache() //TODO //collectonMock.Verify(c => c.UpdateOneAsync(It.IsAny>(), It.IsAny>(), null, default(CancellationToken)), Times.Once); _mediatorMock.Verify(c => c.Publish(It.IsAny>(), default), Times.Once); - //clear product cache and ProductCategory - _casheManagerMock.Verify(c => c.RemoveByPrefix(It.IsAny(), true), Times.Exactly(2)); + //clear product cache, ProductCategory and the storefront listing totals + _casheManagerMock.Verify(c => c.RemoveByPrefix(It.IsAny(), true), Times.Exactly(3)); + _casheManagerMock.Verify(c => c.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY, true), Times.Once); } [TestMethod] @@ -171,7 +173,8 @@ public async Task InsertProductCategory_InvokreRepositoryAndClearCache() //TODO //collectonMock.Verify(c => c.UpdateOneAsync(It.IsAny>(), It.IsAny>(), null, default(CancellationToken)), Times.Once); _mediatorMock.Verify(c => c.Publish(It.IsAny>(), default), Times.Once); - _casheManagerMock.Verify(c => c.RemoveByPrefix(It.IsAny(), true), Times.Exactly(2)); + _casheManagerMock.Verify(c => c.RemoveByPrefix(It.IsAny(), true), Times.Exactly(3)); + _casheManagerMock.Verify(c => c.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY, true), Times.Once); } [TestMethod] From 2522a8fc3bf0cb663ae8cfd12a334c4519879bdc Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:37:49 +0200 Subject: [PATCH 2/3] Scope the listing summary cache to listing pages and close its gaps - Listing pages opt in through GetSearchProductsQuery.CacheListingSummary (category, brand, collection, vendor, tag). Sitemap, autocomplete, search, featured and new products count live again, and keywords, specification options, a price range or a fractional rating never reach the cache. - A full page never reports fewer products than it proves exist. - Summary keys carry a version that clearing the listing pattern drops, so a summary computed across an invalidation is stored where no request reads it. - The shared summary no longer runs under the first request's token; every waiter stops on its own cancellation. - Key fields are length-prefixed and specifications counted by the options that resolved, so no tag or id can run into the next field. - One WhenBoth helper runs the page and summary queries for both paths. --- .../Handlers/GetSearchProductsQueryHandler.cs | 220 +++++++++++------- .../Queries/Catalog/GetSearchProductsQuery.cs | 8 + .../Caching/Constants/ProductCacheKey.cs | 13 +- .../GetSearchProductsQueryHandlerTests.cs | 209 ++++++++++++++--- ...archProductsQueryHandlerTextSearchTests.cs | 18 +- .../Handlers/Catalog/GetBrandHandler.cs | 1 + .../Handlers/Catalog/GetCategoryHandler.cs | 1 + .../Handlers/Catalog/GetCollectionHandler.cs | 1 + .../Catalog/GetProductsByTagHandler.cs | 1 + .../Handlers/Catalog/GetVendorHandler.cs | 1 + 10 files changed, 338 insertions(+), 135 deletions(-) diff --git a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs index 1093bddd20..9d2bed370a 100644 --- a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs +++ b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs @@ -23,6 +23,9 @@ public class GetSearchProductsQueryHandler : IRequestHandler>(new List()); - - try - { - await Task.WhenAll(productsTask, filterableTask); - } - catch when (cancellationToken.IsCancellationRequested) - { - //an aborted request may surface as a driver fault of the other query; report it as cancelled - //so Handle does not mistake it for a missing text index - cancellationToken.ThrowIfCancellationRequested(); - throw; - } - - return (await productsTask, await filterableTask); + // The page and the filterable specification option ids are independent, so both queries run at once + return await WhenBoth( + token => _productRepository.PagedAsync(query, request.PageIndex, request.PageSize, token), + token => ShouldLoadFilterableSpecifications(request) + ? GetFilterableSpecificationAttributeOptionIds(querySpecification, token) + : Task.FromResult>(new List()), + cancellationToken); } private async Task<(IPagedList products, IList filterableSpecificationAttributeOptionIds)> - SearchWithCachedSummary(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds, string search, - IQueryable query, IQueryable querySpecification, CancellationToken cancellationToken) + SearchWithCachedSummary(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds, + IList specificationOptionIds, IQueryable query, IQueryable querySpecification, + CancellationToken cancellationToken) { - var key = string.Format(CacheKey.PRODUCTS_LISTING_SUMMARY_KEY, - ListingSummaryHash(request, allowedCustomerGroupsIds, search)); + var version = await _cacheBase.GetAsync(CacheKey.PRODUCTS_LISTING_VERSION_KEY, + () => Task.FromResult(Guid.NewGuid().ToString("N")), ListingVersionCacheMinutes); + var key = string.Format(CacheKey.PRODUCTS_LISTING_SUMMARY_KEY, version, + ListingSummaryHash(request, allowedCustomerGroupsIds, specificationOptionIds)); //same normalization as IRepository.PagedAsync var pageSize = request.PageSize <= 0 ? 1 : request.PageSize; var skip = (int)Math.Clamp((long)request.PageIndex * pageSize, 0, int.MaxValue); - using var queriesCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - var itemsTask = CancelOnFailure( - _productRepository.ToListAsync(OrderByQueryable(request, query).Skip(skip).Take(pageSize), - queriesCancellation.Token), - queriesCancellation); - - //a miss is computed once; concurrent requests for the same listing wait for it instead of repeating the scan - var summaryTask = CancelOnFailure( - _cacheBase.GetAsync(key, - () => LoadListingSummary(request, query, querySpecification, queriesCancellation.Token), - ListingSummaryCacheMinutes), - queriesCancellation); - - try - { - await Task.WhenAll(itemsTask, summaryTask); - } - catch when (cancellationToken.IsCancellationRequested) - { - cancellationToken.ThrowIfCancellationRequested(); - throw; - } - - var items = await itemsTask; - var summary = await summaryTask; - - //a short page is the last one and gives the exact total; otherwise the cached total may lag up to its lifetime + var (items, summary) = await WhenBoth( + token => _productRepository.ToListAsync(OrderByQueryable(request, query).Skip(skip).Take(pageSize), token), + //a miss is computed once for every request waiting on it, so it does not run under the token of + //whichever request came first - one aborted request would discard it for all; each one still stops + //waiting when it is cancelled itself + token => _cacheBase.GetAsync(key, + () => LoadListingSummary(request, query, querySpecification, CancellationToken.None), + ListingSummaryCacheMinutes) + .WaitAsync(token), + cancellationToken); + + //a short page is the last one and gives the exact total. A full page proves its products exist, so a cached + //total lagging behind writes never puts the page shown past the last one var totalCount = items.Count < pageSize && (items.Count > 0 || skip == 0) ? skip + items.Count - : summary.TotalCount; + : items.Count == pageSize + ? Math.Max(summary.TotalCount, skip + items.Count) + : summary.TotalCount; return (new PagedList(items, request.PageIndex, pageSize, totalCount), summary.FilterableSpecificationAttributeOptionIds.ToList()); } + /// + /// Only listing pages opt in, and only with filters the catalog itself bounds: free text and arbitrary numbers + /// from the query string would each add a cache entry + /// + private static bool CanCacheListingSummary(GetSearchProductsQuery request) + { + return request.CacheListingSummary && !request.ShowHidden && + string.IsNullOrWhiteSpace(request.Keywords) && + (request.SpecificationOptions == null || !request.SpecificationOptions.Any()) && + request.PriceMin == null && request.PriceMax == null && + (request.Rating == null || request.Rating is >= 0 and <= 5 && request.Rating % 1 == 0); + } + private async Task LoadListingSummary(GetSearchProductsQuery request, IQueryable query, IQueryable querySpecification, CancellationToken cancellationToken) { @@ -200,24 +186,79 @@ private async Task LoadListingSummary(GetSearchProductsQuery req } /// - /// Hash of every request value that changes which products match; page and order are left out on purpose + /// Hash of every request value that changes which products match; page and order are left out on purpose. + /// Specifications count by the options that resolved, so ids that match no option do not add entries /// - private string ListingSummaryHash(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds, string search) + private string ListingSummaryHash(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds, + IList specificationOptionIds) { - static string Set(IEnumerable values) + var filters = new StringBuilder(); + + //each value is prefixed with its length, so no id or tag can run into the next field. Invariant culture: + //the request culture follows the customer's language and would split one listing in two + void Add(object value) { - return values == null ? "" : string.Join(",", values.Order(StringComparer.Ordinal)); + var text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; + filters.Append(text.Length).Append(':').Append(text); } - //invariant culture: the request culture follows the customer's language and would split one listing in two - var filters = string.Create(CultureInfo.InvariantCulture, - $"{Set(request.CategoryIds)}|{request.FeaturedProducts}|{request.BrandId}|{request.CollectionId}|{request.StoreId}|{request.VendorId}|{request.WarehouseId}|{request.ProductType}|{request.VisibleIndividuallyOnly}|{request.MarkedAsNewOnly}|{request.ShowOnHomePage}|{request.OverridePublished}|{request.PriceMin}|{request.PriceMax}|{request.Rating}|{request.ProductTag}|{Set(allowedCustomerGroupsIds)}|{Set(request.FilteredSpecs)}|{Set(request.SpecificationOptions)}|{ShouldLoadFilterableSpecifications(request)}|{request.SearchDescriptions}|{request.SearchSku}|{search != null}|{request.Keywords}"); + void AddSet(IEnumerable values) + { + var sorted = values?.Distinct().Order(StringComparer.Ordinal).ToList() ?? []; + Add(sorted.Count); + foreach (var value in sorted) Add(value); + } + + AddSet(request.CategoryIds); + Add(request.FeaturedProducts); + Add(request.BrandId); + Add(request.CollectionId); + Add(request.StoreId); + Add(request.VendorId); + Add(request.WarehouseId); + Add(request.ProductType); + Add(request.VisibleIndividuallyOnly); + Add(request.MarkedAsNewOnly); + Add(request.ShowOnHomePage); + Add(request.OverridePublished); + Add(request.Rating); + Add(request.ProductTag); + AddSet(allowedCustomerGroupsIds); + AddSet(specificationOptionIds); + Add(ShouldLoadFilterableSpecifications(request)); - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(filters))); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(filters.ToString()))); } private sealed record ListingSummary(int TotalCount, string[] FilterableSpecificationAttributeOptionIds); + /// + /// Runs two independent queries at once; the first failure cancels the other one instead of waiting for it + /// + private static async Task<(T1, T2)> WhenBoth(Func> first, + Func> second, CancellationToken cancellationToken) + { + using var queriesCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // The first one starts first - LiteDB completes synchronously, so there it still runs before the second + var firstTask = CancelOnFailure(first(queriesCancellation.Token), queriesCancellation); + var secondTask = CancelOnFailure(second(queriesCancellation.Token), queriesCancellation); + + try + { + await Task.WhenAll(firstTask, secondTask); + } + catch when (cancellationToken.IsCancellationRequested) + { + //an aborted request may surface as a driver fault of the other query; report it as cancelled + //so Handle does not mistake it for a missing text index + cancellationToken.ThrowIfCancellationRequested(); + throw; + } + + return (await firstTask, await secondTask); + } + private static async Task CancelOnFailure(Task task, CancellationTokenSource cancellation) { try @@ -237,15 +278,20 @@ private static void CleanupRequestParameters(GetSearchProductsQuery request) request.CategoryIds.Remove(""); } - private async Task<(IQueryable query, IQueryable querySpecification)> ApplySpecificationFilters( - GetSearchProductsQuery request, - IQueryable query, - IQueryable querySpecification) + /// The filtered queries and the filtered specification option ids that resolved to an attribute + private async Task<(IQueryable query, IQueryable querySpecification, IList specificationOptionIds)> + ApplySpecificationFilters( + GetSearchProductsQuery request, + IQueryable query, + IQueryable querySpecification) { + IList specificationOptionIds = []; + // Apply filtered specs if (request.FilteredSpecs != null && request.FilteredSpecs.Any()) { - (query, querySpecification) = await ApplyFilteredSpecifications(request, query, querySpecification); + (query, querySpecification, specificationOptionIds) = + await ApplyFilteredSpecifications(request, query, querySpecification); } // Apply specification options @@ -254,13 +300,14 @@ private static void CleanupRequestParameters(GetSearchProductsQuery request) query = ApplySpecificationOptions(request, query); } - return (query, querySpecification); + return (query, querySpecification, specificationOptionIds); } - private async Task<(IQueryable query, IQueryable querySpecification)> ApplyFilteredSpecifications( - GetSearchProductsQuery request, - IQueryable query, - IQueryable querySpecification) + private async Task<(IQueryable query, IQueryable querySpecification, IList specificationOptionIds)> + ApplyFilteredSpecifications( + GetSearchProductsQuery request, + IQueryable query, + IQueryable querySpecification) { var specAttributeMapping = await BuildSpecificationAttributeMapping(request.FilteredSpecs, querySpecification); @@ -273,7 +320,8 @@ private static void CleanupRequestParameters(GetSearchProductsQuery request) item.Value.Contains(y.SpecificationAttributeOptionId))); } - return (query, specAttributeMapping.QuerySpecification); + return (query, specAttributeMapping.QuerySpecification, + specAttributeMapping.Dictionary.Values.SelectMany(x => x).ToList()); } private async Task<(Dictionary> Dictionary, IQueryable QuerySpecification)> diff --git a/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs b/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs index 5c2c7c387b..bcec5d2a89 100644 --- a/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs +++ b/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs @@ -44,4 +44,12 @@ public class GetSearchProductsQuery : IRequest<(IPagedList products, IL public ProductSortingEnum OrderBy { get; set; } = ProductSortingEnum.Position; public bool ShowHidden { get; set; } public bool? OverridePublished { get; set; } + + /// + /// Reads the total and the filterable specification option ids from a short-lived cache shared by every + /// request with the same filters, so they may lag behind writes for a few minutes. For storefront listing pages; + /// ignored with , keywords, specification options, a price range or a rating that is + /// not a whole star + /// + public bool CacheListingSummary { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs b/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs index dd4becd967..26f2f056c9 100644 --- a/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs +++ b/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs @@ -19,10 +19,17 @@ public static partial class CacheKey /// Key for caching the total count and the filterable specification option ids of a storefront product listing /// /// - /// {0} : hash of every listing filter - categories, brand, collection, store, customer groups, specifications, - /// price, rating, keywords, flags + /// {0} : listing version, see + /// {1} : hash of every listing filter - categories, brand, collection, store, customer groups, specifications, + /// rating, flags /// - public static string PRODUCTS_LISTING_SUMMARY_KEY => "Grand.product.listing-{0}"; + public static string PRODUCTS_LISTING_SUMMARY_KEY => "Grand.product.listing-{0}-{1}"; + + /// + /// Key for caching the version every listing summary key carries. Clearing the pattern drops it too, so a + /// summary still being computed from older data is stored under a version no request reads any more + /// + public static string PRODUCTS_LISTING_VERSION_KEY => "Grand.product.listing.version"; /// /// Key pattern to clear cache diff --git a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs index 6dabd5dd05..67b8ccf954 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs @@ -20,8 +20,8 @@ namespace Grand.Business.Catalog.Tests.Queries.Handlers; [TestClass] public class GetSearchProductsQueryHandlerTests { - private ICacheBase _cache; private IRepository _repository; + private ICacheBase _cache; private GetSearchProductsQueryHandler handler; [TestInitialize] @@ -149,14 +149,13 @@ public async Task Handle_WhenFilterableSpecificationsRequested_StartsBothQueries var optionsStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var repository = new Mock>(); repository.Setup(x => x.Table).Returns(new List().AsQueryable()); - repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) + repository.Setup(x => x.PagedAsync(It.IsAny>(), It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(async () => { pageStarted.TrySetResult(); await optionsStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); - return new List { new() { Id = "1" } }; + return new PagedList([new Product { Id = "1" }], 0, 10, 1); }); - repository.Setup(x => x.CountAsync(It.IsAny>(), It.IsAny())) - .ReturnsAsync(1); repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) .Returns(async () => { optionsStarted.TrySetResult(); @@ -185,7 +184,8 @@ public async Task Handle_WhenPageQueryFails_CancelsTheFilterableQueryAndRethrows var optionsToken = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var repository = new Mock>(); repository.Setup(x => x.Table).Returns(new List().AsQueryable()); - repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) + repository.Setup(x => x.PagedAsync(It.IsAny>(), It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(async () => { //fail once the options query is running await optionsToken.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -218,11 +218,12 @@ public async Task Handle_WhenRequestCancelledAndOtherQueryFaults_ThrowsCancelled using var cancellation = new CancellationTokenSource(); var repository = new Mock>(); repository.Setup(x => x.Table).Returns(new List().AsQueryable()); - repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) - .Returns(async (IQueryable _, CancellationToken token) => { + repository.Setup(x => x.PagedAsync(It.IsAny>(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(async (IQueryable _, int _, int _, CancellationToken token) => { if (Interlocked.Increment(ref started) == 2) await cancellation.CancelAsync(); await Task.Delay(Timeout.Infinite, token); - return new List(); + return new PagedList([], 0, 10, 0); }); repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) .Returns(async (IQueryable _, CancellationToken token) => { @@ -242,12 +243,12 @@ public async Task Handle_WhenRequestCancelledAndOtherQueryFaults_ThrowsCancelled //Act & Assert await Assert.ThrowsAsync(() => filteringHandler.Handle(searchProductsQuery, cancellation.Token).WaitAsync(TimeSpan.FromSeconds(5))); - repository.Verify(x => x.ToListAsync(It.IsAny>(), It.IsAny()), - Times.Once); + repository.Verify(x => x.PagedAsync(It.IsAny>(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); } [TestMethod] - public async Task Handle_StorefrontListingWithFullPage_ReusesTheCachedTotal() + public async Task Handle_ListingWithFullPage_ReusesTheCachedTotal() { //Arrange await InsertVisibleProducts(3); @@ -260,7 +261,7 @@ public async Task Handle_StorefrontListingWithFullPage_ReusesTheCachedTotal() } [TestMethod] - public async Task Handle_StorefrontListingWithShortPage_ReturnsTheExactTotal() + public async Task Handle_ListingWithShortPage_ReturnsTheExactTotal() { //Arrange await InsertVisibleProducts(3); @@ -273,6 +274,21 @@ public async Task Handle_StorefrontListingWithShortPage_ReturnsTheExactTotal() Assert.AreEqual(4, result.products.Count); } + [TestMethod] + public async Task Handle_ListingWithFullPagePastTheCachedTotal_KeepsThePageWithinTheTotal() + { + //Arrange + await InsertVisibleProducts(2); + await handler.Handle(Listing(pageSize: 2), CancellationToken.None); + await InsertVisibleProducts(2); + //Act + var result = await handler.Handle(Listing(pageSize: 2, pageIndex: 1), CancellationToken.None); + //Assert + Assert.AreEqual(2, result.products.Count); + Assert.AreEqual(4, result.products.TotalCount); + Assert.AreEqual(2, result.products.TotalPages); + } + [TestMethod] public async Task Handle_ListingPatternCleared_CountsAgain() { @@ -288,15 +304,129 @@ public async Task Handle_ListingPatternCleared_CountsAgain() } [TestMethod] - public async Task Handle_ShowHidden_CountsLive() + public async Task Handle_ListingPatternClearedWhileCounting_DoesNotKeepTheOldTotal() + { + //Arrange + var countStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseCount = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var counts = 0; + var repository = PagedRepository(); + repository.Setup(x => x.CountAsync(It.IsAny>(), It.IsAny())) + .Returns(async () => { + if (Interlocked.Increment(ref counts) > 1) return 5; + countStarted.TrySetResult(); + await releaseCount.Task.WaitAsync(TimeSpan.FromSeconds(5)); + return 3; + }); + var cachingHandler = CachingHandler(repository.Object, _cache); + //Act + var first = cachingHandler.Handle(Listing(pageSize: 1), CancellationToken.None); + await countStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await _cache.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); + releaseCount.SetResult(); + await first; + var second = await cachingHandler.Handle(Listing(pageSize: 1), CancellationToken.None); + //Assert + Assert.AreEqual(5, second.products.TotalCount); + } + + [TestMethod] + public async Task Handle_FirstListingRequestAborted_StillCachesTheSharedTotal() + { + //Arrange + var countStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseCount = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var repository = PagedRepository(); + repository.Setup(x => x.CountAsync(It.IsAny>(), It.IsAny())) + .Returns(async (IQueryable _, CancellationToken token) => { + countStarted.TrySetResult(); + await releaseCount.Task.WaitAsync(TimeSpan.FromSeconds(5), token); + return 3; + }); + var cachingHandler = CachingHandler(repository.Object, _cache); + using var cancellation = new CancellationTokenSource(); + //Act + var aborted = cachingHandler.Handle(Listing(pageSize: 1), cancellation.Token); + await countStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await cancellation.CancelAsync(); + await Assert.ThrowsAsync(() => aborted.WaitAsync(TimeSpan.FromSeconds(5))); + releaseCount.SetResult(); + var result = await cachingHandler.Handle(Listing(pageSize: 1), CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(5)); + //Assert + Assert.AreEqual(3, result.products.TotalCount); + repository.Verify(x => x.CountAsync(It.IsAny>(), It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task Handle_TagRunningIntoTheCustomerGroups_DoesNotShareTheCachedTotal() + { + //Arrange + var repository = PagedRepository(); + repository.Setup(x => x.CountAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(3); + var cachingHandler = CachingHandler(repository.Object, _cache); + var tagged = Listing(pageSize: 1); + tagged.ProductTag = "sale|members"; + tagged.CustomerGroupIds = []; + var member = Listing(pageSize: 1); + member.ProductTag = "sale"; + member.CustomerGroupIds = ["members"]; + //Act + await cachingHandler.Handle(tagged, CancellationToken.None); + await cachingHandler.Handle(member, CancellationToken.None); + //Assert + repository.Verify(x => x.CountAsync(It.IsAny>(), It.IsAny()), + Times.Exactly(2)); + } + + [TestMethod] + public async Task Handle_NotOptedIn_CountsLive() + { + //Arrange + await InsertVisibleProducts(3); + var query = () => new GetSearchProductsQuery { Customer = new Customer(), PageSize = 2 }; + await handler.Handle(query(), CancellationToken.None); + await InsertVisibleProducts(1); + //Act + var result = await handler.Handle(query(), CancellationToken.None); + //Assert + Assert.AreEqual(4, result.products.TotalCount); + } + + [TestMethod] + public async Task Handle_ListingWithKeywords_CountsLive() + { + //Arrange + await InsertVisibleProducts(3); + var query = () => { + var listing = Listing(pageSize: 2); + listing.Keywords = "item"; + return listing; + }; + await handler.Handle(query(), CancellationToken.None); + await InsertVisibleProducts(1); + //Act + var result = await handler.Handle(query(), CancellationToken.None); + //Assert + Assert.AreEqual(4, result.products.TotalCount); + } + + [TestMethod] + public async Task Handle_ListingWithShowHidden_CountsLive() { //Arrange await InsertVisibleProducts(3); - await handler.Handle(new GetSearchProductsQuery { ShowHidden = true, PageSize = 2 }, CancellationToken.None); + var query = () => { + var listing = Listing(pageSize: 2); + listing.ShowHidden = true; + return listing; + }; + await handler.Handle(query(), CancellationToken.None); await InsertVisibleProducts(1); //Act - var result = await handler.Handle(new GetSearchProductsQuery { ShowHidden = true, PageSize = 2 }, - CancellationToken.None); + var result = await handler.Handle(query(), CancellationToken.None); //Assert Assert.AreEqual(4, result.products.TotalCount); } @@ -307,16 +437,19 @@ public async Task Handle_DifferentCustomerGroups_DoNotShareTheCachedTotal() //Arrange await InsertVisibleProducts(2); await _repository.InsertAsync(new Product { - Published = true, VisibleIndividually = true, LimitedToGroups = true, CustomerGroups = ["members"] + Name = "item", Published = true, VisibleIndividually = true, LimitedToGroups = true, + CustomerGroups = ["members"] }); + var members = Listing(pageSize: 1); + members.CustomerGroupIds = ["members"]; + var guests = Listing(pageSize: 1); + guests.CustomerGroupIds = ["guests"]; //Act - var members = await handler.Handle( - new GetSearchProductsQuery { CustomerGroupIds = ["members"], PageSize = 1 }, CancellationToken.None); - var guests = await handler.Handle( - new GetSearchProductsQuery { CustomerGroupIds = ["guests"], PageSize = 1 }, CancellationToken.None); + var membersResult = await handler.Handle(members, CancellationToken.None); + var guestsResult = await handler.Handle(guests, CancellationToken.None); //Assert - Assert.AreEqual(3, members.products.TotalCount); - Assert.AreEqual(2, guests.products.TotalCount); + Assert.AreEqual(3, membersResult.products.TotalCount); + Assert.AreEqual(2, guestsResult.products.TotalCount); } [TestMethod] @@ -333,10 +466,10 @@ public async Task Handle_DifferentCategories_DoNotShareTheCachedTotal() Assert.AreEqual(3, bags.products.TotalCount); } - private static GetSearchProductsQuery Listing(int pageSize, string categoryId = null) + private static GetSearchProductsQuery Listing(int pageSize, int pageIndex = 0, string categoryId = null) { return new GetSearchProductsQuery { - Customer = new Customer(), PageSize = pageSize, + Customer = new Customer(), CacheListingSummary = true, PageSize = pageSize, PageIndex = pageIndex, CategoryIds = categoryId == null ? null : [categoryId] }; } @@ -345,13 +478,30 @@ private async Task InsertVisibleProducts(int count, string categoryId = null) { for (var i = 0; i < count; i++) { - var product = new Product { Published = true, VisibleIndividually = true }; + var product = new Product { Name = "item", Published = true, VisibleIndividually = true }; if (categoryId != null) product.ProductCategories.Add(new ProductCategory { CategoryId = categoryId }); await _repository.InsertAsync(product); } } + //a repository whose every page is full, so the listing total always comes from the summary + private static Mock> PagedRepository() + { + var repository = new Mock>(); + repository.Setup(x => x.Table).Returns(new List().AsQueryable()); + repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List { new() { Id = "1" } }); + return repository; + } + + private static GetSearchProductsQueryHandler CachingHandler(IRepository repository, ICacheBase cache) + { + return new GetSearchProductsQueryHandler(repository, new Mock().Object, cache, + new CatalogSettings { IgnoreFilterableSpecAttributeOption = true }, new AccessControlConfig(), + new DatabaseConfig(), Mock.Of(), NullLogger.Instance); + } + private static GetSearchProductsQueryHandler FilteringHandler(IRepository repository, DatabaseConfig databaseConfig, IDatabaseContext databaseContext) { @@ -362,7 +512,6 @@ private static GetSearchProductsQueryHandler FilteringHandler(IRepository(), - new CacheConfig { DefaultCacheTimeMinutes = 1 }); + return new MemoryCacheBase(MemoryCacheTest.Get(), Mock.Of(), new CacheConfig()); } -} +} \ No newline at end of file diff --git a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs index cce107e04e..0ae3a5b0fd 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs @@ -33,7 +33,8 @@ public async Task Init() private GetSearchProductsQueryHandler Handler(bool textSearch = true) { - return new GetSearchProductsQueryHandler(_repository, new Mock().Object, Cache(), + return new GetSearchProductsQueryHandler(_repository, new Mock().Object, + new MemoryCacheBase(MemoryCacheTest.Get(), Mock.Of(), new CacheConfig()), new CatalogSettings { IgnoreFilterableSpecAttributeOption = true }, new AccessControlConfig(), new DatabaseConfig { UseProductTextSearch = textSearch }, _databaseContext, NullLogger.Instance); @@ -197,20 +198,5 @@ public override Task> PagedAsync(IQueryable> ToListAsync(IQueryable query, - CancellationToken cancellationToken = default) - { - if (typeof(TResult) == typeof(Product)) - Pipeline = query.ToString(); - return base.ToListAsync(query, cancellationToken); - } - } - - private static ICacheBase Cache() - { - return new MemoryCacheBase(MemoryCacheTest.Get(), Mock.Of(), - new CacheConfig { DefaultCacheTimeMinutes = 1 }); } } diff --git a/src/Web/Grand.Web/Features/Handlers/Catalog/GetBrandHandler.cs b/src/Web/Grand.Web/Features/Handlers/Catalog/GetBrandHandler.cs index ab78027828..550f561441 100644 --- a/src/Web/Grand.Web/Features/Handlers/Catalog/GetBrandHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Catalog/GetBrandHandler.cs @@ -52,6 +52,7 @@ await model.PagingFilteringContext.SpecificationFilter.GetAlreadyFilteredSpecOpt (_httpContextAccessor.HttpContext?.Request.Query, _specificationAttributeService); var products = await _mediator.Send(new GetSearchProductsQuery { + CacheListingSummary = true, LoadFilterableSpecificationAttributeOptionIds = !_catalogSettings.IgnoreFilterableSpecAttributeOption, BrandId = request.Brand.Id, Customer = request.Customer, diff --git a/src/Web/Grand.Web/Features/Handlers/Catalog/GetCategoryHandler.cs b/src/Web/Grand.Web/Features/Handlers/Catalog/GetCategoryHandler.cs index b594906e28..a06d983089 100644 --- a/src/Web/Grand.Web/Features/Handlers/Catalog/GetCategoryHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Catalog/GetCategoryHandler.cs @@ -192,6 +192,7 @@ public async Task Handle(GetCategory request, CancellationToken c await model.PagingFilteringContext.SpecificationFilter.GetAlreadyFilteredSpecOptionIds( _httpContextAccessor.HttpContext?.Request.Query, _specificationAttributeService); var products = await _mediator.Send(new GetSearchProductsQuery { + CacheListingSummary = true, LoadFilterableSpecificationAttributeOptionIds = !_catalogSettings.IgnoreFilterableSpecAttributeOption, CategoryIds = categoryIds, Customer = request.Customer, diff --git a/src/Web/Grand.Web/Features/Handlers/Catalog/GetCollectionHandler.cs b/src/Web/Grand.Web/Features/Handlers/Catalog/GetCollectionHandler.cs index 3687d1bc5c..dc231a8ad5 100644 --- a/src/Web/Grand.Web/Features/Handlers/Catalog/GetCollectionHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Catalog/GetCollectionHandler.cs @@ -98,6 +98,7 @@ await model.PagingFilteringContext.SpecificationFilter.GetAlreadyFilteredSpecOpt (_httpContextAccessor.HttpContext?.Request.Query, _specificationAttributeService); var products = await _mediator.Send(new GetSearchProductsQuery { + CacheListingSummary = true, LoadFilterableSpecificationAttributeOptionIds = !_catalogSettings.IgnoreFilterableSpecAttributeOption, CollectionId = request.Collection.Id, Customer = request.Customer, diff --git a/src/Web/Grand.Web/Features/Handlers/Catalog/GetProductsByTagHandler.cs b/src/Web/Grand.Web/Features/Handlers/Catalog/GetProductsByTagHandler.cs index cd55f14a51..bf3f988cf8 100644 --- a/src/Web/Grand.Web/Features/Handlers/Catalog/GetProductsByTagHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Catalog/GetProductsByTagHandler.cs @@ -42,6 +42,7 @@ public async Task Handle(GetProductsByTag request, Cancellat //products var products = (await _mediator.Send(new GetSearchProductsQuery { + CacheListingSummary = true, Customer = request.Customer, StoreId = request.Store.Id, ProductTag = request.ProductTag.Name, diff --git a/src/Web/Grand.Web/Features/Handlers/Catalog/GetVendorHandler.cs b/src/Web/Grand.Web/Features/Handlers/Catalog/GetVendorHandler.cs index bb53486c17..428edcfd56 100644 --- a/src/Web/Grand.Web/Features/Handlers/Catalog/GetVendorHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Catalog/GetVendorHandler.cs @@ -101,6 +101,7 @@ await model.PagingFilteringContext.SpecificationFilter.GetAlreadyFilteredSpecOpt //products var products = await _mediator.Send(new GetSearchProductsQuery { + CacheListingSummary = true, LoadFilterableSpecificationAttributeOptionIds = !_catalogSettings.IgnoreFilterableSpecAttributeOption, Customer = request.Customer, VendorId = request.Vendor.Id, From 2c0215734bb8734d98bacbb1eb148f54cd8135b8 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:48:50 +0200 Subject: [PATCH 3/3] Simplify the listing summary cache - Caching depends on the opt-in and ShowHidden only; the key keeps every filter, so no list of excluded filters is needed. - The key is a JSON array of the filters: a separator inside a tag or an id is escaped, with no hand-made length prefixes. - FilteredSpecs go into the key as requested, so the specification filter methods keep their signatures. - No key version: a summary computed across an invalidation lags by at most its 5-minute lifetime, the same bound as any write that does not clear it. - An empty page past the end reports at most skip as its total. --- .../Handlers/GetSearchProductsQueryHandler.cs | 132 ++++++------------ .../Queries/Catalog/GetSearchProductsQuery.cs | 3 +- .../Caching/Constants/ProductCacheKey.cs | 13 +- .../GetSearchProductsQueryHandlerTests.cs | 45 ------ 4 files changed, 44 insertions(+), 149 deletions(-) diff --git a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs index 9d2bed370a..0e3dd4fe32 100644 --- a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs +++ b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs @@ -9,9 +9,9 @@ using Grand.Infrastructure.Configuration; using Grand.Mediator; using Microsoft.Extensions.Logging; -using System.Globalization; using System.Security.Cryptography; using System.Text; +using System.Text.Json; namespace Grand.Business.Catalog.Queries.Handlers; @@ -23,9 +23,6 @@ public class GetSearchProductsQueryHandler : IRequestHandler products, IList filterableSpecificationAttributeOptionIds)> SearchWithCachedSummary(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds, - IList specificationOptionIds, IQueryable query, IQueryable querySpecification, - CancellationToken cancellationToken) + IQueryable query, IQueryable querySpecification, CancellationToken cancellationToken) { - var version = await _cacheBase.GetAsync(CacheKey.PRODUCTS_LISTING_VERSION_KEY, - () => Task.FromResult(Guid.NewGuid().ToString("N")), ListingVersionCacheMinutes); - var key = string.Format(CacheKey.PRODUCTS_LISTING_SUMMARY_KEY, version, - ListingSummaryHash(request, allowedCustomerGroupsIds, specificationOptionIds)); + var key = string.Format(CacheKey.PRODUCTS_LISTING_SUMMARY_KEY, + ListingSummaryHash(request, allowedCustomerGroupsIds)); //same normalization as IRepository.PagedAsync var pageSize = request.PageSize <= 0 ? 1 : request.PageSize; @@ -138,40 +131,23 @@ public GetSearchProductsQueryHandler( var (items, summary) = await WhenBoth( token => _productRepository.ToListAsync(OrderByQueryable(request, query).Skip(skip).Take(pageSize), token), - //a miss is computed once for every request waiting on it, so it does not run under the token of - //whichever request came first - one aborted request would discard it for all; each one still stops - //waiting when it is cancelled itself + //a miss is computed once for all waiting requests, so it does not stop when the first of them is aborted token => _cacheBase.GetAsync(key, () => LoadListingSummary(request, query, querySpecification, CancellationToken.None), ListingSummaryCacheMinutes) .WaitAsync(token), cancellationToken); - //a short page is the last one and gives the exact total. A full page proves its products exist, so a cached - //total lagging behind writes never puts the page shown past the last one - var totalCount = items.Count < pageSize && (items.Count > 0 || skip == 0) - ? skip + items.Count - : items.Count == pageSize - ? Math.Max(summary.TotalCount, skip + items.Count) - : summary.TotalCount; + //a short page is the last one and gives the exact total; a full page proves skip + pageSize products and + //an empty one past the end proves at most skip, so a lagging cached total never contradicts the page shown + var totalCount = items.Count == pageSize + ? Math.Max(summary.TotalCount, skip + pageSize) + : items.Count > 0 || skip == 0 ? skip + items.Count : Math.Min(summary.TotalCount, skip); return (new PagedList(items, request.PageIndex, pageSize, totalCount), summary.FilterableSpecificationAttributeOptionIds.ToList()); } - /// - /// Only listing pages opt in, and only with filters the catalog itself bounds: free text and arbitrary numbers - /// from the query string would each add a cache entry - /// - private static bool CanCacheListingSummary(GetSearchProductsQuery request) - { - return request.CacheListingSummary && !request.ShowHidden && - string.IsNullOrWhiteSpace(request.Keywords) && - (request.SpecificationOptions == null || !request.SpecificationOptions.Any()) && - request.PriceMin == null && request.PriceMax == null && - (request.Rating == null || request.Rating is >= 0 and <= 5 && request.Rating % 1 == 0); - } - private async Task LoadListingSummary(GetSearchProductsQuery request, IQueryable query, IQueryable querySpecification, CancellationToken cancellationToken) { @@ -186,48 +162,27 @@ private async Task LoadListingSummary(GetSearchProductsQuery req } /// - /// Hash of every request value that changes which products match; page and order are left out on purpose. - /// Specifications count by the options that resolved, so ids that match no option do not add entries + /// Hash of every request value that changes which products match; page and order are left out on purpose /// - private string ListingSummaryHash(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds, - IList specificationOptionIds) + private string ListingSummaryHash(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds) { - var filters = new StringBuilder(); - - //each value is prefixed with its length, so no id or tag can run into the next field. Invariant culture: - //the request culture follows the customer's language and would split one listing in two - void Add(object value) + static string[] Set(IEnumerable values) { - var text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; - filters.Append(text.Length).Append(':').Append(text); + return values?.Order(StringComparer.Ordinal).ToArray() ?? []; } - void AddSet(IEnumerable values) - { - var sorted = values?.Distinct().Order(StringComparer.Ordinal).ToList() ?? []; - Add(sorted.Count); - foreach (var value in sorted) Add(value); - } + //JSON keeps the fields apart - a separator inside a tag or an id is escaped - and formats numbers invariantly + var filters = JsonSerializer.Serialize(new object[] { + Set(request.CategoryIds), request.FeaturedProducts, request.BrandId, request.CollectionId, + request.StoreId, request.VendorId, request.WarehouseId, request.ProductType, + request.VisibleIndividuallyOnly, request.MarkedAsNewOnly, request.ShowOnHomePage, + request.OverridePublished, request.PriceMin, request.PriceMax, request.Rating, request.ProductTag, + Set(allowedCustomerGroupsIds), Set(request.FilteredSpecs), Set(request.SpecificationOptions), + ShouldLoadFilterableSpecifications(request), request.SearchDescriptions, request.SearchSku, + request.Keywords + }); - AddSet(request.CategoryIds); - Add(request.FeaturedProducts); - Add(request.BrandId); - Add(request.CollectionId); - Add(request.StoreId); - Add(request.VendorId); - Add(request.WarehouseId); - Add(request.ProductType); - Add(request.VisibleIndividuallyOnly); - Add(request.MarkedAsNewOnly); - Add(request.ShowOnHomePage); - Add(request.OverridePublished); - Add(request.Rating); - Add(request.ProductTag); - AddSet(allowedCustomerGroupsIds); - AddSet(specificationOptionIds); - Add(ShouldLoadFilterableSpecifications(request)); - - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(filters.ToString()))); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(filters))); } private sealed record ListingSummary(int TotalCount, string[] FilterableSpecificationAttributeOptionIds); @@ -278,20 +233,15 @@ private static void CleanupRequestParameters(GetSearchProductsQuery request) request.CategoryIds.Remove(""); } - /// The filtered queries and the filtered specification option ids that resolved to an attribute - private async Task<(IQueryable query, IQueryable querySpecification, IList specificationOptionIds)> - ApplySpecificationFilters( - GetSearchProductsQuery request, - IQueryable query, - IQueryable querySpecification) + private async Task<(IQueryable query, IQueryable querySpecification)> ApplySpecificationFilters( + GetSearchProductsQuery request, + IQueryable query, + IQueryable querySpecification) { - IList specificationOptionIds = []; - // Apply filtered specs if (request.FilteredSpecs != null && request.FilteredSpecs.Any()) { - (query, querySpecification, specificationOptionIds) = - await ApplyFilteredSpecifications(request, query, querySpecification); + (query, querySpecification) = await ApplyFilteredSpecifications(request, query, querySpecification); } // Apply specification options @@ -300,14 +250,13 @@ private static void CleanupRequestParameters(GetSearchProductsQuery request) query = ApplySpecificationOptions(request, query); } - return (query, querySpecification, specificationOptionIds); + return (query, querySpecification); } - private async Task<(IQueryable query, IQueryable querySpecification, IList specificationOptionIds)> - ApplyFilteredSpecifications( - GetSearchProductsQuery request, - IQueryable query, - IQueryable querySpecification) + private async Task<(IQueryable query, IQueryable querySpecification)> ApplyFilteredSpecifications( + GetSearchProductsQuery request, + IQueryable query, + IQueryable querySpecification) { var specAttributeMapping = await BuildSpecificationAttributeMapping(request.FilteredSpecs, querySpecification); @@ -320,8 +269,7 @@ private static void CleanupRequestParameters(GetSearchProductsQuery request) item.Value.Contains(y.SpecificationAttributeOptionId))); } - return (query, specAttributeMapping.QuerySpecification, - specAttributeMapping.Dictionary.Values.SelectMany(x => x).ToList()); + return (query, specAttributeMapping.QuerySpecification); } private async Task<(Dictionary> Dictionary, IQueryable QuerySpecification)> diff --git a/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs b/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs index bcec5d2a89..ec79a66205 100644 --- a/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs +++ b/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs @@ -48,8 +48,7 @@ public class GetSearchProductsQuery : IRequest<(IPagedList products, IL /// /// Reads the total and the filterable specification option ids from a short-lived cache shared by every /// request with the same filters, so they may lag behind writes for a few minutes. For storefront listing pages; - /// ignored with , keywords, specification options, a price range or a rating that is - /// not a whole star + /// ignored with /// public bool CacheListingSummary { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs b/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs index 26f2f056c9..dd4becd967 100644 --- a/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs +++ b/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs @@ -19,17 +19,10 @@ public static partial class CacheKey /// Key for caching the total count and the filterable specification option ids of a storefront product listing /// /// - /// {0} : listing version, see - /// {1} : hash of every listing filter - categories, brand, collection, store, customer groups, specifications, - /// rating, flags + /// {0} : hash of every listing filter - categories, brand, collection, store, customer groups, specifications, + /// price, rating, keywords, flags /// - public static string PRODUCTS_LISTING_SUMMARY_KEY => "Grand.product.listing-{0}-{1}"; - - /// - /// Key for caching the version every listing summary key carries. Clearing the pattern drops it too, so a - /// summary still being computed from older data is stored under a version no request reads any more - /// - public static string PRODUCTS_LISTING_VERSION_KEY => "Grand.product.listing.version"; + public static string PRODUCTS_LISTING_SUMMARY_KEY => "Grand.product.listing-{0}"; /// /// Key pattern to clear cache diff --git a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs index 67b8ccf954..03a4860dfe 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs @@ -303,33 +303,6 @@ public async Task Handle_ListingPatternCleared_CountsAgain() Assert.AreEqual(4, result.products.TotalCount); } - [TestMethod] - public async Task Handle_ListingPatternClearedWhileCounting_DoesNotKeepTheOldTotal() - { - //Arrange - var countStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var releaseCount = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var counts = 0; - var repository = PagedRepository(); - repository.Setup(x => x.CountAsync(It.IsAny>(), It.IsAny())) - .Returns(async () => { - if (Interlocked.Increment(ref counts) > 1) return 5; - countStarted.TrySetResult(); - await releaseCount.Task.WaitAsync(TimeSpan.FromSeconds(5)); - return 3; - }); - var cachingHandler = CachingHandler(repository.Object, _cache); - //Act - var first = cachingHandler.Handle(Listing(pageSize: 1), CancellationToken.None); - await countStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); - await _cache.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); - releaseCount.SetResult(); - await first; - var second = await cachingHandler.Handle(Listing(pageSize: 1), CancellationToken.None); - //Assert - Assert.AreEqual(5, second.products.TotalCount); - } - [TestMethod] public async Task Handle_FirstListingRequestAborted_StillCachesTheSharedTotal() { @@ -395,24 +368,6 @@ public async Task Handle_NotOptedIn_CountsLive() Assert.AreEqual(4, result.products.TotalCount); } - [TestMethod] - public async Task Handle_ListingWithKeywords_CountsLive() - { - //Arrange - await InsertVisibleProducts(3); - var query = () => { - var listing = Listing(pageSize: 2); - listing.Keywords = "item"; - return listing; - }; - await handler.Handle(query(), CancellationToken.None); - await InsertVisibleProducts(1); - //Act - var result = await handler.Handle(query(), CancellationToken.None); - //Assert - Assert.AreEqual(4, result.products.TotalCount); - } - [TestMethod] public async Task Handle_ListingWithShowHidden_CountsLive() {