Skip to main content

ADR-0038: Cache Invalidation Strategy

Status

Accepted - 2025-01-26


Context

Cached data must be invalidated when source data changes to prevent stale data bugs (booking conflicts, wrong pricing).


Decision

Multi-layered invalidation with TTL expiry, write-through invalidation, and event-based invalidation.

Rationale

  1. Correctness: Prevent stale data bugs
  2. Performance: Selective invalidation (not global)
  3. Simplicity: Clear invalidation rules per resource
  4. Safety: TTL as fallback (eventual consistency)

Alternatives Considered

Alternative 1: TTL Only

Rejected - Stale data up to TTL duration (unacceptable for bookings)

Alternative 2: No Caching

Rejected - Slower performance, higher DB load

Alternative 3: Cache-Control Headers (HTTP)

Rejected - Only works for HTTP responses, not internal caching


Invalidation Strategies

Strategy 1: Write-Through Invalidation

Invalidate immediately on write:

// src/services/properties/updateProperty.ts
export async function updateProperty(
orgId: string,
propertyId: string,
data: Partial<Property>
) {
// 1. Update database
const [updated] = await db
.update(properties)
.set(data)
.where(and(eq(properties.orgId, orgId), eq(properties.id, propertyId)))
.returning();

// 2. Invalidate caches
await invalidateProperty(orgId, propertyId);

// 3. Publish event
await publishEvent('property.updated', { orgId, propertyId, changes: data });

return updated;
}

async function invalidateProperty(orgId: string, propertyId: string) {
await Promise.all([
// Invalidate single property
redis.del(CacheKeys.property(orgId, propertyId)),

// Invalidate property list
redis.del(CacheKeys.properties(orgId)),

// Invalidate related caches
redis.del(CacheKeys.availability(orgId, propertyId, '*')),
redis.del(CacheKeys.pricing(orgId, propertyId)),
]);
}

Strategy 2: Event-Based Invalidation

Invalidate when domain events are published:

// src/events/cacheInvalidationSubscriber.ts
import { eventBus } from '../events/eventBus';

// Subscribe to property events
eventBus.on('property.updated', async (event) => {
const { orgId, propertyId } = event.payload;
await invalidateProperty(orgId, propertyId);
});

eventBus.on('property.deleted', async (event) => {
const { orgId, propertyId } = event.payload;
await invalidateProperty(orgId, propertyId);
});

// Subscribe to booking events
eventBus.on('booking.created', async (event) => {
const { orgId, propertyId, checkIn, checkOut } = event.payload;

// Invalidate availability for affected dates
const dates = generateDateRange(checkIn, checkOut);
await Promise.all(
dates.map((date) =>
redis.del(CacheKeys.availabilityDay(orgId, propertyId, date))
)
);
});

eventBus.on('booking.cancelled', async (event) => {
const { orgId, propertyId, checkIn, checkOut } = event.payload;

// Re-invalidate to refresh availability
const dates = generateDateRange(checkIn, checkOut);
await Promise.all(
dates.map((date) =>
redis.del(CacheKeys.availabilityDay(orgId, propertyId, date))
)
);
});

Strategy 3: TTL-Based Expiry (Fallback)

Auto-expire after fixed duration:

// Properties rarely change - 1 hour TTL
await redis.setex(
CacheKeys.property(orgId, propertyId),
3600,
JSON.stringify(property)
);

// Availability changes frequently - 5 min TTL
await redis.setex(
CacheKeys.availabilityDay(orgId, propertyId, date),
300,
JSON.stringify(availability)
);

// Pricing changes dynamically - 15 min TTL
await redis.setex(
CacheKeys.pricing(orgId, propertyId),
900,
JSON.stringify(pricing)
);

Strategy 4: Manual Invalidation (Admin)

Admin endpoint for emergency invalidation:

// src/routes/admin/cache.ts
app.delete('/admin/cache/:orgId', async (req, reply) => {
const { orgId } = req.params;

// Verify admin permissions
if (!req.user.isAdmin) {
return reply.status(403).send({ error: 'Forbidden' });
}

// Invalidate all caches for org
const deletedCount = await invalidateOrgSafe(orgId);

logger.warn({ orgId, deletedCount, adminId: req.user.id }, 'Manual cache invalidation');

return reply.send({ deletedCount });
});

Invalidation Rules by Resource

ResourceInvalidation TriggerStrategyTTL Fallback
PropertyUpdate/DeleteWrite-through1 hour
AvailabilityBooking created/cancelledEvent-based5 minutes
PricingPricing rule updatedWrite-through15 minutes
BookingsCreate/Update/CancelWrite-through10 minutes
UserProfile updatedWrite-through30 minutes
SessionLogoutWrite-through24 hours

Cascade Invalidation

When Property Updated

async function invalidatePropertyCascade(orgId: string, propertyId: string) {
await Promise.all([
// Direct caches
redis.del(CacheKeys.property(orgId, propertyId)),
redis.del(CacheKeys.properties(orgId)),

// Related caches
invalidatePattern(`org:${orgId}:availability:${propertyId}:*`),
invalidatePattern(`org:${orgId}:pricing:${propertyId}:*`),
invalidatePattern(`org:${orgId}:bookings:property:${propertyId}`),
]);
}

async function invalidatePattern(pattern: string) {
let cursor = '0';

do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;

if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
}

Conditional Invalidation

Only Invalidate If Changed

export async function updatePropertyConditional(
orgId: string,
propertyId: string,
data: Partial<Property>
) {
// Check if data actually changed
const current = await getProperty(orgId, propertyId);

const hasChanged = Object.keys(data).some((key) => {
return current[key] !== data[key];
});

if (!hasChanged) {
// No changes - don't invalidate
return current;
}

// Update and invalidate
const updated = await db.update(properties).set(data).returning();
await invalidateProperty(orgId, propertyId);

return updated[0];
}

Invalidation Logging

// src/cache/invalidationLogger.ts
export async function logInvalidation(
resource: string,
key: string,
reason: string
) {
logger.info({
event: 'cache_invalidation',
resource,
key,
reason,
timestamp: new Date().toISOString(),
});

// Track invalidation rate
await redis.incr('metrics:cache:invalidations');
}

// Usage
await logInvalidation('property', CacheKeys.property(orgId, propertyId), 'property.updated');

Bulk Invalidation

Invalidate All Properties for Org

export async function invalidateAllProperties(orgId: string) {
const pattern = `org:${orgId}:property:*`;
let cursor = '0';
let deletedCount = 0;

do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;

if (keys.length > 0) {
await redis.del(...keys);
deletedCount += keys.length;
}
} while (cursor !== '0');

// Also invalidate list
await redis.del(CacheKeys.properties(orgId));

logger.info({ orgId, deletedCount }, 'Bulk invalidation: properties');
}

Testing Invalidation

// tests/cache/invalidation.test.ts
describe('Cache Invalidation', () => {
it('should invalidate property on update', async () => {
const { orgId, propertyId } = await setupTestProperty();

// Cache property
await cacheProperty(orgId, propertyId);

// Verify cached
const cached = await redis.get(CacheKeys.property(orgId, propertyId));
expect(cached).toBeTruthy();

// Update property
await updateProperty(orgId, propertyId, { name: 'Updated Name' });

// Verify invalidated
const afterUpdate = await redis.get(CacheKeys.property(orgId, propertyId));
expect(afterUpdate).toBeNull();
});

it('should invalidate availability on booking', async () => {
const { orgId, propertyId } = await setupTestProperty();
const date = '2025-02-01';

// Cache availability
await cacheAvailability(orgId, propertyId, date);

// Create booking
await createBooking(orgId, propertyId, { checkIn: date, checkOut: '2025-02-05' });

// Verify invalidated
const afterBooking = await redis.get(
CacheKeys.availabilityDay(orgId, propertyId, date)
);
expect(afterBooking).toBeNull();
});
});

Consequences

Positive

  • Correctness: Fresh data after writes
  • Performance: Selective invalidation (not global)
  • Safety: TTL fallback for missed invalidations
  • Debugging: Invalidation logging

Negative

  • Complexity: Multiple invalidation strategies
  • Over-Invalidation: May invalidate more than needed

Mitigations

  • Clear invalidation rules per resource
  • Conditional invalidation (only if changed)
  • Monitor invalidation rate (alert if >1000/min)

Validation Checklist

  • Write-through invalidation on all updates
  • Event-based invalidation for cross-resource changes
  • TTL fallback on all caches
  • Cascade invalidation for related caches
  • Invalidation logging enabled
  • Tests for all invalidation paths

References