Review Caching
Review caching optimizes performance by storing frequently accessed review data in Redis. This reduces database load and improves response times for product pages and review listings.
Caching Overview
Cache Strategy
Reviews use a persistent caching strategy:
- No Expiration: Cache entries don't expire automatically
- Invalidation-Based: Cache cleared when reviews change
- Query-Based Keys: Cache keys include query parameters
Cache Flow
Cache Key Patterns
Key Structure
const KEY_PATTERNS = {
// Reviews list with query hash
REVIEWS: (variantId: string, queryHash: string) =>
`product_reviews:${variantId}:${queryHash}`,
// Review aggregate
AGGREGATE: (variantId: string) => `product_review_agg:${variantId}`,
} as const;
Query Hashing
private hashQuery(query: ReviewQueryDto): string {
const parts = [
query.page?.toString() || "1",
query.limit?.toString() || "20",
query.sort || "newest",
query.minRating?.toString(),
query.imagesOnly ? "images" : null,
].filter(Boolean);
return parts.join(":");
}
Caching Reviews
Get Reviews from Cache
async getReviews(
variantId: string,
query: ReviewQueryDto,
): Promise<PaginatedReviews | null> {
try {
const queryHash = this.hashQuery(query);
const key = KEY_PATTERNS.REVIEWS(variantId, queryHash);
const cached = await this.client.get(key);
if (!cached) {
return null;
}
return JSON.parse(cached);
} catch (error) {
this.logger.warn(
createErrorContext(this.contextService, "getReviews", error, {
variantId,
}),
"Failed to get reviews from cache",
);
return null;
}
}
Set Reviews in Cache
async setReviews(
variantId: string,
query: ReviewQueryDto,
data: PaginatedReviews,
): Promise<void> {
try {
const queryHash = this.hashQuery(query);
const key = KEY_PATTERNS.REVIEWS(variantId, queryHash);
// Cache with no expiration (persistent cache)
await this.client.set(key, JSON.stringify(data));
} catch (error) {
this.logger.warn(
createErrorContext(this.contextService, "setReviews", error, {
variantId,
}),
"Failed to set reviews in cache",
);
}
}
Caching Aggregates
Get Aggregate from Cache
async getAggregate(
variantId: string,
): Promise<ReviewAggregateDto | null> {
try {
const key = KEY_PATTERNS.AGGREGATE(variantId);
const cached = await this.client.get(key);
if (!cached) {
return null;
}
return JSON.parse(cached);
} catch (error) {
this.logger.warn(
createErrorContext(this.contextService, "getAggregate", error, {
variantId,
}),
"Failed to get aggregate from cache",
);
return null;
}
}
Set Aggregate in Cache
async setAggregate(
variantId: string,
aggregate: ReviewAggregateDto,
): Promise<void> {
try {
const key = KEY_PATTERNS.AGGREGATE(variantId);
// Cache with no expiration (persistent cache)
await this.client.set(key, JSON.stringify(aggregate));
} catch (error) {
this.logger.warn(
createErrorContext(this.contextService, "setAggregate", error, {
variantId,
}),
"Failed to set aggregate in cache",
);
}
}
Cache Invalidation
Invalidate Variant Cache
async invalidateVariant(variantId: string): Promise<void> {
try {
// Find all review keys for this variant
const pattern = `product_reviews:${variantId}:*`;
const keys = await this.client.keys(pattern);
// Delete all review list caches
if (keys.length > 0) {
await this.client.del(...keys);
}
// Aggregate is updated, not invalidated
// (it's set directly with new values)
} catch (error) {
this.logger.warn(
createErrorContext(this.contextService, "invalidateVariant", error, {
variantId,
}),
"Failed to invalidate variant cache",
);
}
}
Invalidation Triggers
Cache is invalidated when:
- Review Approved: Review becomes visible
- Review Rejected: Review removed from visible set
- Review Updated: Review content or rating changed
- Review Deleted: Review removed from system
Invalidation Flow
// On review approval
async approveReview(reviewId: string): Promise<void> {
// ... approval logic ...
// Invalidate cache
await this.cacheService.invalidateVariant(review.variantId);
// Update aggregate (which updates aggregate cache)
await this.aggregationService.recomputeAggregate(review.variantId);
}
Cache TTL Strategy
No Expiration
const CACHE_TTL = 0; // No expiration
// Cache entries persist until invalidated
// This ensures consistency and performance
Why No Expiration?
- Consistency: Cache always matches database after invalidation
- Performance: No cache misses due to expiration
- Control: Explicit invalidation ensures accuracy
Cache Usage in Service Layer
Reviews Service
async findByVariant(
variantId: string,
query: ReviewQueryDto,
customerId?: string,
): Promise<PaginatedReviews> {
// Try cache first (only if no customerId - cache doesn't have helpful status)
const cached = await this.cacheService.getReviews(variantId, query);
if (cached && !customerId) {
return cached;
}
// Query database
const reviews = await this.queryReviewsFromDatabase(variantId, query);
// Cache result (if no customerId)
if (!customerId) {
await this.cacheService.setReviews(variantId, query, reviews);
}
return reviews;
}
Aggregate Service
async getAggregate(variantId: string): Promise<ReviewAggregateDto | null> {
// Try cache first
const cached = await this.cacheService.getAggregate(variantId);
if (cached) {
return cached;
}
// Get from database
const aggregate = await this.getAggregateFromDatabase(variantId);
if (!aggregate) {
return null;
}
// Cache it
await this.cacheService.setAggregate(variantId, aggregate);
return aggregate;
}
Cache Performance
Cache Hit Rate
// Track cache performance
cache_hit_total: counter
cache_miss_total: counter
cache_hit_rate: gauge // hit_total / (hit_total + miss_total)
Cache Benefits
- Reduced Database Load: Fewer queries to database
- Faster Response Times: Cache lookups are fast (< 1ms)
- Scalability: Handles high traffic efficiently
Cache Warming
Pre-populate Cache
async warmCache(variantIds: string[]): Promise<void> {
for (const variantId of variantIds) {
// Pre-fetch aggregates
await this.aggregationService.getAggregate(variantId);
// Pre-fetch common queries
await this.reviewsService.findByVariant(variantId, {
page: 1,
limit: 20,
sort: "newest",
});
}
}
Error Handling
Cache Failures
// Cache failures don't break the application
try {
const cached = await this.cacheService.getReviews(variantId, query);
if (cached) return cached;
} catch (error) {
// Log but continue to database
this.logger.warn("Cache lookup failed, falling back to database", error);
}
// Always fall back to database
return await this.queryReviewsFromDatabase(variantId, query);
Best Practices
- Cache First: Always check cache before database
- Invalidate Properly: Invalidate cache on all review changes
- Error Handling: Handle cache failures gracefully
- Monitor Performance: Track cache hit rates
- Query Hashing: Use consistent query hashing for cache keys
Edge Cases
Cache Miss
- Fall back to database query
- Store result in cache for next request
- No performance impact for occasional misses
Cache Invalidation Race
- Multiple updates may cause race conditions
- Last update wins (eventual consistency)
- Aggregate updates are idempotent
Cache Size
- Monitor Redis memory usage
- Implement cache eviction if needed
- Consider cache partitioning for large catalogs