Skip to content
Webitops

Notes

Two-dimensional cache versioning

Invalidating a graph of derived values with two counters instead of enumerating keys. A small technique that makes it structurally impossible to forget to invalidate something.

4 min read Caching, Architecture

If an application derives everything it displays from an event log, it will need a cache. And a cache over derived data has an unpleasant property: the set of things that must be invalidated when an input changes is not local to the change.

Add one trade to a ledger and you have invalidated the position, the average cost, the cash balance, the realised gain, the unrealised gain, the equity total, the tax report, the chart series and the allocation breakdown. Update one stock price and you have invalidated a subset of those for every user who holds it.

The usual approach is to enumerate:

Cache::forget("portfolio_summary_{$userId}");
Cache::forget("tax_report_{$userId}_{$year}");
Cache::forget("chart_data_{$userId}");
// ...and the one you forgot, which is why you are reading this

This works right up until someone adds a tenth derived value and updates four of the five places that need to forget it. The bug that results is the worst kind: intermittent, user-specific, invisible in tests, and it manifests as wrong numbers rather than an error.

Version the namespace, not the key

Instead of removing entries, make them unreachable. Every cache key carries the version counters of everything it depends on:

$key = sprintf(
    'portfolio_summary_u%d_v%d_m%d',
    $user->id,
    $this->userVersion($user),   // bumped when this user writes an event
    $this->marketVersion(),      // bumped when any price updates
);

return Cache::remember($key, now()->addDay(), fn () => $this->compute($user));

Invalidation is then an increment:

// A new trade or capital event: only this user's derived data is stale.
Cache::increment("user_version_{$user->id}");

// A price update: every user's valuations are stale.
Cache::increment('market_version');

One increment retires every key in that namespace at once — summary, tax report, chart series, allocation, the lot. Old entries are never deleted; they simply stop being addressed and expire on their own TTL.

The two dimensions matter because the invalidation sources are genuinely independent. Your own writes affect only you. A market price change affects everyone holding that instrument. A single counter would force you to bump the world every time one user recorded a trade.

Making the bump automatic

The remaining hole is a developer updating a price without remembering to increment. Close it at the model boundary rather than at the call sites:

protected static function booted(): void
{
    static::saved(fn () => Cache::increment('market_version'));
    static::deleted(fn () => Cache::increment('market_version'));
}

Now the invariant is enforced by the thing being changed, not by everyone who changes it.

What it costs

It is deliberately coarse. Bumping the market version invalidates cached data for every user, including those holding none of the affected instrument. That is over-invalidation, and you should be clear-eyed that you are trading recomputation for correctness.

At small scale that trade is obviously right: recomputation is cheap, and wrong numbers in a financial tool are not. At large scale — millions of users, prices ticking constantly — it becomes obviously wrong, and you would want per-instrument versions with the dependency tracking that implies. Somewhere in between there is a crossover point, and you should know roughly where yours is before you adopt this.

Old entries linger until TTL. Memory that would have been freed by an explicit forget is held a while longer. In practice this is why a TTL is not optional — a day is generous, and it bounds the accumulation.

Requires an atomic increment. Redis and Memcached give you this. A database cache driver can too, but check that your driver’s increment is genuinely atomic and not a read-modify-write, or concurrent bumps will be lost and you are back to serving stale data.

Where else it fits

Anywhere a set of derived values shares a small number of independent invalidation sources: a pricing engine keyed on customer tier and rate-card version, a permissions cache keyed on user and role-definition version, a rendered report keyed on tenant and template version.

The test for whether it applies is simple. Ask: when input X changes, can I confidently list every cache key that is now wrong? If the honest answer is “probably, but I would want to grep first” — the enumeration approach has already failed, and you just have not been bitten yet.