diff --git a/openspec/changes/add-cache-version-scoping/.openspec.yaml b/openspec/changes/archive/2026-11-10-add-cache-version-scoping/.openspec.yaml similarity index 100% rename from openspec/changes/add-cache-version-scoping/.openspec.yaml rename to openspec/changes/archive/2026-11-10-add-cache-version-scoping/.openspec.yaml diff --git a/openspec/changes/add-cache-version-scoping/design.md b/openspec/changes/archive/2026-11-10-add-cache-version-scoping/design.md similarity index 100% rename from openspec/changes/add-cache-version-scoping/design.md rename to openspec/changes/archive/2026-11-10-add-cache-version-scoping/design.md diff --git a/openspec/changes/archive/2026-11-10-add-cache-version-scoping/pr.md b/openspec/changes/archive/2026-11-10-add-cache-version-scoping/pr.md new file mode 100644 index 0000000..abf2f19 --- /dev/null +++ b/openspec/changes/archive/2026-11-10-add-cache-version-scoping/pr.md @@ -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..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 `cache_app_version_` (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) diff --git a/openspec/changes/add-cache-version-scoping/proposal.md b/openspec/changes/archive/2026-11-10-add-cache-version-scoping/proposal.md similarity index 100% rename from openspec/changes/add-cache-version-scoping/proposal.md rename to openspec/changes/archive/2026-11-10-add-cache-version-scoping/proposal.md diff --git a/openspec/changes/add-cache-version-scoping/specs/cache-version-scoping/spec.md b/openspec/changes/archive/2026-11-10-add-cache-version-scoping/specs/cache-version-scoping/spec.md similarity index 100% rename from openspec/changes/add-cache-version-scoping/specs/cache-version-scoping/spec.md rename to openspec/changes/archive/2026-11-10-add-cache-version-scoping/specs/cache-version-scoping/spec.md diff --git a/openspec/changes/add-cache-version-scoping/tasks.md b/openspec/changes/archive/2026-11-10-add-cache-version-scoping/tasks.md similarity index 100% rename from openspec/changes/add-cache-version-scoping/tasks.md rename to openspec/changes/archive/2026-11-10-add-cache-version-scoping/tasks.md diff --git a/openspec/specs/cache-version-scoping/spec.md b/openspec/specs/cache-version-scoping/spec.md new file mode 100644 index 0000000..86ffd23 --- /dev/null +++ b/openspec/specs/cache-version-scoping/spec.md @@ -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 +`cache_app_version_`. 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 `cache_app_version_` 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 `cache_app_version_`, 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