Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 46 additions & 11 deletions docs/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,18 +291,20 @@ return [
'allowed_classes' => [], // extra classes the persistent drivers may store
'stores' => [
'database' => [
'driver' => 'database',
'forever_ttl' => 10 * YEAR_IN_SECONDS,
'network' => false,
'gc' => 'daily', // false disables the flush cleanup sweep
'events' => true,
'driver' => 'database',
'forever_ttl' => 10 * YEAR_IN_SECONDS,
'network' => false,
'gc' => 'daily', // false disables the flush cleanup sweep
'events' => true,
'version_scoped' => false, // true starts the store cold on each app version change
],
'file' => [
'driver' => 'file',
'path' => null, // null derives one beneath uploads
'fallback' => 'database',
'gc' => 'daily', // false disables the sweep
'events' => true,
'driver' => 'file',
'path' => null, // null derives one beneath uploads
'fallback' => 'database',
'gc' => 'daily', // false disables the sweep
'events' => true,
'version_scoped' => false, // true starts the store cold on each app version change
],
'array' => ['driver' => 'array', 'events' => false],
],
Expand All @@ -323,6 +325,37 @@ The classes the `database` and `file` drivers may store, on top of the built-in

It applies to every store rather than per store, because a `file` store can silently divert to its `fallback`. A value must not be accepted by one and refused by the other. The list is only ever read from configuration, never from stored data.

### `version_scoped`

A store with `'version_scoped' => true` starts cold whenever the application version changes. Turn it on for a store that holds values whose **shape your own code produces**, such as a remote JSON list after your plugin normalizes it. Without it, an update that changes that shape reads entries written by the previous release under the same key. In one real case, 1.0.1 read a list cached by 1.0.0 that had no `slug` field, and the WordPress update check crashed with a `TypeError`.

```php
'stores' => [
'database' => [
'driver' => 'database',
'version_scoped' => true,
],
],
```

How it works:

- The first time a request uses the store, the framework compares `app()->version()` with the version recorded in the option `<prefix>cache_app_version_<store name>`. This is a network-wide site option when the store has `'network' => true`.
- If the two differ, the store is flushed with the ordinary `flush()`, and the new version is recorded. A downgrade counts as a change.
- A **missing** recorded version also counts as a change. So the first request after you turn the setting on, or after upgrading from a framework release without it, flushes once.
- The check runs at most once per store per request, and only in requests that use the store. The option is autoloaded, so reading it costs no query.
- The version is recorded only when `flush()` succeeds, so a failed flush is retried on the next request.
- A `file` store that diverts to its `fallback` is checked under the fallback's name, using the fallback's `version_scoped` setting.
- It has no effect on the `array` store, which lives for one request anyway.

What it costs:

- **Every update starts with a cold cache.** Only turn it on for entries you can rebuild from their source. Leave it off for any store that holds values you cannot recreate.
- **The flush reaches every store that shares the namespace.** Every `database` store is namespaced by the application prefix alone, not by store name, and so is every `file` store on the default path. Scoping one of them therefore empties all of them.
- **Rate-limiter counts start again.** This happens when `cache.limiter` is `null` (the limiter counts in the default store), or when the limiter's store shares the namespace with a scoped store, as the previous point describes. To keep counts across an update, give the limiter a store on a different backend.

The flush goes through the store's normal `flush()`, not a version suffix in the key prefix. That keeps the superseded entries in the scheduled sweep described in [section 9](#9-the-drivers): a moved prefix would strand the `database` driver's stale-version queue, and every `forever()` row and `file` directory with it.

---

## 11. Events
Expand All @@ -343,7 +376,7 @@ To share a store across an entire network, set `'network' => true`. Its entries

## 13. Where this differs from Laravel

Five deliberate divergences, each forced by WordPress:
Six deliberate divergences, each forced by WordPress:

**`forever()` uses a long finite lifetime.** WordPress marks a transient with no expiry as an *autoloaded* option, and skips its own 150,000-byte autoload guard when doing so — so a large `forever()` value would be read and unserialized on every request, at a cost paid before your code runs and therefore nearly undiagnosable. The entry never logically expires as far as the cache API is concerned, while the underlying transient is given a ten-year lifetime so WordPress leaves it out of autoload. Set `forever_ttl` to `0` for literal never-expiry. Note that Laravel does not treat `forever` as durable either — its own documentation warns that Memcached may evict such entries.

Expand All @@ -355,6 +388,8 @@ Five deliberate divergences, each forced by WordPress:

**`flush()` advances a namespace version.** Necessary because transients cannot be enumerated under an object cache. The practical effect is the same — every prior key reads as a miss — and it takes constant time. Reclaiming the rows a version leaves behind happens later, off the request that called `flush()`, in the same scheduled sweep described in [section 9](#9-the-drivers).

**`version_scoped` has no Laravel counterpart.** Laravel leaves invalidating a cache on deploy to the deploy process (`php artisan cache:clear`). A WordPress plugin update has no such step, so the framework provides one. See [section 10](#version_scoped).

`Cache::lock()` **does** exist — see [section 15](#15-locks) — though only the acquire, release and callback forms; `block()`, `funnel()` and `without_overlapping()` do not. Absent by design: tagged cache (`supports_tags()` returns `false`), the `failover` driver, `sear`, and `rememberWithWarmth`.

---
Expand Down
30 changes: 30 additions & 0 deletions example/config/cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@
* this on costs almost nothing.
*/
'events' => true,

/*
* When true, the store starts over whenever the application
* version changes: the first request on the new version that uses
* the store flushes it once. A store with no recorded version
* counts as changed, so turning this on flushes once too.
*
* Turn this on for stores holding values whose shape your own code
* produces, so an update that changes the shape never reads an
* entry written by the previous release. The cost is a cold cache
* after every update. Every database store shares one namespace,
* so this flushes all of them, and the rate limiter's counts start
* again if it counts in one of them.
*/
'version_scoped' => false,
],

/*
Expand Down Expand Up @@ -123,6 +138,21 @@

'network' => false,
'events' => true,

/*
* When true, the store starts over whenever the application
* version changes: the first request on the new version that uses
* the store flushes it once. A store with no recorded version
* counts as changed, so turning this on flushes once too.
*
* Turn this on for stores holding values whose shape your own code
* produces, so an update that changes the shape never reads an
* entry written by the previous release. The cost is a cold cache
* after every update. Every file store on the default path shares
* one directory, so this flushes all of them, and the rate
* limiter's counts start again if it counts in one of them.
*/
'version_scoped' => false,
],

/*
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/add-cache-version-scoping/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-10
146 changes: 146 additions & 0 deletions openspec/changes/add-cache-version-scoping/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
## Context

See proposal.md (Why) for the motivation, and specs/cache-version-scoping/spec.md for the
requirements.

Current state that shapes the approach:

- `CacheManager::store()` resolves a store once per request through `resolve($name)` and memoises
the repository in `$this->stores[$name]`. `memo()` goes through `store()`, and so does the rate
limiter (`RateLimiter::cache()`).
- `Repository::flush()` clears the `MemoTable` for the store, calls `Store::flush()`, and fires
`CacheFlushed` when the flush returns `true`.
- `DatabaseStore::flush()` increments `<prefix>cache_version` and queues the old version in
`<prefix>cache_stale_versions` for `gc()`. `FileStore::flush()` deletes its directory.
- `DatabaseStore` takes its scope from the `network` constructor flag and reads options through
`get_site_option()` / `get_option()` (`read_option()`).
- `create_file_repository()` catches a store that cannot run and returns
`$this->store($fallback)`. That repository carries the **fallback's** name.
- Every `database`-driver store is built from `$this->prefix()` alone, and so is the default `file`
directory. Neither includes the store name, so all stores on one driver share one namespace.
- `Application::version()` returns a string (default `'1.0.0'`, set with `use_version()`). A
container that is not a `Framework\Application` may have no `version()`.

## Goals / Non-Goals

**Goals:**

- Flush a scoped store at most once per version change, in the first request that uses it.
- Add no database query in the steady state.
- Keep stores that are not scoped exactly as they are.

**Non-Goals:**

- Per-store namespaces for stores that share a driver. This is documented as a caveat, not fixed.
- Guarding concurrent first requests with a lock. Two requests may both flush, and the namespace
version then rises by two. This is safe.
- Changing how `lock()` derives its prefix. This is reported as a known issue in the PR.

## Decisions

### D1. Flush through the existing namespace, not a version in the prefix

The proposal records the reasoning ("Why a flush rather than a version in the key prefix"). The
key point is that `flush()` already keeps the `gc()` queue, the `forever()` rows and the file
directories consistent, and a prefix change would bypass all three.

### D2. Cache the repository first, then check

```php
if (!isset($this->stores[$name])) {
$this->stores[$name] = $this->resolve($name);

$this->scope_to_application_version($this->stores[$name]);
}
```

`Repository::flush()` fires `CacheFlushed`. If the repository were checked before it is memoised,
a listener that calls `Cache::store()` for the same store would resolve it again and flush again,
or loop. Memoising first makes a re-entrant call return the same instance with no check.
*Alternative rejected:* check, then cache. It matches the brief's wording more literally, but the
re-entrancy above makes it unsafe.

### D3. The check takes the repository and reads the store name from it

The method signature is `scope_to_application_version(Repository $repository)`. It reads
`$repository->get_name()` and `$this->store_config($that_name)`. For a file store that fell back,
the name is the fallback's, so the fallback's `version_scoped` and `network` apply. The nested
`store($fallback)` call has already checked that store, so the outer check finds the recorded
version equal to the current one and does nothing. No special case is needed.
*Alternative rejected:* passing `$name` from `store()`. That would check the fallback repository
under the file store's name and config, which the brief rules out.

### D4. Method shape

- `protected`, `@since 3.3.1`, `@return void`.
- Steps:
1. Read `$config = $this->store_config($name)`.
2. Return early unless `($config['version_scoped'] ?? false) === true`.
3. Read `$app = app()`. Return early unless `method_exists($app, 'version')`.
4. Read the recorded version with default `null`, so that a missing option and a stored empty
string stay distinguishable. Compare with `(string) $app->version()` as strings. Any
difference counts, including a downgrade.
5. If `$repository->flush()` returns `true`, write the version: `update_option($option, $version,
true)` for a site store, or `update_site_option($option, $version)` for a network store.
- The option name is built by a small protected helper,
`application_version_option(string $name)`, which returns
`$this->prefix() . 'cache_app_version_' . $name`. The test reads the name from it, and a
subclass can override it.
- Scope comes from `$config['network'] ?? false`, not from the store object, so custom drivers
behave the same way.

### D5. Record the version only after a successful flush

A `false` result leaves the option unchanged, so the next request retries. Exceptions are not
caught. The built-in stores cannot fail here, and a custom driver whose flush throws should
surface the error rather than serve old-shape data silently. *Alternatives rejected:* always
writing the option, which marks a stale cache as current; and catching and logging, which adds
error handling for a case the built-in stores cannot hit.

### D6. Configuration and docs

- `default_configuration()` adds `'version_scoped' => false` to `database` and `file` only.
It is pointless on `array`, which lives for one request.
- `example/config/cache.php` gets a `/* */` block comment on the key in both persistent stores,
in the style of `forever_ttl` and `network`.
- `docs/cache.md` §10 gets the key in the snippet and a `### version_scoped` subsection covering:
- when to turn it on;
- the cold-cache cost after each update;
- the rate-limiter reset;
- the shared-namespace caveat.
- `docs/cache.md` §13 gets one line saying Laravel has no equivalent.

### D7. Tests

- New file `tests/Unit/Cache/VersionScopedCacheTest.php`, extending `CacheTestCase`.
- Uses `ConfigurableCacheManager::set_stores()` and `$this->app->use_version()`.
- Each "request" is simulated with a fresh `CacheManager` (or `forget_resolved()` plus
`MemoTable::clear()`), so stores re-resolve.
- The `update_option` stub ignores `$autoload` today. To assert the option is autoloaded, the stub
records it in `$GLOBALS['framework_test_option_autoload']`, which `CacheTestCase` resets.
- The failure case uses a custom driver registered through `extend()`, whose `flush()` returns
`false`.
- The re-entrancy case registers a `CacheFlushed` listener that calls `store()` on the same store,
using the event mechanism `CacheEventsTest` uses.
- The fallback case needs no extra setup. The test process has no direct filesystem, so a `file`
store always diverts to its fallback, as
`FileStoreTest::test_the_store_diverts_to_its_fallback_when_direct_access_is_unavailable` relies on.

## Risks / Trade-offs

- [Every `database`-driver store shares one namespace, so scoping one store flushes all of them,
including a separately configured limiter store on that driver] → Documented in `docs/cache.md`
and the example config. To keep its counts, the limiter needs a store on another backend.
- [Any flush, this one included, changes the default store's `get_prefix()`, so locks held at that
moment become invisible to new requests] → Known issue, listed in the PR. Not changed here.
- [Concurrent first requests both see the old version and both flush, so the namespace version
rises by two] → Safe: the result is still a cold cache with every superseded version queued.
No lock is added.
- [A custom driver whose flush keeps returning `false` is retried on every request] → This is
intended: a stale-shape cache is worse than a repeated flush attempt.

## Migration Plan

No migration. The setting defaults to `false`. A consumer that enables it gets one flush on the
first request after deploying, because the missing option counts as a change. Rollback means
setting it back to `false`. The recorded option stays behind and is harmless.
Loading
Loading