Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.Security.Cryptography;
using System.Text;
using System.Text.Json;

namespace Grand.Business.Catalog.Queries.Handlers;

Expand All @@ -15,7 +20,11 @@ public class GetSearchProductsQueryHandler : IRequestHandler<GetSearchProductsQu
//the missing index is reported once per process, not on every search; a race may report it twice
private static bool _missingTextIndexReported;

//how long a listing total and its filter options may lag behind writes that do not clear them
private const int ListingSummaryCacheMinutes = 5;

private readonly AccessControlConfig _accessControlConfig;
private readonly ICacheBase _cacheBase;
private readonly CatalogSettings _catalogSettings;
private readonly IDatabaseContext _databaseContext;
private readonly DatabaseConfig _databaseConfig;
Expand All @@ -26,6 +35,7 @@ public class GetSearchProductsQueryHandler : IRequestHandler<GetSearchProductsQu
public GetSearchProductsQueryHandler(
IRepository<Product> productRepository,
ISpecificationAttributeService specificationAttributeService,
ICacheBase cacheBase,
CatalogSettings catalogSettings,
AccessControlConfig accessControlConfig,
DatabaseConfig databaseConfig,
Expand All @@ -34,6 +44,7 @@ public GetSearchProductsQueryHandler(
{
_productRepository = productRepository;
_specificationAttributeService = specificationAttributeService;
_cacheBase = cacheBase;
_catalogSettings = catalogSettings;
_accessControlConfig = accessControlConfig;
_databaseConfig = databaseConfig;
Expand Down Expand Up @@ -89,27 +100,108 @@ public GetSearchProductsQueryHandler(
// Apply specification filters
(query, querySpecification) = await ApplySpecificationFilters(request, query, querySpecification);

// A listing page 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.CacheListingSummary && !request.ShowHidden)
return await SearchWithCachedSummary(request, allowedCustomerGroupsIds, query, querySpecification,
cancellationToken);

// Order the results
query = OrderByQueryable(request, query);

// The page and the filterable specification option ids are independent, so both queries run at once;
// the first failure cancels the other one instead of waiting for it
using var queriesCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// 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<IList<string>>(new List<string>()),
cancellationToken);
}

private async Task<(IPagedList<Product> products, IList<string> filterableSpecificationAttributeOptionIds)>
SearchWithCachedSummary(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds,
IQueryable<Product> query, IQueryable<Product> querySpecification, CancellationToken cancellationToken)
{
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;
var skip = (int)Math.Clamp((long)request.PageIndex * pageSize, 0, int.MaxValue);

// Create paged list first - LiteDB completes synchronously, so the page still runs before the options there
var productsTask = CancelOnFailure(
_productRepository.PagedAsync(query, request.PageIndex, request.PageSize, queriesCancellation.Token),
queriesCancellation);
var (items, summary) = await WhenBoth(
token => _productRepository.ToListAsync(OrderByQueryable(request, query).Skip(skip).Take(pageSize), token),
//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);

// Get filterable specification attributes if needed
//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<Product>(items, request.PageIndex, pageSize, totalCount),
summary.FilterableSpecificationAttributeOptionIds.ToList());
}

private async Task<ListingSummary> LoadListingSummary(GetSearchProductsQuery request, IQueryable<Product> query,
IQueryable<Product> querySpecification, CancellationToken cancellationToken)
{
var countTask = _productRepository.CountAsync(query, cancellationToken);
var filterableTask = ShouldLoadFilterableSpecifications(request)
? CancelOnFailure(GetFilterableSpecificationAttributeOptionIds(querySpecification, queriesCancellation.Token),
queriesCancellation)
? GetFilterableSpecificationAttributeOptionIds(querySpecification, cancellationToken)
: Task.FromResult<IList<string>>(new List<string>());

await Task.WhenAll(countTask, filterableTask);

return new ListingSummary(await countTask, (await filterableTask).ToArray());
}

/// <summary>
/// Hash of every request value that changes which products match; page and order are left out on purpose
/// </summary>
private string ListingSummaryHash(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds)
{
static string[] Set(IEnumerable<string> values)
{
return values?.Order(StringComparer.Ordinal).ToArray() ?? [];
}

//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
});

return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(filters)));
}

private sealed record ListingSummary(int TotalCount, string[] FilterableSpecificationAttributeOptionIds);

/// <summary>
/// Runs two independent queries at once; the first failure cancels the other one instead of waiting for it
/// </summary>
private static async Task<(T1, T2)> WhenBoth<T1, T2>(Func<CancellationToken, Task<T1>> first,
Func<CancellationToken, Task<T2>> 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(productsTask, filterableTask);
await Task.WhenAll(firstTask, secondTask);
}
catch when (cancellationToken.IsCancellationRequested)
{
Expand All @@ -119,7 +211,7 @@ public GetSearchProductsQueryHandler(
throw;
}

return (await productsTask, await filterableTask);
return (await firstTask, await secondTask);
}

private static async Task<T> CancelOnFailure<T>(Task<T> task, CancellationTokenSource cancellation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,11 @@ public class GetSearchProductsQuery : IRequest<(IPagedList<Product> products, IL
public ProductSortingEnum OrderBy { get; set; } = ProductSortingEnum.Position;
public bool ShowHidden { get; set; }
public bool? OverridePublished { get; set; }

/// <summary>
/// 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 <see cref="ShowHidden" />
/// </summary>
public bool CacheListingSummary { get; set; }
}
14 changes: 14 additions & 0 deletions src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ public static partial class CacheKey
/// </remarks>
public static string PRODUCTS_BY_ID_KEY => "Grand.product.id-{0}";

/// <summary>
/// Key for caching the total count and the filterable specification option ids of a storefront product listing
/// </summary>
/// <remarks>
/// {0} : hash of every listing filter - categories, brand, collection, store, customer groups, specifications,
/// price, rating, keywords, flags
/// </remarks>
public static string PRODUCTS_LISTING_SUMMARY_KEY => "Grand.product.listing-{0}";

/// <summary>
/// Key pattern to clear cache
/// </summary>
public static string PRODUCTS_LISTING_PATTERN_KEY => "Grand.product.listing";

/// <summary>
/// Key for caching
/// </summary>
Expand Down
Loading
Loading