Skip to main content

ADR-0037: Cache Key Naming Convention

Status

Accepted - 2025-01-26


Context

Consistent cache key naming prevents collisions, enables bulk invalidation, and simplifies debugging.


Decision

Hierarchical key convention with namespace, resource, and identifier segments.

Rationale

  1. Collision Prevention: Unique keys per tenant + resource
  2. Bulk Invalidation: Pattern-based deletion (org:123:properties:*)
  3. Debugging: Human-readable keys
  4. Consistency: Enforced via TypeScript functions

Key Convention

Format

{namespace}:{tenant_id}:{resource}:{id}:{sub_resource}

Examples:

org:abc-123:properties                          # List all properties
org:abc-123:property:prop-456 # Single property
org:abc-123:availability:prop-456:2025-02 # Availability for property (monthly)
org:abc-123:pricing:prop-456 # Pricing for property
session:sess-789 # User session
ratelimit:org-abc-123:/api/v1/bookings # Rate limit counter

Alternatives Considered

Alternative 1: Flat Keys

Rejected - High collision risk, no namespace isolation

property:123  # Ambiguous - which org?

Alternative 2: JSON-Encoded Keys

Rejected - Hard to read, harder to debug

{"org":"abc","resource":"property","id":"123"}

Alternative 3: UUID-Only Keys

Rejected - Not human-readable, can't pattern-match

550e8400-e29b-41d4-a716-446655440000

Implementation

1. Key Builder Functions

// src/cache/keys.ts
export const CacheKeys = {
// Properties
properties: (orgId: string) =>
`org:${orgId}:properties`,

property: (orgId: string, propertyId: string) =>
`org:${orgId}:property:${propertyId}`,

propertyWithVersion: (orgId: string, propertyId: string, version: number) =>
`org:${orgId}:property:${propertyId}:v${version}`,

// Availability
availability: (orgId: string, propertyId: string, yearMonth: string) =>
`org:${orgId}:availability:${propertyId}:${yearMonth}`,

availabilityDay: (orgId: string, propertyId: string, date: string) =>
`org:${orgId}:availability:${propertyId}:${date}`,

// Bookings
bookings: (orgId: string) =>
`org:${orgId}:bookings`,

booking: (orgId: string, bookingId: string) =>
`org:${orgId}:booking:${bookingId}`,

bookingsByProperty: (orgId: string, propertyId: string) =>
`org:${orgId}:bookings:property:${propertyId}`,

// Pricing
pricing: (orgId: string, propertyId: string) =>
`org:${orgId}:pricing:${propertyId}`,

pricingByDate: (orgId: string, propertyId: string, date: string) =>
`org:${orgId}:pricing:${propertyId}:${date}`,

// Users & Sessions
user: (orgId: string, userId: string) =>
`org:${orgId}:user:${userId}`,

session: (sessionId: string) =>
`session:${sessionId}`,

// API Responses (Channel Integrations)
apiResponse: (channel: string, endpoint: string, params: string) =>
`api:${channel}:${endpoint}:${params}`,

// Rate Limiting
rateLimit: (orgId: string, endpoint: string, window: string) =>
`ratelimit:${orgId}:${endpoint}:${window}`,

// Idempotency
idempotency: (idempotencyKey: string) =>
`idempotency:${idempotencyKey}`,
};

2. Dynamic Key Generation

// src/cache/keyBuilder.ts
export class CacheKeyBuilder {
constructor(private namespace: string, private orgId?: string) {}

resource(name: string, id?: string): string {
const parts = [this.namespace];

if (this.orgId) {
parts.push(this.orgId);
}

parts.push(name);

if (id) {
parts.push(id);
}

return parts.join(':');
}

withSubResource(base: string, subResource: string, id?: string): string {
const parts = [base, subResource];
if (id) parts.push(id);
return parts.join(':');
}
}

// Usage
const builder = new CacheKeyBuilder('org', orgId);
const key = builder.resource('property', propertyId);
// Result: org:abc-123:property:prop-456

Namespace Rules

Namespace Prefixes

PrefixPurposeExample
org:Multi-tenant dataorg:abc-123:properties
session:User sessionssession:sess-789
ratelimit:Rate limitingratelimit:org-abc:endpoint
api:External API responsesapi:hostaway:listings:page1
idempotency:Idempotency keysidempotency:req-abc-123
lock:Distributed lockslock:booking:prop-456
queue:Job queue metadataqueue:webhook:job-123

Bulk Invalidation Patterns

Invalidate All Properties for Org

export async function invalidateAllProperties(orgId: string) {
const pattern = `org:${orgId}:property:*`;
const keys = await redis.keys(pattern);

if (keys.length > 0) {
await redis.del(...keys);
}

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

Invalidate Availability for Property

export async function invalidateAvailability(orgId: string, propertyId: string) {
const pattern = `org:${orgId}:availability:${propertyId}:*`;
const keys = await redis.keys(pattern);

if (keys.length > 0) {
await redis.del(...keys);
}
}

Invalidate All Caches for Org (Dangerous!)

export async function invalidateOrg(orgId: string) {
const pattern = `org:${orgId}:*`;
const keys = await redis.keys(pattern);

if (keys.length > 0) {
await redis.del(...keys);
}

logger.warn({ orgId, keysDeleted: keys.length }, 'Invalidated all org caches');
}

⚠️ Warning: KEYS command is slow on large datasets. Use SCAN in production:

export async function invalidateOrgSafe(orgId: string) {
const pattern = `org:${orgId}:*`;
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');

logger.info({ orgId, deletedCount }, 'Invalidated org caches');
}

Special Characters Handling

Escape Special Characters

export function escapeKey(value: string): string {
// Replace special chars that could cause issues
return value.replace(/[:\s]/g, '_');
}

// Usage
const propertyName = "Villa:Sunset Beach";
const key = `org:${orgId}:property:${escapeKey(propertyName)}`;
// Result: org:abc-123:property:Villa_Sunset_Beach

Key Length Limits

Redis key max length: 512MB (practically unlimited)

Best practice: Keep keys under 100 characters for readability.

// BAD: Too long
const badKey = `org:abc-123:property:prop-456:availability:2025-01-26:unit:unit-789:pricing:rule:rule-123`;

// GOOD: Hierarchical, concise
const goodKey = `org:abc-123:availability:prop-456:2025-01-26`;

Debugging

List All Keys for Org

# Redis CLI
redis-cli KEYS "org:abc-123:*"

# Or programmatically
const keys = await redis.keys('org:abc-123:*');
console.log(keys);

Inspect Key Value

redis-cli GET "org:abc-123:property:prop-456"

Count Keys by Pattern

export async function countKeys(pattern: string): Promise<number> {
const keys = await redis.keys(pattern);
return keys.length;
}

// Usage
const propCount = await countKeys('org:abc-123:property:*');
console.log(`Cached properties: ${propCount}`);

Consequences

Positive

  • Collision-Free: Unique keys per tenant + resource
  • Bulk Invalidation: Pattern-based deletion
  • Human-Readable: Easy to debug
  • Consistent: Enforced via TypeScript

Negative

  • Longer Keys: More memory per key
  • KEYS Command: Slow on large datasets

Mitigations

  • Keys are small (avg 50 bytes), memory impact negligible
  • Use SCAN instead of KEYS in production

Validation Checklist

  • All cache keys use CacheKeys functions
  • Multi-tenant keys include org:${orgId} prefix
  • Bulk invalidation uses SCAN (not KEYS)
  • Special characters escaped in dynamic keys
  • Keys under 100 characters
  • Debugging utilities available

References