Trovella Wiki

Invalidation

How to invalidate cached data — single-key deletion, pattern-based SCAN invalidation, and strategies for keeping cache and database in sync.

Invalidation Methods

The @repo/cache package provides two invalidation functions. Both are fire-and-forget deletes — they remove the key(s) from Redis so the next cacheGet call triggers a fresh fetch.

Single-Key Invalidation

Use cacheInvalidate when you know the exact key to remove.

import { cacheInvalidate } from "@repo/cache";

// After updating org settings in the database
await cacheInvalidate(`tenant:${orgId}:settings`);

This calls DEL on the key. If the key does not exist, the operation is a no-op.

Pattern-Based Invalidation

Use cacheInvalidatePattern when you need to clear a set of keys that share a prefix. This is the primary tool for tenant-wide cache busting.

import { cacheInvalidatePattern } from "@repo/cache";

// Clear all cached data for a tenant
await cacheInvalidatePattern(`tenant:${orgId}:*`);

// Clear all cached research plan data for a tenant
await cacheInvalidatePattern(`tenant:${orgId}:research:*`);

How It Works

The function uses Redis SCAN with MATCH and COUNT 100 to iterate through the keyspace incrementally:

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");

Key properties of this approach:

  • Non-blockingSCAN iterates in batches of ~100 keys per round-trip, so it does not block the Redis event loop like KEYS would.
  • Safe at scale — works correctly even with millions of keys in the keyspace.
  • Eventually complete — the cursor-based iteration guarantees all matching keys are visited, though keys added during the scan may or may not be included.

Performance Considerations

  • Each SCAN round-trip is a network call to Upstash Redis. For a keyspace with many matching keys, this can result in multiple round-trips.
  • The DEL calls are batched per scan page (up to 100 keys per DEL), which is efficient.
  • For bulk invalidation of thousands of keys, consider whether a TTL-based expiration strategy would be more appropriate than explicit invalidation.

When to Invalidate

Invalidate after any write operation that changes data the cache holds. The general pattern:

// 1. Write to the database
await db.update(organizations).set({ name: newName }).where(eq(organizations.id, orgId));

// 2. Invalidate the cache
await cacheInvalidate(`tenant:${orgId}:settings`);

Common invalidation points:

EventKey pattern to invalidate
Org settings updatedtenant:{orgId}:settings
Member added/removedtenant:{orgId}:members:*
Research plan status changedtenant:{orgId}:research:{planId}:*
Org deletedtenant:{orgId}:*
Reference data reseededref:*

Invalidation Strategies

TTL-Only (Passive)

For data where slight staleness is acceptable, rely on TTL expiration alone and skip explicit invalidation. This is the simplest approach and works well for:

  • Reference data that changes only during deployments
  • Aggregated counts or statistics displayed in dashboards
  • Data that is expensive to recompute but not critical to be real-time

Write-Through Invalidation (Active)

Invalidate the cache immediately after a database write. This is the recommended approach for data that users expect to see updated immediately after they make a change (their own settings, profile, etc.).

The current @repo/cache API supports this via cacheInvalidate and cacheInvalidatePattern. There is no write-through (update the cache directly) function — cache entries are always repopulated on the next read.

Event-Driven Invalidation (Future)

As the system grows, invalidation logic can be moved into Inngest event handlers. When a domain event fires (e.g., org.settings.updated), an Inngest function handles cache invalidation alongside other side effects (audit logging, notifications, etc.). This decouples the write path from cache management.

Gotchas

  • No atomic read-modify-writecacheGet and cacheInvalidate are separate operations. If two requests race (one writing, one reading), the reader may repopulate the cache with stale data immediately after invalidation. For most use cases this is acceptable because the TTL will expire the stale entry shortly. For critical data, consider shorter TTLs or skipping the cache entirely.
  • Pattern invalidation is not instantSCAN-based invalidation iterates in batches. During the scan, some keys may still be readable. This is a non-issue in practice for Trovella's current scale.
  • cacheInvalidatePattern with broad patterns — avoid * as the pattern. Use the most specific prefix possible to minimize the number of keys scanned.

On this page