Skip to content

Add a PSR-16 transient cache - #37

Merged
ahegyes merged 12 commits into
trunkfrom
feat/transient-cache
Oct 20, 2026
Merged

ahegyes merged 12 commits into
trunkfrom
feat/transient-cache

Conversation

@ahegyes

@ahegyes ahegyes commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

Plugins had no cache to type against. Bare transients cannot tell a stored false from a miss, the options table folds keys that differ only in letter case or accents, a transient name past 172 characters is stored without its timeout, and an entry stored without an expiration is autoloaded on every request and never reclaimed.

  • Caching\TransientCache implements Psr\SimpleCache\CacheInterface over the current site's transients. Each entry is named by its prefix and the md5 of its key, so any key PSR-16 allows works at any length, and the prefix is checked once, at construction.
  • Each value is stored serialized, in an envelope with the generation it was written under: falsy values hit, foreign values miss, and reads behind an object cache return copies.
  • clear() starts a new generation from Shared\Caching\CacheGenerationInterface. A read misses unless its entry holds the current generation, so a key set again reuses its row. The default OptionCacheGeneration keeps a random token in an autoloaded option named after the prefix, so neither a clear nor an uninstall can revive older entries.
  • A null TTL stores for a day, so every entry expires and none is autoloaded; zero or less deletes. set() and delete() report what the store holds afterwards, on the options table and behind an object cache. getMultiple() reads the options table in one query.
  • The generation contract lives in shared, so a WooCommerce generation can ship in the woocommerce package without a core edge. Core requires psr/simple-cache ^3, an interface-only package scoped like psr/log.

AGENTS.md records the file map, how a cache's footprint is covered (convention 8) and limitation 13; core's and shared's descriptions name the cache and the generation contract.

Affected packages

  • wp-framework-bootstrap
  • wp-framework-shared
  • wp-framework-core
  • wp-framework-settings
  • wp-framework-woocommerce

Checklist

  • Tests added or updated:
    • Integration: tests/Integration/Core/Caching/TransientCacheTest.php and OptionCacheGenerationTest.php (137 runs, plus 1 in the multisite run) against real transients on the options table and behind an object cache: key validation, entry names, value round-trips, lifetimes, write and delete results, generations and clear(), batches, prefixes, shared and custom generations, object copies and per-site isolation.
    • Unit: PackageManifestsTest pins every edge's constraint, including core's psr/simple-cache ^3.
  • composer quality-check passes locally (Unit 204, Integration 1142), plus composer test:integration:multisite (31) and PHP 7.4 php -l over packages/bootstrap and the woocommerce uninstall files
  • Public API changes are documented in the affected package's README.md (the package READMEs stay pointer-form; AGENTS.md carries the file map)
  • Breaking changes are explicitly justified below (none: the classes are new)

The lock still carried core's path-package reference and description from before the capability grants changed core's manifest description, so composer outdated listed core as outdated. composer packages-update refreshes that entry and changes nothing else; npm outdated was empty and npm update left package-lock.json unchanged. The workflows already pin configs trunk 6302f1f.

@wordpress/env 12.0.0, released today, is still inside the host's seven-day npm cooldown, so the lock keeps 11.17.0.

Assisted-by: Claude Code:claude-opus-5-5
Core has no transient cache that plugins can type against: a port either hand-rolls get_transient() calls, which cannot tell a stored false from a miss, fold keys that differ in letter case on the options table, and silently drop the expiry of names past 172 characters, or wraps them in a concrete class that a second backend would retype.

TransientCacheTest covers construction, which rejects a prefix the transient names would not keep and touches nothing; round-trips of every value type from storage, falsy values included; the default for an absent key; an invalid PSR-16 key through every method, which throws before any read or write; keys of any length and script that differ only in case or accents; entries named by the hash of the key, each with its timeout row; and stored values without the envelope, which read as misses. OptionCacheGenerationTest covers the option name check. PackageManifestsTest pins each edge's constraint and expects core to require psr/simple-cache ^3, and IsolatesStorageTrait also removes the test prefix's transient rows.

Assisted-by: Claude Code:claude-opus-5-5
Plugins had no cache to type against: transients cannot tell a stored false from a miss, the options table folds keys that differ only in letter case or accents, and a transient name past 172 characters is stored without its timeout, so it never expires.

Caching\TransientCache implements Psr\SimpleCache\CacheInterface over transients of the current site. Each entry is named by the prefix and the md5 of its key, so case, accents and length never reach the column, and the prefix is checked once, at construction. Every value sits in an envelope with the generation it was stored under, so falsy values hit and a foreign value under the name reads as a miss. An invalid key throws InvalidCacheKeyException, which is also PSR-16's InvalidArgumentException, before anything is read or written. Entries are stored for a day.

The generation comes from Shared\Caching\CacheGenerationInterface, which lives in shared so that a WooCommerce generation can ship in the woocommerce package without a core edge. Its default, OptionCacheGeneration, keeps a random token in an autoloaded option, named after the prefix. Core requires psr/simple-cache ^3, an interface-only package scoped like psr/log.

Assisted-by: Claude Code:claude-opus-5-5
The cache stores every entry for a day whatever TTL it is given, so a short TTL outlives its data and a TTL of zero or less, which PSR-16 defines as already expired, stores the entry instead of deleting it. It also passes on WordPress's return values, which report false for a value the store already holds and for deleting an absent transient, where PSR-16 reports success.

TransientCacheTest adds rows for each lifetime (a day for null, seconds, intervals counted from now), for zero, negative and inverted lifetimes deleting the entry, for an expired entry reading as the default, for setting a held value, for a failed write keeping the prior value, for a value the options table refuses, for deleting a present entry and an absent key, for a failed delete, and for the same results behind a persistent object cache.

Assisted-by: Claude Code:claude-opus-5-5
The cache stored every entry for a day whatever its TTL, stored instead of deleting an entry whose TTL was zero or less, and passed on WordPress's return values, which report false for a value the store already holds and for deleting an absent transient.

A null TTL still stores for a day, an integer is seconds and an interval counts from now; zero or less deletes the entry, as PSR-16 defines it as already expired. When set_transient() returns false, set() re-reads the entry and succeeds if the store holds it. When delete_transient() returns false, delete() succeeds if no entry remains: behind an object cache by re-reading it, and on the options table by the query error, because delete_option() marks the option absent in its cache even when its DELETE fails.

Assisted-by: Claude Code:claude-opus-5-5
The cache stores the generation with each entry but never compares it, so clear() starts a new generation and every earlier entry still hits, an entry written before an uninstall deleted the generation comes back, and caches sharing a generation do not clear together. getMultiple() reads each key's rows in a query of its own.

TransientCacheTest adds rows for clear() on the options table and behind an object cache, where earlier entries miss and a key set again reuses its row; for each clear() storing a new generation; for reads without a generation, which miss and write nothing; for an entry from before the generation was deleted; for the multiple methods from arrays, generators and integer keys; for getMultiple() reading the options table in one query; for a failed write or delete among several; for two prefixes; for a shared generation; for a custom generation started only by a first write or a clear(), and one that cannot be started; and, in the multisite group, for entries and generations per site. OptionCacheGenerationTest adds rows for stored values that are no generation, for each advance() storing a new autoloaded token, and for a token that cannot be stored.

Assisted-by: Claude Code:claude-opus-5-5
clear() started a new generation that no read compared, so every earlier entry still hit, an entry from before an uninstall deleted the generation came back, and caches sharing a generation did not clear together. getMultiple() read each key's rows in a query of its own.

A read now misses unless the entry holds the current generation, and with no generation stored it misses without touching the entry, so the next set() overwrites the same row. getMultiple() primes the options cache for all its entries in one query when transients live on the options table.

Assisted-by: Claude Code:claude-opus-5-5
AGENTS.md, the manifests and the READMEs did not mention the transient cache, its generation contract or core's new psr/simple-cache edge.

AGENTS.md's file map lists Caching/ in shared and core and the core edge; convention 8 says how a cache's footprint is covered, since its entries are transients outside the plugin's option prefix that all expire; limitation 13 records what the cache does not guarantee. Core's and shared's manifest descriptions, their READMEs and the root README's package table name the cache and the generation contract, and the lock carries the new descriptions.

Assisted-by: Claude Code:claude-opus-5-5
The class docblock said the options table keeps only 172 characters of a transient name. It keeps the value under a longer name but stores no timeout for it, so the entry never expires, which is the reason the cache hashes its keys.

Assisted-by: Claude Code:claude-opus-5-5
Six one-line mutations of TransientCache passed the suite. Batch writes and deletes whose result kept only the last key survived because the failing key came last. Dropping the count or value check of an envelope, or the early miss without a generation, survived because no foreign value carried the current generation and the planted entry's generation was an empty string. A delete behind an object cache that never re-read the entry survived because nothing made that delete fail.

The batch row now fails the first key and checks the second. The foreign-value rows build each value from the current generation and add a value without the value key and one with only the generation. The no-generation row plants an entry whose generation is null. A new row stands in an object cache whose delete fails and expects delete() to return false with the entry still readable.

Assisted-by: Claude Code:claude-opus-5-5
Behind a persistent object cache, the envelope array keeps references to the objects in a cached value. WordPress's object cache, like the drop-ins, clones only an object stored or read at the top level, so changing an object after set(), or one that get() returned, changes what later reads in the request return, though PSR-16 requires the exact value stored.

TransientCacheTest adds a row on the options table and behind an object cache that changes both objects and expects the stored value back.

Assisted-by: Claude Code:claude-opus-5-5
Behind a persistent object cache, the envelope array kept references to the objects in a cached value, and object caches clone only a top-level object, so changing an object after set(), or one get() returned, changed what later reads in the request returned.

set() now stores the value through maybe_serialize(), and a read unserializes a stored string, so every read returns a fresh copy on either backend. A scalar stays as it is, and a string that looks serialized round-trips because maybe_serialize() serializes it again.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Feat/transient cache Add a PSR-16 transient cache Oct 20, 2026
@ahegyes
ahegyes merged commit 8f0bf23 into trunk Oct 20, 2026
16 checks passed
@ahegyes
ahegyes deleted the feat/transient-cache branch October 20, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant