Index

A Cache Hit Is Not a Freshness Guarantee

A catalogue price is cached at revision 1. The database commits revision 2. Three seconds later, Laravel finds the key and returns revision 1 from inside its declared five-second fresh window.

The cache hit is real. So is the stale price.

In the LAR-06 fixture, the cached JSON is well formed and its SHA-256 hash matches its body. Nothing has expired or become corrupt. The failure is semantic: current-price requires the acknowledged source revision, while the cache only knows that a key still contains bytes.

Freshness therefore belongs to the reader contract, not the cache backend. Name that contract before choosing a TTL, invalidation callback, versioned key, lock, or stale-while-revalidate window.

Start with the revision the reader is allowed to see

The fixture uses SQLite as the authoritative catalogue and Redis as a derived store. Every source update increments a revision. Every cached value carries the key family, schema, source revision, generation time, freshness windows, body, body hash, and writer.

That envelope makes two questions visible:

{
  "key_family": "catalogue:books",
  "schema": "catalogue-price-v1",
  "source_revision": 1,
  "generated_at": 100,
  "fresh_until": 105,
  "stale_until": 110,
  "body_sha256": "c0827f...",
  "writer": "ttl"
}

Is this a valid cache value? Yes. Is revision 1 acceptable to this reader when the source has acknowledged revision 2? That depends on the reader.

The fixture declares three answers:

ReaderContract
Current priceMust match the acknowledged source revision
Catalogue browseMay serve a verified value for a bounded stale window
Best-effort recommendationMay serve an older verified value during an outage, with its age and degraded status

These are synthetic contracts, not claims about Brian’s applications. Their purpose is to stop a framework feature from silently making a product decision. Serving an old thumbnail and serving an old price may use the same Redis command while requiring opposite failure behavior.

The no-cache control reads revision 1 directly from SQLite and returns it with one source call. That is not a naive baseline to dismiss. If the source can comfortably serve the workload, direct reads remove invalidation, key cleanup, stampede, and stale-data policy from the path. A cache has to earn the extra state it creates.

A TTL limits age, not disagreement with the source

Laravel’s cache repository can store a value for a duration and return it on subsequent reads. The cache documentation describes the retrieval and expiration mechanics. Neither mechanism observes a commit in an unrelated database.

The fixture makes that separation explicit:

100  cache writes source revision 1
102  SQLite commits source revision 2
103  cache hits revision 1 inside its fresh window

The TTL bounds how long this entry remains eligible under the cache’s clock. It does not guarantee that the source stays unchanged during that interval. Calling the five-second interval “fresh” is safe only after the reader agrees that up to five seconds of source disagreement is acceptable.

A shorter TTL narrows the window but increases misses and origin work. A longer TTL reduces that work while allowing disagreement to last longer. Neither duration repairs a current-price contract which says an acknowledged revision must be visible immediately.

TTL still has a useful role. It limits retention, supplies a repair path when invalidation is lost, and fits data whose acceptable age is already known. The order matters: choose the age from the reader’s tolerance, then configure the duration. Do not derive correctness from a convenient round number.

Invalidation moves the failure window; it does not erase it

The successful invalidation case commits revision 2, forgets the stable key, misses, and rebuilds revision 2. Ordering the forget after the transaction keeps uncommitted data out of the cache.

Moving the forget before the transaction creates a different problem. In the fixture, the source update rolls back after the key is removed. The next read misses and rebuilds revision 1. Correct data returns, but the miss was needless.

The harder case comes after a successful commit:

source.commit          revision 2
cache.forget.failed    cached revision 1

The database is correct and the invalidation delivery failed. A synchronous after-commit callback can make this interval small. It cannot make delivery durable. If the reader cannot tolerate the old revision, the design needs an observable repair mechanism. Depending on the consequence, that may be an outbox, a reconciliation job, a revision check, or no cache on that path.

Versioned keys change the operation. Revision 1 lives at catalogue:books:v1, revision 2 at catalogue:books:v2, and a small current pointer selects one of them. Old values may remain addressable without being selected.

That does not remove ordering concerns. The fixture advances the pointer to revision 2, then revision 3, and finally delivers an older revision 2 event. The handler compares revisions and leaves the pointer at 3. An event which blindly writes its own revision would move the reader backwards.

Stable keys trade cleanup simplicity for invalidation coordination. Versioned keys trade safer selection for a pointer protocol and old-version cleanup. Choose between those failures; “we use Redis” does not choose for you.

One cold key can become twelve source calls

Expiration solves retention and creates a concurrency problem. A popular key can become absent for many readers at once.

The fixture releases twelve real PHP processes against one missing key and a deterministic synthetic source delay. Without coordination, all twelve observe the miss and all twelve execute equivalent origin work. The response hashes match, which is precisely the waste: twelve source calls produce one logical answer.

A shared Laravel atomic lock reduces the observed origin count to one. One reader rebuilds while eleven obtain the same cached response after the lock. That protects the source, but it gives the waiters a new latency and timeout contract.

The lease must cover the work it protects. In the short-lease control, the first reader is still loading when the lock expires. A second reader acquires the same lock name, and two origin calls overlap. The cache eventually contains a valid response, yet the stampede control has already failed.

The Redis lock guidance emphasizes a unique owner value and a bounded validity time. Laravel’s pinned RedisLock implementation uses the owner token when releasing the lock. The fixture’s failure sequence keeps that ownership visible: the first owner crashes before writing, an immediate waiter times out, and a later reader rebuilds only after the lease expires. Recovery finishes with revision 2 cached and no remaining owner.

An array cache or a process-local mutex cannot prove this behavior. Every PHP process must coordinate through the same lock store. Nor does one local Redis container prove a fault-tolerant distributed-lock topology. It proves the owner, lease, waiter, and recovery semantics exercised against this pinned store.

Stale-while-revalidate spends correctness to protect latency

Laravel’s flexible method divides a value’s life into fresh and stale windows. A fresh request returns the value. A stale request also returns the value, then registers a deferred refresh. An expired or absent value rebuilds before returning.

The fixture controls the application clock instead of trying to infer these branches from sleep:

Request timeBranchImmediate revisionDeferred callbacksRevision afterward
100Cold101
103Fresh101
107Stale112
108Fresh after refresh202
118Expired303

At time 107, the fixture observes revision 1 before invoking the deferred callback. Only then does the cache move to revision 2. That order is the feature, not an implementation detail to edit out of the explanation.

Twelve concurrent stale readers all return revision 1 and register deferred callbacks. The pinned Laravel repository source uses a refresh lock and checks the stored creation time, so the callbacks produce one observed source refresh and revision 2 becomes current. The result belongs to Laravel 13.20 and the Redis store exercised here; it is not a promise about every framework version or cache driver.

The failed-refresh control returns stale revision 3, runs one deferred callback, logs the declared exception, and leaves revision 3 cached. That can be honest for catalogue browsing if the age remains acceptable. It violates the current-price contract. Cache::flexible supplies lifecycle mechanics; the reader still supplies permission to serve the stale response.

A cache miss is only one of the failure states

Code which catches every cache exception and calls the source has made several different events look like absence. The fallback might be appropriate, but the classification is gone.

The fixture isolates eight states, including:

  • an absent key;
  • an unreachable Redis endpoint;
  • a Redis user which can read revision 1 but cannot write;
  • a malformed envelope;
  • a body whose stored hash does not match its content;
  • an unavailable SQLite source;
  • a lock timeout; and
  • a failed deferred refresh.

None is silently reported as a miss. When Redis is unreachable but SQLite is available, all three readers can use the current source response. When SQLite is unavailable, current-price fails closed because it cannot establish the current revision. The two stale-tolerant readers may serve verified revision 1 at age seven, marked degraded. A corrupt value is rejected rather than granted the privileges of a previously verified stale response.

This distinction belongs in operations as well as code. A useful event names the strategy, reader contract, key family, source revision, cached revision, age, freshness state, and outcome. Those fields answer questions which a global hit ratio cannot:

  • Are current-price readers receiving an old source revision?
  • Is one cold key multiplying origin work?
  • Did invalidation stop after a successful source commit?
  • Are deferred callbacks refreshing values or only serving old ones?
  • Is Redis empty, unavailable, corrupt, or slow?

Raw full keys may contain sensitive or unbounded identifiers. A bounded key family such as catalogue:books is usually the safer signal dimension.

Choose the failure you can detect and repair

The strategies in this fixture do not form a maturity ladder.

StrategyUseful whenFailure which must be owned
No cacheThe source can serve the workloadSource capacity and availability
TTLBounded source disagreement is acceptableStale hits until expiry and concurrent misses
After-commit invalidationSource changes should remove a stable keyLost delivery and the commit-to-forget window
Versioned keysReaders can select an immutable revisionPointer ordering and old-key cleanup
Shared lockCold rebuilds threaten the sourceLease sizing, waiter timeout, owner failure
Flexible windowsThe reader explicitly tolerates stale responsesDeferred refresh and prolonged stale service

There is no cache-readiness score. Correctness, source load, latency, availability, cleanup, and operating complexity are separate dimensions. A weighted total would conceal the very disagreement the design must resolve.

The reproduction retains seven authority and invalidation cases, 51 real-process reader outcomes, 18 reader-and-strategy decisions, eight failure classes, 20 rejected counterfactuals, 13 hashed primary-source responses, and a clean export of commit 925672495e377a133b21aee4322ce18176150b23. Its source and traffic are synthetic. It does not supply Brian’s production workload, topology, cost, service objective, or approved freshness windows.

Start with no cache. Add one only when the source pressure or latency justifies another state owner. Then write down the permitted revision, invalidation boundary, cold-miss behavior, failure policy, signals, repair, cleanup, and the condition which sends the path back to direct source reads.

A hit answers “did this key resolve?” Freshness begins with the next question: “is this the revision this reader is allowed to see?”