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
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Add opt-in `version_scoped` cache stores that start cold on app version change

## Problem

A plugin caches values whose shape its own code produces, for example a remote JSON list after
the plugin normalizes it. When a plugin update changes that shape, the cache key stays the same,
so the new code reads an entry in the old shape and fails. In a real case, version 1.0.1 read a
list cached by 1.0.0 that had no `slug` field, and the WordPress plugin update check crashed with a
`TypeError`.

Every cache entry can be rebuilt from its source, so after a version change the correct state is a
cold cache.

## What this adds

- A per-store setting, `cache.stores.<name>.version_scoped` (bool, default `false`). It is added to
the built-in `database` and `file` stores.
- `CacheManager::store()` now runs a protected check, `scope_to_application_version()`, straight
after `resolve()`. For a scoped store, the check:
- reads `<app prefix>cache_app_version_<store name>` (a site option when `network => true`);
- if that differs from `app()->version()`, or is missing, calls `flush()` on the repository and
then records the version, autoloaded.
- The version is recorded only when `flush()` returns `true`, so a failed flush is retried on the
next request.
- The repository is memoised **before** the check runs, so a `CacheFlushed` listener that resolves
the same store gets the same instance and cannot cause a second flush.
- The store name comes from the repository. A `file` store that diverts to its fallback is
therefore checked under the fallback's name and configuration.
- `store()` memoises per request, so the check runs at most once per store per request, and only
in requests that use the store. The option is autoloaded, so the read adds no query.
- A missing option counts as a change. A site that upgrades from a framework release without this
setting may hold old-shape entries, so it must flush once.

## Why a flush, and not a version in the key prefix

- `DatabaseStore` builds `cache_version`, `cache_stale_versions` and `cache_counters` from the
same prefix. A version in the prefix moves them too, so the previous version's stale-version
queue is lost and `gc()` never removes those rows.
- Rows under an old prefix are in no `gc()` queue. Entries written with `forever()` (TTL
`forever_ttl`, ten years by default) would be left behind by every release, and `FileStore`
would leave one orphaned directory per release.
- `flush()` already handles all of this. `DatabaseStore::flush()` increments the namespace version
and queues the old one for `gc()`. `FileStore::flush()` deletes its directory. A persistent
object cache evicts the old namespace by itself.

## Why opt-in

This is a general-purpose library. A consumer may keep values in the cache that it cannot rebuild,
so a flush the consumer did not ask for would change behaviour. The rate limiter also counts in
the default store when `cache.limiter` is `null`, and a flush resets its counts to zero.

## Caveat: stores share a namespace

Every `database`-driver 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 flushes all of
them, a limiter store on the same driver included. This is documented in `docs/cache.md` and
`example/config/cache.php`, not changed here.

## Known issues (not changed in this PR)

- **Locks lose visibility on any flush.** `CacheManager::lock()` builds its lock prefix from
`$this->store()->get_prefix()`, which includes the namespace version. Any `flush()`, the one this
setting performs included, therefore makes the locks that requests hold at that moment invisible
to new requests.
- **Concurrent first requests may both flush.** Two requests can both see the old version and both
flush, so the namespace version rises by two. This is safe: the cache is still cold, and both
superseded versions are queued for `gc()`. No lock was added for it.

## Tests

`tests/Unit/Cache/VersionScopedCacheTest.php` has 12 tests:

- a scoped store loses the value after an A → B update, and so does a downgrade;
- an unscoped store keeps it;
- the built-in defaults are off;
- a missing option flushes once and writes an autoloaded option;
- an unchanged version does not flush;
- a network store uses a site option;
- the flushed namespace is in the `gc()` queue, and `gc()` reclaims it;
- a flush that returns `false` leaves the version unchanged;
- a re-entrant resolve from a `CacheFlushed` listener flushes only once;
- a fallback is checked under its own name;
- an app without `version()` is never flushed.

The `update_option` test stub now records its autoload argument.

`composer phpcs && composer test:unit`: 947 tests pass. PHPCS reports no errors; the three warnings
it reports already exist on `main`, in files this PR does not touch.

## Docs

- `docs/cache.md` §10 adds the key to the configuration snippet and a new `version_scoped`
subsection: what it does, when to turn it on, what it costs, and the shared-namespace caveat.
- `docs/cache.md` §13 notes there is no Laravel counterpart.
- `example/config/cache.php` adds the key with an annotated comment on both persistent stores.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
95 changes: 95 additions & 0 deletions openspec/specs/cache-version-scoping/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
## Purpose

Lets a consuming plugin opt a cache store into starting cold whenever the application version
changes, so that entries written by an older release can never be read by code that expects a
different shape.

## Requirements

### Requirement: Version scoping is opt-in per store
Each configured cache store SHALL accept a boolean `version_scoped` setting, which defaults to
`false`. A store whose setting is anything other than `true` SHALL behave exactly as it did before
this capability existed. Nothing SHALL be flushed, and no version SHALL be recorded for it.

#### Scenario: Disabled store keeps values across a version change
- **WHEN** a store with `version_scoped => false` holds a value written while the application version was A, and the store is resolved again in a later request where the application version is B
- **THEN** the value is still returned

#### Scenario: Default configuration leaves scoping off
- **WHEN** the built-in `database` or `file` store is resolved with no `version_scoped` setting configured
- **THEN** the setting is `false` and no version is recorded

### Requirement: A version change empties a scoped store
When a store with `version_scoped => true` is first resolved in a request, the framework SHALL
compare the application version with the version recorded for that store. If they differ, the
framework SHALL flush the store before handing it out and SHALL then record the current
application version. Any difference SHALL count as a change, a downgrade included.

#### Scenario: Value written under an older version is gone
- **WHEN** a store with `version_scoped => true` holds a value written while the application version was A, and the store is resolved again in a later request where the application version is B
- **THEN** the value is not returned, and B is recorded as the store's version

#### Scenario: Same version does not flush
- **WHEN** a store with `version_scoped => true` is resolved while the recorded version equals the application version
- **THEN** the store is not flushed and its entries remain readable

#### Scenario: Application without a version
- **WHEN** a store with `version_scoped => true` is resolved and the application exposes no version
- **THEN** the store is not flushed and no version is recorded

### Requirement: A missing recorded version counts as a change
If no version has been recorded for a scoped store, the framework SHALL treat that as a version
change, flush the store, and record the current version. A site upgrading from a framework release
without this capability may hold entries in an old shape and MUST start cold.

#### Scenario: First resolve after upgrading the framework
- **WHEN** a store with `version_scoped => true` holds a value and has no recorded version
- **THEN** the first resolve flushes the store, the value is no longer returned, and the current application version is recorded

### Requirement: Check runs at most once per store per request
The version check SHALL run only when a store is resolved for the first time in a request, and
SHALL NOT run for stores the request never uses. Resolving the same store again in the same
request, including from inside a listener for the flush event, SHALL return the already-resolved
store without checking or flushing again.

#### Scenario: Re-entrant resolve during the flush
- **WHEN** a listener for the cache-flushed event resolves the same store while the version check is flushing it
- **THEN** the store is flushed exactly once in that request

### Requirement: Recorded version storage
The recorded version SHALL be stored in an autoloaded option named
`<application prefix>cache_app_version_<store name>`. For a store configured with `network => true`
it SHALL be a network-wide site option instead.

#### Scenario: Site-scoped store
- **WHEN** a store with `version_scoped => true` and `network => false` records a version
- **THEN** the version is written to the site option `<prefix>cache_app_version_<store name>` and no network option is written

#### Scenario: Network store
- **WHEN** a store with `version_scoped => true` and `network => true` records a version
- **THEN** the version is read from and written to the network option `<prefix>cache_app_version_<store name>`, and no site option is written

### Requirement: Failed flush is retried
The version SHALL be recorded only when the flush reports success. When the flush reports failure,
the recorded version SHALL remain unchanged, so the next request tries the flush again. An
exception raised by the flush SHALL reach the caller unchanged.

#### Scenario: Flush reports failure
- **WHEN** a scoped store's flush reports failure during a version change
- **THEN** the recorded version is not updated

### Requirement: Fallback stores are checked under their own name
When a store diverts to a fallback store, the version check SHALL use the fallback store's name
and configuration, not those of the store that diverted.

#### Scenario: File store falls back to the database store
- **WHEN** a `file` store with `version_scoped => true` cannot run and diverts to a `database` store with `version_scoped => false`
- **THEN** the database store is not flushed and no version is recorded under the `file` store's name

### Requirement: Flushed entries remain reclaimable
A flush triggered by a version change SHALL leave the superseded entries reclaimable by the
store's scheduled garbage collection, exactly as an explicit flush does.

#### Scenario: Database store queues the old namespace
- **WHEN** a scoped `database` store is flushed because the version changed
- **THEN** the superseded namespace version is in the store's garbage-collection queue, and a garbage-collection run removes its rows
Loading