Repository navigation
Add opt-in version_scoped cache stores that start cold on app version change - #18
Merged
Merged
Conversation
… change A plugin update that changes the shape of a cached value kept reading entries written by the previous release under the same key (1.0.1 read a list cached by 1.0.0 without a `slug` field and the update check crashed with a TypeError). A store with `version_scoped => true` is now flushed the first time a request resolves it after app()->version() changes, and the version is recorded in an autoloaded `<prefix>cache_app_version_<store>` option (a site option for network stores). A missing option counts as a change. The version is recorded only after a successful flush, the repository is memoised before the check so a CacheFlushed listener cannot flush twice, and a file store that falls back is checked under the fallback's name. The existing namespace flush is used instead of a version in the key prefix, so superseded entries stay in the gc() queue and forever() rows and file directories are not orphaned. The setting is opt-in (default false) because a flush the consumer did not ask for changes behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
slugfield, and the WordPress plugin update check crashed with aTypeError.Every cache entry can be rebuilt from its source, so after a version change the correct state is a
cold cache.
What this adds
cache.stores.<name>.version_scoped(bool, defaultfalse). It is added tothe built-in
databaseandfilestores.CacheManager::store()now runs a protected check,scope_to_application_version(), straightafter
resolve(). For a scoped store, the check:<app prefix>cache_app_version_<store name>(a site option whennetwork => true);app()->version(), or is missing, callsflush()on the repository andthen records the version, autoloaded.
flush()returnstrue, so a failed flush is retried on thenext request.
CacheFlushedlistener that resolvesthe same store gets the same instance and cannot cause a second flush.
filestore that diverts to its fallback istherefore checked under the fallback's name and configuration.
store()memoises per request, so the check runs at most once per store per request, and onlyin requests that use the store. The option is autoloaded, so the read adds no query.
setting may hold old-shape entries, so it must flush once.
Why a flush, and not a version in the key prefix
DatabaseStorebuildscache_version,cache_stale_versionsandcache_countersfrom thesame 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.gc()queue. Entries written withforever()(TTLforever_ttl, ten years by default) would be left behind by every release, andFileStorewould leave one orphaned directory per release.
flush()already handles all of this.DatabaseStore::flush()increments the namespace versionand queues the old one for
gc().FileStore::flush()deletes its directory. A persistentobject 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.limiterisnull, 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
filestore on the default path. Scoping one of them therefore flushes all ofthem, a limiter store on the same driver included. This is documented in
docs/cache.mdandexample/config/cache.php, not changed here.Known issues (not changed in this PR)
CacheManager::lock()builds its lock prefix from$this->store()->get_prefix(), which includes the namespace version. Anyflush(), the one thissetting performs included, therefore makes the locks that requests hold at that moment invisible
to new requests.
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.phphas 12 tests:gc()queue, andgc()reclaims it;falseleaves the version unchanged;CacheFlushedlistener flushes only once;version()is never flushed.The
update_optiontest stub now records its autoload argument.composer phpcs && composer test:unit: 947 tests pass. PHPCS reports no errors; the three warningsit 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 newversion_scopedsubsection: 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.phpadds the key with an annotated comment on both persistent stores.🤖 Generated with Claude Code