Cache Invalidation

Cache invalidation is one of the hardest problems in distributed systems. layercache provides multiple strategies to invalidate cached data efficiently.

Table of Contents


Tag-Based Invalidation

Tag keys when writing, then invalidate all keys with a given tag when data changes.

Basic Usage

import { CacheStack, MemoryLayer, RedisLayer } from 'layercache'

const cache = new CacheStack([
  new MemoryLayer({ ttl: 60_000 }),
  new RedisLayer({ client: redis, ttl: 300_000 })
])

// Tag related data together
await cache.set('user:123',         user,    { tags: ['user:123'] })
await cache.set('user:123:posts',   posts,   { tags: ['user:123', 'posts'] })
await cache.set('user:123:profile', profile, { tags: ['user:123'] })

// Invalidate all data for user 123
await cache.invalidateByTag('user:123')

Multi-Tag Keys

A single key can have multiple tags:

await cache.set('post:456', post, {
  tags: ['post:456', 'author:123', 'category:tech']
})

// Invalidate by any tag
await cache.invalidateByTag('author:123')  // Removes post:456
await cache.invalidateByTag('category:tech')  // Removes post:456

Hierarchical Tagging

Use tag hierarchies for flexible invalidation:

// Product data with hierarchical tags
await cache.set('product:123', product, {
  tags: [
    'product:123',           // Individual product
    'category:electronics',  // Category
    'brand:sony',            // Brand
    'in-stock:true'          // Stock status
  ]
})

// Invalidate all Sony products
await cache.invalidateByTag('brand:sony')

// Invalidate all electronics
await cache.invalidateByTag('category:electronics')

Batch Tag Invalidation

Invalidate keys matching multiple tags with any or all semantics.

Any Mode

Delete keys tagged with any of the specified tags:

// Invalidate keys tagged with 'users' OR 'posts'
await cache.invalidateByTags(['users', 'posts'], 'any')

Use case: Clear multiple related caches at once.

// Clear all user and post caches after database migration
await cache.invalidateByTags(['users', 'posts', 'comments'], 'any')

All Mode

Delete keys tagged with all of the specified tags:

// Invalidate keys tagged with BOTH 'tenant:a' AND 'admin'
await cache.invalidateByTags(['tenant:a', 'admin'], 'all')

Use case: Invalidate specific cross-cutting concerns.

// Invalidate all admin data for tenant A
await cache.set('user:a:1', data, { tags: ['tenant:a', 'admin', 'user'] })
await cache.set('user:a:2', data, { tags: ['tenant:a', 'admin', 'user'] })
await cache.set('user:b:1', data, { tags: ['tenant:b', 'admin', 'user'] })

await cache.invalidateByTags(['tenant:a', 'admin'], 'all')
// Only user:a:1 and user:a:2 are deleted
// user:b:1 remains (different tenant)

Pattern Invalidation

Use glob-style patterns to match and delete keys.

Supported Patterns

  • * - Match any sequence of characters
  • ? - Match exactly one character
// Delete all user keys
await cache.invalidateByPattern('user:*')

// Delete all keys matching a pattern
await cache.invalidateByPattern('session:*')

// Delete keys with specific format
await cache.invalidateByPattern('report:2024-04-*')

Pattern Validation

Patterns must be:

  • Non-empty
  • At most 1024 characters
  • Free of control characters (except * and ?)
// Valid patterns
await cache.invalidateByPattern('user:*')
await cache.invalidateByPattern('temp:?')
await cache.invalidateByPattern('cache:v1:*.data')

// Invalid patterns (throw)
await cache.invalidateByPattern('')      // Empty
await cache.invalidateByPattern('a\nb')  // Contains newline

Performance Considerations

Pattern invalidation requires scanning all known keys. For better performance:

  1. Use prefix invalidation when possible (faster)
  2. Use tag-based invalidation for complex queries (more flexible)
  3. Avoid overly broad patterns like * alone
// SLOWER: Scans all keys
await cache.invalidateByPattern('*')

// FASTER: Uses trie-based prefix search
await cache.invalidateByPrefix('')

// BEST: Uses tag index
await cache.invalidateByTag('all')

Prefix Invalidation

Hierarchical prefix-based invalidation using efficient trie structures. Prefer this over pattern invalidation for hierarchical keys.

Basic Usage

// Set up hierarchical keys
await cache.set('user:123:profile', profileData)
await cache.set('user:123:posts', postsData)
await cache.set('user:123:settings', settingsData)
await cache.set('user:456:profile', otherProfile)

// Invalidate all data for user 123
await cache.invalidateByPrefix('user:123:')
// Deletes: user:123:profile, user:123:posts, user:123:settings
// Keeps: user:456:profile

Namespace Hierarchy

Combine with namespaces for clean organization:

const tenantCache = cache.namespace('tenant:acme')

await tenantCache.set('users:1', userData)
await tenantCache.set('users:2', userData)
await tenantCache.set('posts:1', postData)

// Clear all tenant data
await tenantCache.invalidateByPrefix('')

Trie-Based Performance

Prefix invalidation uses a trie data structure for O(k) lookup where k is prefix length:

// Fast: O(7) operations for 'user:123:'
await cache.invalidateByPrefix('user:123:')

// Slower: O(n) pattern matching for 'user:123:*'
await cache.invalidateByPattern('user:123:*')

Generation-Based Versioning

Add a generation prefix to every key and rotate it for instant bulk invalidation without scanning.

Basic Usage

// Create cache with generation
const cache = new CacheStack([
  new MemoryLayer({ ttl: 60_000 }),
  new RedisLayer({ client: redis, ttl: 300_000 })
], {
  generation: 1  // Start at generation 1
})

// All keys are prefixed: "g1:user:123"
await cache.set('user:123', userData)

// Bump generation to invalidate everything
cache.bumpGeneration()  // Now generation 2

// Old keys are automatically ignored
// New writes use "g2:user:123"
await cache.set('user:123', newUserData)

Auto-Cleanup Old Generations

Automatically delete keys from previous generations:

const cache = new CacheStack([...], {
  generation: 1,
  generationCleanup: {
    batchSize: 500  // Delete 500 keys per batch
  }
})

cache.bumpGeneration()  // Cleans up g1 keys in batches

Use Cases

Deployment Invalidation

// Deploy with new generation
const generation = process.env.DEPLOYMENT_ID ?? '1'

const cache = new CacheStack([...], {
  generation: Number.parseInt(generation)
})

// Every deployment starts with a fresh cache namespace

Feature Flag Rollout

// Rotate cache when enabling feature
let cacheGeneration = 1

async function enableNewFeature() {
  cacheGeneration += 1
  cache.bumpGeneration()

  // All cached data is invalidated
  // Next requests use fresh data with new feature enabled
}

Schema Migration

// Bump generation after schema change
async function migrateSchema() {
  await db.migrate()

  // Invalidate all cached data that uses old schema
  cache.bumpGeneration()
}

Distributed Invalidation

For multi-instance deployments, use distributed invalidation to keep memory caches in sync across servers.

Redis Tag Index

Share tag state across all servers using Redis:

import { RedisTagIndex } from 'layercache'

const tagIndex = new RedisTagIndex({
  client: redis,
  prefix: 'myapp:tag-index',
  scanCount: 100,
  knownKeysShards: 16  // Default: distribute known keys across 16 shards
})

const cache = new CacheStack([
  new MemoryLayer({ ttl: 60_000 }),
  new RedisLayer({ client: redis, ttl: 300_000 })
], {
  tagIndex  // All servers share the same tag index
})

// Any server can invalidate by tag
await cache.invalidateByTag('user:123')
// All servers use the same Redis-backed tag lookup

Redis Invalidation Bus

Pub/sub-based L1 invalidation for real-time memory cache consistency:

import { RedisInvalidationBus } from 'layercache'

const bus = new RedisInvalidationBus({
  publisher: redis,           // Use existing Redis connection
  subscriber: new Redis(),    // Separate connection for subscriptions
  signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET
})

const cache = new CacheStack([
  new MemoryLayer({ ttl: 60_000 }),
  new RedisLayer({ client: redis, ttl: 300_000 })
], {
  invalidationBus: bus,
  broadcastL1Invalidation: true  // Publish writes to peer memory layers
})

// When Server A writes to cache:
await cache.set('user:123', userData)
// -> Publishes invalidation message to Redis

// Server B's memory layer receives message:
// -> Deletes 'user:123' from local memory
// -> Next read fetches fresh data from Redis

Full Distributed Setup

Combine all distributed features for multi-instance consistency:

import {
  CacheStack, MemoryLayer, RedisLayer,
  RedisInvalidationBus, RedisTagIndex, RedisSingleFlightCoordinator
} from 'layercache'

const redis = new Redis()
const bus = new RedisInvalidationBus({
  publisher: redis,
  subscriber: new Redis(),
  signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET
})
const tagIndex = new RedisTagIndex({
  client: redis,
  prefix: 'myapp:tags',
  knownKeysShards: 16
})
const coordinator = new RedisSingleFlightCoordinator({
  client: redis
})

const cache = new CacheStack([
  new MemoryLayer({ ttl: 60_000, maxSize: 10_000 }),
  new RedisLayer({ client: redis, ttl: 3_600_000, prefix: 'myapp:cache:' })
], {
  invalidationBus: bus,
  tagIndex: tagIndex,
  singleFlightCoordinator: coordinator,
  gracefulDegradation: { retryAfterMs: 10_000 }
})

Signed Invalidation Messages

Set signingSecret on every instance that shares a Redis invalidation channel. Messages are signed with HMAC-SHA256 and rejected when the signature does not match, which prevents unrelated publishers on the same Redis from spoofing invalidation messages.

const bus = new RedisInvalidationBus({
  publisher: redis,
  subscriber: new Redis(process.env.REDIS_URL),
  channel: 'myapp:invalidation',
  signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET
})

All producers and consumers on the same channel must use the same secret. Omit signingSecret only for trusted, isolated Redis channels or for compatibility with older deployments.

RedisTagIndex Shard Migration

RedisTagIndex now defaults knownKeysShards to 16. Existing deployments that used the previous single-set layout (<prefix>:keys) are still read for compatibility and emit a warning, but you should migrate them into the sharded layout:

npx layercache migrate-tag-index \
  --redis rediss://prod-redis.example.com:6380 \
  --tag-index-prefix "myapp:tags" \
  --known-key-shards 16 \
  --require-tls

Invalidation Flow

Server A                        Redis                          Server B
  |                               |                               |
  | cache.set('user:123', data)   |                               |
  |------------------------------->|                               |
  |                               | SET user:123                   |
  |                               |                               |
  |                               | PUBLISH invalidate user:123    |
  |                               |------------------------------->|
  |                               |                               | delete user:123
  |                               |                               | (from memory)
  |                               |                               |

Best Practices

// GOOD: Tag related data
await cache.set('user:123', user, { tags: ['user:123'] })
await cache.set('user:123:posts', posts, { tags: ['user:123'] })
await cache.set('user:123:profile', profile, { tags: ['user:123'] })

await cache.invalidateByTag('user:123')  // Invalidates all 3 keys

2. Use Prefixes for Hierarchical Data

// GOOD: Hierarchical keys with prefix invalidation
await cache.set('tenant:acme:users:1', data)
await cache.set('tenant:acme:posts:1', data)

await cache.invalidateByPrefix('tenant:acme:')  // Fast trie lookup

3. Use Generations for Bulk Invalidation

// GOOD: Generation for instant bulk invalidation
cache.bumpGeneration()  // Invalidates everything immediately

4. Avoid Overly Broad Patterns

// BAD: Too broad
await cache.invalidateByPattern('*')

// GOOD: Specific pattern
await cache.invalidateByPattern('temp:*')

// BETTER: Prefix
await cache.invalidateByPrefix('temp:')

5. Clean Up Tags on Delete

Tags are automatically removed when keys are deleted:

await cache.set('user:123', data, { tags: ['user:123'] })
await cache.delete('user:123')  // Tags automatically cleaned up

await cache.invalidateByTag('user:123')  // No keys found