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
- Correctness: Prevent stale data bugs
- Performance: Selective invalidation (not global)
- Simplicity: Clear invalidation rules per resource
- 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
| Resource | Invalidation Trigger | Strategy | TTL Fallback | 
|---|---|---|---|
| Property | Update/Delete | Write-through | 1 hour | 
| Availability | Booking created/cancelled | Event-based | 5 minutes | 
| Pricing | Pricing rule updated | Write-through | 15 minutes | 
| Bookings | Create/Update/Cancel | Write-through | 10 minutes | 
| User | Profile updated | Write-through | 30 minutes | 
| Session | Logout | Write-through | 24 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