diff --git a/docs/plans/2026-07-06-stack-cache-tags-and-tag-architecture-refactor.md b/docs/plans/2026-07-06-stack-cache-tags-and-tag-architecture-refactor.md new file mode 100644 index 000000000..a8630588a --- /dev/null +++ b/docs/plans/2026-07-06-stack-cache-tags-and-tag-architecture-refactor.md @@ -0,0 +1,2325 @@ +# Stack Cache Tags and Tag Architecture Refactor + +## Goal + +Add cache tag support to the `stack` driver (any-mode only), rebuild the tagged-cache and tag-set class hierarchies so mode-specific behavior lives in mode-specific types, fix four live bugs found during design review, make the JWT blacklist work with both tag modes, and give the stack driver lock support by delegation. + +Churn and backwards compatibility are not constraints. The final codebase must read as if it was designed this way from the start: no stale code, no stale comments, no inherit-then-neutralize hierarchies, no capability probes that lie. + +This plan is the output of a five-round design review between Claude and Codex. Every factual claim below was verified against source. An implementer starting cold needs no context beyond this document and the repository. + +## Background + +The `stack` driver is Hypervel's multi-tier cache (microcaching): a fast node-local L1 (e.g. `swoole`, 3–5s TTL) over a shared L2 (e.g. `redis`). Reads hit L1 first and backfill on L2 hits. Writes go through all layers. `foundation/config/auth.php` already documents swoole+redis stacks as the recommended high-scale auth cache topology. + +Hypervel's Redis cache supports two tag modes (`TagMode` enum, configured per store via `tag_mode`): + +- **`all`** (default, Laravel classic): the tag set namespaces item keys. Reads/writes/deletes go through `tags([...])`. Tag ZSETs track entries with expiry-timestamp scores; flush deletes tracked entries; `FlushStale`/prune remove entries whose scores have passed. +- **`any`** (Redis 8.0+/Valkey 9.0+): items live under plain keys; tags are invalidation indexes only. Writes go through `tags([...])`; reads/deletes use plain keys; flushing any one tag deletes the actual keys. Tagged read-style calls throw `BadMethodCallException`. + +Any mode is what makes stack tags possible: reads never consult tags, so the stack's plain-key read/backfill path is untouched, and tag flush deletes real L2 keys while L1 entries self-heal within their short TTL. All mode is incompatible: reads are tag-scoped and key-namespaced, which does not fit the stack's plain-key model. The auth package already encodes this rule (`EloquentUserProvider::ensureTaggableAnyModeStore()` requires `TagMode::Any`). + +Bounded staleness after a tag flush (L1 serves stale for up to its TTL) is the standard microcache tradeoff (nginx `proxy_cache_use_stale`, Varnish grace, near-caches). `foundation/config/auth.php` already documents it for auth stacks. + +## Verified Current State + +All file references are relative to the components repo root. Line numbers are as of the commit this plan was written against — verify with grep before editing, do not trust them blindly after other changes land. + +### Key formats (`src/cache/src/Redis/Support/TagKeyBuilder.php`, `StoreContext.php`) + +- Cache key: `{prefix}{key}` +- Reverse index (any mode; tracks which tags a key belongs to): `{prefix}{key}:_any:tags` (a SET) +- Tag hash (any mode; tracks which keys a tag contains): `{prefix}_any:tag:{tag}:entries` (a HASH, fields expire via HSETEX) +- Tag registry (any mode): `{prefix}_any:tag:registry` (a ZSET, scores = expiry, updated with `ZADD GT`) +- All-mode tag entry set: `{prefix}_all:tag:{tag}:entries` (a ZSET, member = namespaced item key, score = expiry timestamp, `-1` for forever) +- None of these use Redis cluster hash tags (`{...}`), so they hash to different slots in cluster mode. + +### Op invariants + +- `AnyTag\Put` (`src/cache/src/Redis/Operations/AnyTag/Put.php`) writes value SETEX + reverse index (SET with TTL) + tag hash fields (HSETEX with TTL) + registry (`ZADD GT`) together — Lua single round trip on standard Redis, sequential commands on cluster (with a same-slot `multi()` batch for the reverse index and a single multi-member `ZADD` for the registry). It also removes the key from tags it no longer belongs to (reverse-index diff), so tagged writes maintain tag membership. +- `AllTag\Put` pipelines `ZADD {tagZset} {expiryTimestamp} {namespacedKey}` per tag + `SETEX`. +- `AllTag\FlushStale` uses `ZREMRANGEBYSCORE 0..now`; scores are the authoritative cleanup boundary; score `-1` (forever) is exempt. +- Plain `Operations/Put.php` is a bare `SETEX`; plain `Operations/Forget.php` is a bare `DEL`; `RedisStore::touch()` (`RedisStore.php:217`) is an inline raw `EXPIRE` with no operation class. All three are tag-metadata-blind. +- Operation classes are lazily instantiated singletons via private nullable properties + `get*Operation()` accessors on `RedisStore`, and grouped factories `AnyTagOperations` / `AllTagOperations` (constructor takes `StoreContext` + `Serialization`; ops that don't serialize take only `StoreContext`). +- Cluster branches never use pipeline (phpredis `RedisCluster` does not support it — documented in `AllTag\FlushStale`); the only batching is `multi()` for same-slot keys and multi-member single commands (`ZADD` with several members). + +### Class hierarchy (current) + +- `TagSet` (`src/cache/src/TagSet.php`): store + names + the classic random-identifier machinery — `resetTag()` stores a `uniqid()` under `tag:{name}:key` via `store->forever()`, `tagId()` reads it back (`store->get() ?: resetTag()`), `getNamespace()` implodes tag ids, `flush()`/`flushTag()` forget the tag keys. Only the generic path uses this machinery. +- `Redis\AllTagSet extends TagSet`: overrides `tagId()`/`tagKey()` to deterministic `_all:tag:{name}:entries` identifiers, `resetTag()` forgets the entry ZSET. Inherits `getNamespace()`/`tagIds()` walkers. +- `Redis\AnyTagSet extends TagSet`: neutralizes everything — `tagId()` returns the name, `getNamespace()` returns `''`, `reset()` delegates to `flush()` (which deletes actual keys via `anyTagOps()->flush()`). Adds `tagHashKey()`, `entries()`. +- `TaggedCache extends Repository` (`src/cache/src/TaggedCache.php`): implicitly all-mode — `itemKey()` → `taggedItemKey()` (xxh128 of `tags->getNamespace()` + `:` + key), `flush()` = `tags->reset()` + `CacheFlushing`/`CacheFlushed` events, `putMany()` loop, `increment()`/`decrement()` via `store` + `itemKey()`, tag-aware `event()` wrapper (sets tags on events with `setTags`), `getTags()`. +- `Redis\AllTaggedCache extends TaggedCache`: op-class overrides for add/put/putMany/increment/decrement/forever/flush/flushStale/remember/rememberForever; caches the tagged item key prefix (`taggedItemKeyPrefix()`, safe because all-mode identifiers are deterministic). +- `Redis\AnyTaggedCache extends TaggedCache`: throws `BadMethodCallException` on `get`/`getRaw`/`many`/`manyRaw`/`has`/`pull`/`forget`; passthrough `itemKey()`; op-class write overrides; `flush()` via `tags->flush()`; `items()`; `remember`/`rememberForever` via single-connection ops. +- `TaggableStore` (`src/cache/src/TaggableStore.php`): abstract, `tags()` returns `new TaggedCache($this, new TagSet(...))`, `getTagMode()` returns `TagMode::All`. Subclasses: `RedisStore` (mode-aware `tags()`), `FailoverStore`, `NullStore`, `AbstractArrayStore` (→ `ArrayStore`, `WorkerArrayStore`). +- `StackStore` (`src/cache/src/StackStore.php`): `implements Store` only. Wraps values in records `['value' => ..., 'ttl' => ...]` normalized to `['value' => ..., 'expiration' => ts]` by `putToStore()`; `getOrRestoreRecord()` reads down the layer chain and backfills upward; `callStores()`/`callStoresStacked()` implement write-through with rollback. No tags, no locks, no `RawReadable` (not needed — records carry sentinels verbatim and `Repository::getRaw()` falls back to `get()` for non-RawReadable stores). +- `StackStoreProxy` (`src/cache/src/StackStoreProxy.php`): per-layer wrapper created by `CacheManager::createStackDriver()`; clamps TTLs in `put()`/`forever()`/`touch()` when a layer `ttl` override is configured; implements `Store` only; wrapped store and ttl are `protected` with no accessors. + +### Repository facts (`src/cache/src/Repository.php`) + +- `supportsTags()` (line 827) is `method_exists($this->store, 'tags')`. +- `tags()` (line 803) carries `/* @phpstan-ignore-next-line */` because `tags()` is not on the `Store` contract. +- `clear()` (line 748) calls `$this->store->flush()` directly. **No tagged cache overrides `clear()`** — `Cache::tags([...])->clear()` flushes the entire underlying store in both modes. Live bug. +- `touch()` (line 669) calls `$this->get($key)` first; null TTL routes to `forever($key, $value)`, never `store->touch()`. Consequence: any-mode tagged `touch()` already throws, but via `get()`'s misleading message; all-mode tagged `touch(int)` reaches `store->touch(namespacedKey)` = raw `EXPIRE` that desyncs the ZSET score. Live bug (all mode) + accidental contract (any mode). +- `put()` with `seconds <= 0` calls `$this->forget($key)` (line ~297). `AnyTaggedCache::put()` with `seconds <= 0` returns `false` instead ("Can't forget via tags") — a divergence from repository semantics, fixable once plain forget cleans metadata. +- `remember()`/`rememberForever()` read via `getRaw()`, wrap in `withPinnedConnection` when the store supports it, and handle `NullSentinel` (nullable variants wrap the callback with `?? NullSentinel::VALUE`). All read-style methods route through the core set (`missing()`→`has()`, typed getters→`get()`, `sear`→`rememberForever`, `flexible`→`manyRaw`, `getMultiple`→`many`, `delete`/`deleteMultiple`→`forget`, `offsetGet/Exists/Unset/Set`→`get/has/forget/put`) — so any-mode throwing overrides propagate to the whole surface automatically. +- `supportsFlushingLocks()` (line ~835) is `$this->store instanceof CanFlushLocks`; `flushLocks()` (line 770) gates on it and carries a phpstan-ignore. +- `itemKey()` (line 987, protected) returns the key unchanged — the correct passthrough for any-mode tagged caches. + +### Consumers (full trace results) + +- `JWT` (`src/jwt/src/JWTServiceProvider.php:135`): gates blacklist storage on `$repository->supportsTags()` only. `src/jwt/src/Storage/TaggedCache.php` does tag-scoped `get()`/`forget()` — all-mode-only operations. **Live bug:** an any-mode Redis default store passes the gate, then every blacklist read throws at runtime. +- `Auth` (`src/auth/src/EloquentUserProvider.php`): `SUPPORTED_AUTH_CACHE_STORES` already contains `StackStore::class`; `ensureTaggableAnyModeStore()` (line 439) checks `instanceof TaggableStore` then `getTagMode() !== TagMode::Any`. `buildTaggedCache()` (line ~497) has a phpstan-ignore because `tags()` is not on the `Repository` contract — that contract follows Laravel and stays unchanged; the ignore is intentional and remains. +- `cache:doctor` / `cache:redis:benchmark`: guard `instanceof RedisStore` before every `getTagMode()` call, so a throwing `StackStore::getTagMode()` never reaches them. +- `ClearCommand.php:53` phpstan-ignore (flush via `__call`) — unaffected. +- `Cache` facade docblock: `@method static \Hypervel\Cache\TaggedCache tags(mixed $names)` — stays valid with `TaggedCache` abstract. +- `MemoizedStore` implements plain `Store` — correctly reports no tag support under the new probe. +- Nothing outside `TaggableStore::tags()` constructs the cache package's `TaggedCache` or `TagSet` (the `new TaggedCache` in JWT is JWT's own `Storage\TaggedCache`). +- `CanFlushLocks` implementers: `RedisStore`, `AbstractArrayStore`, `DatabaseStore`, `FileStore`. +- `LockProvider` contract: `lock(string $name, int $seconds = 0, ?string $owner = null): Lock`, `restoreLock(string $name, string $owner): Lock`. +- `FailoverStore` implements `LockProvider` — precedent for a composite store completing the lock contract. + +### Bugs this plan fixes + +1. **Tagged `clear()`** flushes the whole store (`Repository::clear()`, no tagged override). +2. **All-mode tagged `touch()`** raw-EXPIREs the namespaced key without updating tag ZSET scores; `FlushStale`/prune then removes the entry while the key lives, so a later tag flush misses it. +3. **Any-mode plain `touch()`** extends the key but not reverse index / tag hash fields / registry; tag flush misses the still-live key. +4. **Any-mode plain `forget()`** leaves tag membership behind; a later tag flush deletes a new, unrelated value written at the reused key; forever-tagged metadata orphans live indefinitely. +5. **JWT blacklist gate is mode-blind** (see consumers above). +6. **`AnyTaggedCache::put()` with `seconds <= 0`** returns `false` instead of deleting the key (diverges from `Repository::put()` semantics). + +## Design Decisions + +Each decision below is final. Rejected alternatives are recorded so the review doesn't re-litigate them. + +### D1. Stack tags are any-mode only, write/index/flush only + +Tagged reads/lookups/deletes throw exactly as any-mode Redis does today. Reads use plain keys through the stack. Rejected: all-mode stack tags — all-mode reads are tag-scoped and namespaced, incompatible with plain-key read/backfill; the generic `TagSet` version keys would themselves get cached into L1 and reconstruct stale namespaces. + +### D2. Composition rule (structural validation, computed once) + +A stack supports tags iff: at least one layer's underlying store is a `TaggableStore`, and **every layer from the first taggable layer to the bottom** is a `TaggableStore` whose `supportsTags()` is true and whose `getTagMode()` is `TagMode::Any`. Non-taggable layers are allowed only above the first taggable layer. + +Why: a non-taggable (or all-mode) layer at or below the taggable region breaks invalidation. `[swoole, redis-any, database]` — tag flush clears Redis; the next read misses swoole and Redis, hits the database layer, and backfills the flushed value everywhere. That is permanent resurrection, not bounded staleness, so it is rejected structurally. Note this also correctly rejects all-mode-taggable upper layers (e.g. `[array, redis-any]`: `ArrayStore` is taggable all-mode, becomes "first taggable layer", fails the any-mode requirement). + +Rejected: enforcing finite TTL overrides on the non-taggable microcache layers (`[swoole no-ttl, redis-any]`). Missing TTL bounds staleness at the item TTL — a tuning footgun, not corruption. Documented prominently instead. + +Rejected: skipping tagged writes to lower non-taggable layers instead of validating. Asymmetric layer contents and murky rollback semantics; a config error at `tags()` time is strictly better. + +### D3. Tagged-cache hierarchy: abstract base + mode siblings + +`TaggedCache` becomes the abstract mode-neutral base; `NamespacedTaggedCache` (all-mode key namespacing, generic path) and `AnyModeTaggedCache` (throw-on-read contract) are siblings under it. `AllTaggedCache extends NamespacedTaggedCache`; `AnyTaggedCache` and the new `StackTaggedCache` extend `AnyModeTaggedCache`. + +Why: today `AnyTaggedCache` inherits the all-mode implementation and disables half of it with throwing overrides. Adding `StackTaggedCache` would duplicate the throw-on-read dance — the any-mode contract existing twice as convention. After the split, the contract is a type. `Repository::tags(): TaggedCache` stays meaningful; `assertInstanceOf(TaggedCache::class)` tests stay valid; nothing else constructs the base (verified). + +### D4. TagSet hierarchy: slim base + namespaced branch + +`TagSet` slims to store + names + `getNames()` + abstract `reset()`/`flush()`. `NamespacedTagSet extends TagSet` adds the namespace surface (`getNamespace()`, `tagIds()`, abstract `tagId()`/`tagKey()`). `VersionedTagSet extends NamespacedTagSet` holds the current random-identifier machinery and serves the generic path. `AllTagSet extends NamespacedTagSet` (deterministic ids). `AnyTagSet` and the new `StackTagSet` extend the slim `TagSet` — any-mode code structurally cannot reach namespace machinery. `AnyTagSet` drops its neutralization overrides (`tagId()`, `tagIds()`, `tagKey()`, `getNamespace()`, `resetTag()`) — they existed only to fight the old base. + +### D5. Capability probes are first-class and never lie + +- `TaggableStore::supportsTags(): bool` (base returns `true`); `StackStore` overrides with composition validity. +- `Repository::supportsTags()` becomes `$store instanceof TaggableStore && $store->supportsTags()` — removing the `method_exists` probe and the phpstan-ignore at `Repository::tags()` (inline instanceof narrows the store). +- `StackStore::getTagMode()` returns `TagMode::Any` when the composition is valid and **throws** `NotSupportedException` when it is not. `getTagMode()` is a statement about a valid tag-capable store, never a probe — probes use `supportsTags()`. Verified safe: all `getTagMode()` call sites are either behind `instanceof RedisStore` guards (doctor/benchmark) or behind `supportsTags()` in the new gate orderings. +- Same pattern for locks: `CanFlushLocks` gains `supportsFlushingLocks(): bool`; `Repository::supportsFlushingLocks()` becomes `instanceof && probe`. Existing implementers (`RedisStore`, `AbstractArrayStore`, `DatabaseStore`, `FileStore`) return `true`. + +Why mode is a probe at store level but a type at cache level: `RedisStore` is dual-mode by config (`tag_mode`), so store-level mode cannot be a static type — per-mode marker interfaces were rejected because a config-dual store would implement both and gates would still need runtime probes. Tagged-cache objects are constructed after mode resolution, so there mode *is* static and is encoded as a type (D3). + +### D6. Touch becomes metadata-aware where drift is real; plain put/forever stays blind by contract + +- All-mode tagged `touch(int)` → new `AllTag\Touch` op (EXPIRE + plain `ZADD` new score per tag ZSET — not `GT`, because touch may shorten TTL and the score must track the real expiry). `touch(null)` already routes through tagged `forever()` (metadata synced, score `-1`); the override only intercepts the integer branch. +- Any-mode tagged `touch()` → explicit throw in `AnyModeTaggedCache` with an accurate message (the current throw is an accident of `Repository::touch()` calling `get()` first). +- Any-mode plain `touch()` → new mode-aware path: `RedisStore::touch()` routes to `AnyTag\Touch` in any mode (EXPIRE key + if reverse index exists: EXPIRE reverse index, `HEXPIRE` each tag hash field, `ZADD GT` registry) and to a new plain `Operations/Touch.php` op in all mode (bare EXPIRE — plain keys are never in all-mode ZSETs, verified; the op class exists for consistency: every other store method delegates to an op class). +- Any-mode plain `forget()` → new `AnyTag\Forget` op (read reverse index; HDEL the key's field from each tag hash; DEL reverse index; DEL key). **No registry ops** — removing one key doesn't empty a tag; registry hygiene belongs to the prune command. All-mode plain forget stays a bare DEL (plain keys carry no all-mode metadata). +- Plain `put()`/`forever()` on tagged keys stays metadata-blind. Making the hottest cache operation do a reverse-index check to cover value-replacement-on-tagged-keys is the wrong trade. Documented contract: tag membership persists until the key is deleted or re-tagged; tagged writes sync metadata; plain deletes remove membership; TTL changes to tagged items go through `tags()`. A `docs/todo.md` entry records the possible future opt-in. + +Cluster branches follow the established patterns: no pipeline; `multi()` only for same-slot batches; multi-member single commands where possible; sequential otherwise. Standard-mode branches are single Lua round trips (dynamic tag-hash key construction inside Lua is established practice — `StoreContext::tagHashSuffix()` exists for exactly this). + +### D7. Stack tagged-write mechanics live in StackStore + +`StackTaggedCache` stays thin (like `AnyTaggedCache` delegating to `anyTagOps()`): record building, layer iteration, TTL clamping, and rollback stay in `StackStore` (`putRecordTagged()`, `incrementTagged()`), which is the single owner of record semantics. Tagged writes route through each taggable layer's own `tags($names)` path (so tag indexes are recorded) with the layer's proxy TTL clamp replicated exactly; non-taggable upper layers get plain proxied writes; rollback mirrors `callStores()` (plain `forget`, which now cleans any-mode metadata). + +### D8. Stack locks: bottom-layer delegation + +`StackStore` implements `LockProvider` + `CanFlushLocks`. `lock()`/`restoreLock()` delegate to the bottom layer's underlying store when it is a `LockProvider`, else throw `NotSupportedException` naming the layer. Locks never touch upper tiers (a microcached lock is broken mutual exclusion; delegation makes the lock exactly as correct as using the bottom store directly). `supportsFlushingLocks()`/`flushLocks()`/`hasSeparateLockStore()` delegate the same way with the honest probe. Motivation: stack-as-default-store currently breaks `Cache::lock()`, `withoutOverlapping()`, `funnel()`, and lock-backed `flexible()`. Precedent: `FailoverStore` (its attempt-all semantics fit failover; bottom-delegation fits stacking). + +### D9. JWT blacklist: dual-mode storage + +`JWT\Storage\TaggedCache` becomes mode-aware via `getTagMode()->supportsDirectGet()`: all mode keeps current tag-scoped behavior; any mode prefixes logical keys with a private constant (`jwt_blacklist:` — replaces the collision isolation the all-mode namespace provided), writes through `tags()`, reads/deletes plain (plain forget now cleans metadata), flushes via tag. The provider gate becomes just `supportsTags()` (now composition-aware). No `instanceof RedisStore` anywhere — the probe surface is `TaggableStore`. Same pattern as auth user caching. + +### D10. Non-goals + +- No metadata-aware plain `put()`/`forever()` (D6; todo.md entry). +- No TTL-override enforcement on microcache layers (D2; docs). +- No registry ops in any-mode forget (prune owns registry hygiene). +- No `Repository::tagMode()` convenience method — `getStore()->getTagMode()` behind the `TaggableStore` instanceof is the established consumer pattern (auth). +- No changes to the `Repository`/`CacheContract` contracts — they follow Laravel; the auth phpstan-ignore at the `tags()` call stays, with its existing justification comment. +- No `RawReadable` on `StackStore` — verified unnecessary (records carry sentinels; `getRaw()` falls back to `get()`). +- No changes to `flexible()` through tagged caches — it throws via `manyRaw()` in any mode, which is existing parity. + +## Target Hierarchies + +```text +TagSet (abstract: store, names, getNames, abstract reset/flush) +├── NamespacedTagSet (abstract: getNamespace, tagIds; abstract tagId, tagKey) +│ ├── VersionedTagSet (generic path: random ids stored in cache, rotated on reset) +│ └── Redis\AllTagSet (deterministic "_all:tag:{name}:entries" ids) +├── Redis\AnyTagSet (tagHashKey, entries; flush deletes real keys) +└── StackTagSet (delegates flush to taggable layers) + +TaggedCache (abstract: tags property, getTags, event wrapper, putMany, +│ increment/decrement via itemKey, flush = tags->reset + events, +│ clear = $this->flush) +├── NamespacedTaggedCache (itemKey → taggedItemKey namespace hashing; generic path) +│ └── Redis\AllTaggedCache (op-class overrides, cached key prefix, touch override) +└── AnyModeTaggedCache (abstract: throwing get/getRaw/many/manyRaw/has/pull/forget/touch) + ├── Redis\AnyTaggedCache (op-class write overrides, items(), remember ops) + └── StackTaggedCache (delegates record writes to StackStore) + +TaggableStore (abstract: tags → NamespacedTaggedCache+VersionedTagSet, +│ getTagMode → All, supportsTags → true) +├── RedisStore (mode-aware tags(), mode-aware touch()/forget() routing) +├── FailoverStore, NullStore, AbstractArrayStore (unchanged behavior) +└── StackStore (composition-validated tags(), LockProvider, CanFlushLocks) +``` + +## Implementation Phases + +Work through phases in order. Each phase ends with its test files green (`./vendor/bin/phpunit --no-progress `), per the repo rule: run tests per file, immediately. Run all commands from the components repo root. + +--- + +### Phase 1 — TagSet hierarchy + +**1.1 Slim the base `TagSet`** (`src/cache/src/TagSet.php`): + +```php +store = $store; + $this->names = $names; + } + + /** + * Reset all tags in the set. + */ + abstract public function reset(): void; + + /** + * Flush all the tags in the set. + */ + abstract public function flush(): void; + + /** + * Get all of the tag names in the set. + */ + public function getNames(): array + { + return $this->names; + } +} +``` + +**1.2 New `NamespacedTagSet`** (`src/cache/src/NamespacedTagSet.php`): + +```php +tagIds()); + } + + /** + * Get an array of tag identifiers for all of the tags in the set. + * + * @return array + */ + public function tagIds(): array + { + return array_map([$this, 'tagId'], $this->names); + } + + /** + * Get the unique tag identifier for a given tag. + */ + abstract public function tagId(string $name): string; + + /** + * Get the tag identifier key for a given tag. + */ + abstract public function tagKey(string $name): string; +} +``` + +**1.3 New `VersionedTagSet`** (`src/cache/src/VersionedTagSet.php`) — receives the machinery removed from the old base, unchanged in behavior: + +```php +names, [$this, 'resetTag']); + } + + /** + * Reset the tag and return the new tag identifier. + */ + public function resetTag(string $name): string + { + $this->store->forever($this->tagKey($name), $id = str_replace('.', '', uniqid('', true))); + + return $id; + } + + /** + * Flush all the tags in the set. + */ + public function flush(): void + { + array_walk($this->names, [$this, 'flushTag']); + } + + /** + * Flush the tag from the cache. + */ + public function flushTag(string $name): string + { + $this->store->forget($key = $this->tagKey($name)); + + return $key; + } + + /** + * Get the unique tag identifier for a given tag. + */ + public function tagId(string $name): string + { + return $this->store->get($this->tagKey($name)) ?: $this->resetTag($name); + } + + /** + * Get the tag identifier key for a given tag. + */ + public function tagKey(string $name): string + { + return 'tag:' . $name . ':key'; + } +} +``` + +**1.4 `Redis\AllTagSet`**: change `extends TagSet` → `extends NamespacedTagSet` (import `Hypervel\Cache\NamespacedTagSet`). It must now define `reset()` and `flush()` concretely (previously inherited walkers): + +```php + /** + * Reset all tags in the set. + */ + public function reset(): void + { + array_walk($this->names, [$this, 'resetTag']); + } + + /** + * Flush all the tags in the set. + */ + public function flush(): void + { + array_walk($this->names, [$this, 'flushTag']); + } +``` + +Place them in the source order matching `VersionedTagSet` (reset, resetTag, flush, flushTag, then the id methods). Its existing `tagId()`/`tagKey()`/`resetTag()`/`flushTag()`/`addEntry()`/`entries()` are unchanged. + +**1.5 `Redis\AnyTagSet`**: stays `extends TagSet`. Delete the neutralization overrides — `tagId()`, `tagIds()`, `tagKey()`, `getNamespace()`, `resetTag()` — and their docblocks. Keep: constructor, `tagHashKey()`, `entries()`, `reset()` (add `: void` — it already delegates to `flush()`), `flush()`, `flushTag()`. Update the class docblock: remove the "Key differences from AllTagSet" comparisons that referenced the deleted methods; state what it is (any-mode tag set: names are the identifiers, flush deletes the actual cache keys, hashes track membership with HSETEX field expiry). + +**1.6 `TaggableStore::tags()`**: construct the generic pair explicitly: + +```php + /** + * Begin executing a new tags operation. + */ + public function tags(mixed $names): TaggedCache + { + return new NamespacedTaggedCache($this, new VersionedTagSet($this, is_array($names) ? $names : func_get_args())); + } +``` + +(Applied in Phase 2 together with the cache split — the two phases must land together for the suite to be green; treat Phases 1+2 as one commit-sized unit, testing after both.) + +**Consumers to update in this phase**: grep `tests/` for `new TagSet(`, `TagSet::class`, `getNamespace`, `tagId`, `resetTag` usages against `AnyTagSet` and fix accordingly (`tests/Cache/Redis/AnyTagSetTest.php` will lose the tests for deleted methods — deleting a test here is correct only for methods that no longer exist; port any behavioral assertions to the surviving methods). `tests/Cache/CacheTaggedCacheTest.php` references the generic set — update instantiations to `VersionedTagSet`. + +--- + +### Phase 2 — TaggedCache hierarchy + `clear()` fix + +**2.1 `TaggedCache`** (`src/cache/src/TaggedCache.php`) becomes abstract and mode-neutral. Full target content of the class body (imports unchanged plus none removed): + +```php +abstract class TaggedCache extends Repository +{ + /** + * The tag set instance. + */ + protected TagSet $tags; + + /** + * Create a new tagged cache instance. + */ + public function __construct(Store $store, TagSet $tags) + { + parent::__construct($store); + + $this->tags = $tags; + } + + /** + * Store multiple items in the cache for a given number of seconds. + */ + public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ttl = null): bool + { + // unchanged body + } + + /** + * Increment the value of an item in the cache. + */ + public function increment(UnitEnum|string $key, int $value = 1): bool|int + { + // unchanged body (store->increment via itemKey) + } + + /** + * Decrement the value of an item in the cache. + */ + public function decrement(UnitEnum|string $key, int $value = 1): bool|int + { + // unchanged body + } + + /** + * Remove all items from the cache. + */ + public function flush(): bool + { + // unchanged body (tags->reset() + CacheFlushing/CacheFlushed events) + } + + /** + * Remove all items from the cache. + * + * A tagged cache's PSR clear() scope is the tag set, not the whole store. + */ + public function clear(): bool + { + return $this->flush(); + } + + /** + * Get the tag set instance. + */ + public function getTags(): TagSet + { + return $this->tags; + } + + /** + * Fire an event for this cache instance. + */ + protected function event(string $eventClass, Closure $event): void + { + // unchanged body (setTags wrapper) + } +} +``` + +Removed from `TaggedCache`: `taggedItemKey()` and the `itemKey()` override (they move to `NamespacedTaggedCache`). `Repository::itemKey()` passthrough becomes the inherited default — correct for any-mode. + +**2.2 New `NamespacedTaggedCache`** (`src/cache/src/NamespacedTaggedCache.php`): + +```php +tags->getNamespace()) . ':' . $key; + } + + /** + * Format the key for a cache item. + */ + protected function itemKey(string $key): string + { + return $this->taggedItemKey($key); + } + + /** + * Get the tag set instance (covariant return type). + */ + public function getTags(): NamespacedTagSet + { + return $this->tags; + } +} +``` + +(Import `Hypervel\Contracts\Cache\Store`.) + +**2.3 New `AnyModeTaggedCache`** (`src/cache/src/AnyModeTaggedCache.php`): + +```php +store->forget($key); + } +``` + +This matches `Repository::put()` semantics (expired TTL deletes the key). It is correct in any mode because the item key is the plain key, and after Phase 5 the store-level forget also cleans tag metadata. + +**Fix the same divergence in both Redis `putMany()` overrides.** `Repository::putMany()` with `$seconds <= 0` routes to `deleteMultiple()` (verified — `Repository.php:340`); both Redis tagged overrides return `false` instead: + +- `AnyTaggedCache::putMany()`: tagged `deleteMultiple()` would throw (it routes through the throwing `forget()`), so delete plain: + +```php + if ($seconds <= 0) { + $result = true; + + foreach (array_keys($values) as $key) { + if (! $this->store->forget((string) $key)) { + $result = false; + } + } + + return $result; + } +``` + +- `AllTaggedCache::putMany()`: tagged `forget()` is supported and namespaced in all mode, so mirror the repository directly: + +```php + if ($seconds <= 0) { + return $this->deleteMultiple(array_map(static fn ($key) => (string) $key, array_keys($values))); + } +``` + +`StackTaggedCache` needs no change: it inherits the base `TaggedCache::putMany()` loop, and each `put()` handles `<= 0` by plain-forgetting (6.4). The generic `NamespacedTaggedCache` path is likewise already correct through the base loop. Tests: zero/negative TTL `putMany()` for Redis any-mode (plain deletes, metadata cleaned), Redis all-mode (namespaced deletes), and the stack (per-key plain forget through the loop). + +**2.7 `TaggableStore::tags()`** — apply the Phase 1.6 change now (returns `NamespacedTaggedCache`). Add imports. + +**Tests for Phases 1+2** (write/update, then run each file): +- `tests/Cache/CacheTaggedCacheTest.php` — update generic instantiations (`new NamespacedTaggedCache(..., new VersionedTagSet(...))`); all existing behavioral assertions must pass unchanged (the generic path is behavior-identical). Add: `clear()` on a generic tagged cache flushes only the tag namespace (put a tagged and an untagged value on an `ArrayStore` repository, `tags(...)->clear()`, assert untagged survives and `assertInstanceOf(TaggedCache::class, ...)` still holds). +- `tests/Cache/Redis/AnyTaggedCacheTest.php` — assertions for throwing methods now exercise the inherited base; add `touch()` throw + message; add PSR/ArrayAccess alias coverage: `getMultiple()`, `delete()`, `deleteMultiple()`, `offsetGet`, `offsetExists`, `offsetUnset` throw; `setMultiple()` and `offsetSet()` succeed (mock the write path); `clear()` calls tag flush, not store flush. Add zero-TTL `put()` deletes via store `forget`. +- `tests/Cache/Redis/AllTaggedCacheTest.php` — `clear()` uses tagged flush (`allTagOps` flush), not `store->flush()`. +- `tests/Cache/Redis/AnyTagSetTest.php` / `AllTagSetTest.php` — reflect the hierarchy (deleted methods gone; `AllTagSet` reset/flush walkers still behave as before). + +--- + +### Phase 3 — First-class `supportsTags()` + +**3.1 `TaggableStore`**: add below `tags()`: + +```php + /** + * Determine if this store currently supports tags. + * + * Stores whose tag support depends on configuration or composition + * (e.g. the stack store) override this; for everything else extending + * TaggableStore, tag support is unconditional. + */ + public function supportsTags(): bool + { + return true; + } +``` + +**3.2 `Repository::supportsTags()`**: + +```php + /** + * Determine if the current store supports tags. + */ + public function supportsTags(): bool + { + return $this->store instanceof TaggableStore && $this->store->supportsTags(); + } +``` + +**3.3 `Repository::tags()`** — restructure so phpstan narrows the store and the ignore comes out: + +```php + /** + * Begin executing a new tags operation if the store supports it. + * + * @throws BadMethodCallException + */ + public function tags(mixed $names): TaggedCache + { + $store = $this->store; + + if (! $store instanceof TaggableStore || ! $store->supportsTags()) { + throw new BadMethodCallException('This cache store does not support tagging.'); + } + + $names = is_array($names) ? $names : func_get_args(); + $names = array_map(fn ($name) => enum_value($name), $names); + + $cache = $store->tags($names); + + $cache->config = $this->config; + + if (! is_null($this->events)) { + $cache->setEventDispatcher($this->events); + } + + return $cache->setDefaultCacheTime($this->default); + } +``` + +Delete the `/* @phpstan-ignore-next-line */`. Add the `TaggableStore` import if missing. Run `./vendor/bin/phpstan` on the cache package after this phase to confirm the ignore is no longer needed (and that `$cache->config` public-property access still passes — it did before, unchanged). + +**Tests**: `tests/Cache/CacheRepositoryTest.php` — `supportsTags()` true for a `TaggableStore` mock with `supportsTags() => true`, false for a plain `Store` mock, false for a `TaggableStore` mock with `supportsTags() => false`; `tags()` throws for the latter two. + +--- + +### Phase 4 — Touch operations + +**4.1 New plain `Operations/Touch.php`** (`src/cache/src/Redis/Operations/Touch.php`): + +```php +context->withConnection( + fn (RedisConnection $connection) => (bool) $connection->expire( + $this->context->prefix() . $key, + max(1, $seconds) + ) + ); + } +} +``` + +Note the current inline code casts `(int) max(1, $seconds)` — `max(1, int)` is already `int`; drop the redundant cast. + +**4.2 New `Operations/AnyTag/Touch.php`** — mirrors the `AnyTag\Put` structure (Lua standard / sequential cluster). Behavior: extend the key; if the key has a reverse index, extend the reverse index, each tag hash field (`HEXPIRE`), and the registry scores (`ZADD GT` — a shortened key TTL must not shrink registry scores below other keys' needs; the registry is a per-tag upper bound, unlike the all-mode per-entry scores): + +```php +context->isCluster()) { + return $this->executeCluster($key, $seconds); + } + + return $this->executeUsingLua($key, $seconds); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(string $key, int $seconds): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds) { + $seconds = max(1, $seconds); + + if (! $connection->expire($this->context->prefix() . $key, $seconds)) { + return false; + } + + $tagsKey = $this->context->reverseIndexKey($key); + $tags = $connection->smembers($tagsKey); + + if (empty($tags)) { + return true; + } + + $connection->expire($tagsKey, $seconds); + + foreach ($tags as $tag) { + $connection->hexpire($this->context->tagHashKey((string) $tag), $seconds, [$key]); + } + + $expiry = time() + $seconds; + $zaddArgs = []; + + foreach ($tags as $tag) { + $zaddArgs[] = $expiry; + $zaddArgs[] = (string) $tag; + } + + $connection->zadd($this->context->registryKey(), ['GT'], ...$zaddArgs); + + return true; + }); + } + + /** + * Execute using Lua script for performance. + */ + private function executeUsingLua(string $key, int $seconds): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds) { + $keys = [ + $this->context->prefix() . $key, // KEYS[1] + $this->context->reverseIndexKey($key), // KEYS[2] + ]; + + $args = [ + max(1, $seconds), // ARGV[1] + $this->context->fullTagPrefix(), // ARGV[2] + $this->context->fullRegistryKey(), // ARGV[3] + time(), // ARGV[4] + $key, // ARGV[5] + $this->context->tagHashSuffix(), // ARGV[6] + ]; + + return (bool) $connection->evalWithShaCache($this->touchWithTagsScript(), $keys, $args); + }); + } + + /** + * Get the Lua script for touching a value and its tag metadata. + * + * KEYS[1] - The cache key (prefixed) + * KEYS[2] - The reverse index key + * ARGV[1] - TTL in seconds + * ARGV[2] - Tag prefix for building tag hash keys + * ARGV[3] - Tag registry key + * ARGV[4] - Current timestamp + * ARGV[5] - Raw key (without prefix, for hash field name) + * ARGV[6] - Tag hash suffix (":entries") + */ + protected function touchWithTagsScript(): string + { + return <<<'LUA' + local key = KEYS[1] + local tagsKey = KEYS[2] + local ttl = tonumber(ARGV[1]) + local tagPrefix = ARGV[2] + local registryKey = ARGV[3] + local now = tonumber(ARGV[4]) + local rawKey = ARGV[5] + local tagHashSuffix = ARGV[6] + + if redis.call('EXPIRE', key, ttl) == 0 then + return false + end + + local tags = redis.call('SMEMBERS', tagsKey) + + if #tags == 0 then + return true + end + + redis.call('EXPIRE', tagsKey, ttl) + + local expiry = now + ttl + + for _, tag in ipairs(tags) do + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HEXPIRE', tagHash, ttl, 'FIELDS', 1, rawKey) + redis.call('ZADD', registryKey, 'GT', expiry, tag) + end + + return true + LUA; + } +} +``` + +Before writing this file, read `Operations/AnyTag/Put.php`, `Forever.php`, and `Increment.php` in full and match their exact conventions (docblock style, `withConnection` usage, `evalWithShaCache`, cluster batching, `hexpire` client method availability — check `RedisConnection` / how `hsetex` is invoked in `Put.php` and mirror the calling convention for `hexpire`; if the phpredis client exposes hash-field expiry via a different method signature, follow whatever `Put.php`'s HSETEX handling establishes). + +**4.3 New `Operations/AllTag/Touch.php`** — extends the namespaced key and re-scores each tag ZSET entry (plain `ZADD`, not `GT`; per-entry scores must track the entry's real expiry in both directions): + +```php + $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries") + */ + public function execute(string $key, int $seconds, array $tagIds): bool + { + if ($this->context->isCluster()) { + return $this->executeCluster($key, $seconds, $tagIds); + } + + return $this->executeUsingLua($key, $seconds, $tagIds); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(string $key, int $seconds, array $tagIds): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds, $tagIds) { + $prefix = $this->context->prefix(); + $seconds = max(1, $seconds); + + if (! $connection->expire($prefix . $key, $seconds)) { + return false; + } + + $score = now()->addSeconds($seconds)->getTimestamp(); + + foreach ($tagIds as $tagId) { + $connection->zadd($prefix . $tagId, $score, $key); + } + + return true; + }); + } + + /** + * Execute using Lua script for performance. + */ + private function executeUsingLua(string $key, int $seconds, array $tagIds): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds, $tagIds) { + $seconds = max(1, $seconds); + + // Tag ZSET keys are statically known, so they are declared in KEYS + // (phpredis applies OPT_PREFIX to KEYS only) — the opt-prefixed + // fullTagPrefix()/fullRegistryKey() ARGV convention is reserved for + // keys built dynamically inside Lua. + $keys = [ + $this->context->prefix() . $key, // KEYS[1] + ...array_map(fn (string $tagId) => $this->context->prefix() . $tagId, $tagIds), // KEYS[2...] + ]; + + $args = [ + $seconds, // ARGV[1] + now()->addSeconds($seconds)->getTimestamp(), // ARGV[2] + $key, // ARGV[3] + ]; + + return (bool) $connection->evalWithShaCache($this->touchTaggedScript(), $keys, $args); + }); + } + + /** + * Get the Lua script for touching a tagged value and its ZSET scores. + * + * KEYS[1] - The cache key (prefixed, namespaced) + * KEYS[2...] - Prefixed tag ZSET keys + * ARGV[1] - TTL in seconds + * ARGV[2] - New expiry score + * ARGV[3] - Raw namespaced key (ZSET member) + */ + protected function touchTaggedScript(): string + { + return <<<'LUA' + local key = KEYS[1] + local ttl = tonumber(ARGV[1]) + local score = tonumber(ARGV[2]) + local member = ARGV[3] + + if redis.call('EXPIRE', key, ttl) == 0 then + return false + end + + for i = 2, #KEYS do + redis.call('ZADD', KEYS[i], score, member) + end + + return true + LUA; + } +} +``` + +Score convention matches `AllTag\Put` (`now()->addSeconds(...)->getTimestamp()`); the any-mode family uses `time()` (matching `AnyTag\Put`). Both branches expire-then-score so a missing or concurrently deleted key never gains ZSET entries — the same prove-liveness-first shape as `AnyTag\Touch`. + +**4.4 Register the ops.** `AllTagOperations`: add `private ?Touch $touch = null;` + accessor `touch(): Touch` (`new Touch($this->context)`) in the position matching the class's existing ordering. `AnyTagOperations`: same. `RedisStore`: add `private ?Touch $touchOperation = null;` + `getTouchOperation()` following the exact pattern of `getForgetOperation()`. + +**4.5 `RedisStore::touch()`** — route by mode, delete the inline `expire`: + +```php + /** + * Adjust the expiration time of a cached item. + */ + public function touch(string $key, int $seconds): bool + { + if ($this->tagMode === TagMode::Any) { + return $this->anyTagOps()->touch()->execute($key, $seconds); + } + + return $this->getTouchOperation()->execute($key, $seconds); + } +``` + +**4.6 `AllTaggedCache::touch()`** override (place it near the other write overrides, matching upstream ordering conventions — after `putMany()`, before `increment()`, mirroring `Repository`'s method grouping): + +```php + /** + * Set the expiration of a cached item; null TTL will retain the item forever. + */ + public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl = null): bool + { + $key = enum_value($key); + $value = $this->get($key); + + if (is_null($value)) { + return false; + } + + if (is_null($ttl)) { + return $this->forever($key, $value); + } + + return $this->store->allTagOps()->touch()->execute( + $this->itemKey($key), + $this->getSeconds($ttl), + $this->tags->tagIds() + ); + } +``` + +This mirrors `Repository::touch()` exactly: get-check, null-TTL routes to (tagged) `forever()`, integer TTL goes to the store path. `Repository::touch()` has no `<= 0` special case (the op clamps with `max(1, ...)` like the plain path), so this override adds none. + +**4.7 `AnyModeTaggedCache::touch()`** — already added in Phase 2.3. + +**Tests**: +- `tests/Cache/Redis/AllTaggedCacheTest.php`: `touch(int)` routes to `allTagOps()->touch()` with namespaced key + tagIds; `touch(null)` routes to tagged `forever()`; `touch()` on missing key returns false. +- `tests/Cache/Redis/AnyTaggedCacheTest.php`: `touch()` throws with the touch-specific message. +- `tests/Cache/Redis/RedisStoreTest.php`: `touch()` routes by mode — assert at the connection level per the file's existing style (op instances are private lazy singletons, not mockable directly): in all mode a bare `expire` is issued; in any mode the touch script/metadata commands run. Read the file's existing mode-routing tests first and mirror their approach. +- Integration (`tests/Integration/Cache/Redis/TaggedOperationsIntegrationTest.php` or a new `TouchOperationsIntegrationTest.php` following that file's structure): all-mode — put tagged, touch to extend, run `flushStale`, assert entry survives and tag flush still deletes the key; touch to shorten, assert score followed; **tagged touch of a missing key returns false and writes no ZSET entries** (inspect the tag ZSETs via raw connection). Any-mode — put tagged, plain-touch via `Cache::touch()`, assert reverse index TTL / hash field TTL / registry extended (inspect via raw connection), tag flush still deletes; touch of an untagged key is a plain expire; touch of a missing key returns false and writes no metadata. + +--- + +### Phase 5 — Any-mode metadata-aware forget + +**5.1 New `Operations/AnyTag/Forget.php`**: + +```php +context->isCluster()) { + return $this->executeCluster($key); + } + + return $this->executeUsingLua($key); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(string $key): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key) { + $tagsKey = $this->context->reverseIndexKey($key); + $tags = $connection->smembers($tagsKey); + + foreach ($tags as $tag) { + $connection->hdel($this->context->tagHashKey((string) $tag), $key); + } + + if (! empty($tags)) { + $connection->del($tagsKey); + } + + return (bool) $connection->del($this->context->prefix() . $key); + }); + } + + /** + * Execute using Lua script for performance. + */ + private function executeUsingLua(string $key): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key) { + $keys = [ + $this->context->prefix() . $key, // KEYS[1] + $this->context->reverseIndexKey($key), // KEYS[2] + ]; + + $args = [ + $this->context->fullTagPrefix(), // ARGV[1] + $key, // ARGV[2] + $this->context->tagHashSuffix(), // ARGV[3] + ]; + + return (bool) $connection->evalWithShaCache($this->forgetWithTagsScript(), $keys, $args); + }); + } + + /** + * Get the Lua script for deleting a value and its tag membership. + * + * KEYS[1] - The cache key (prefixed) + * KEYS[2] - The reverse index key + * ARGV[1] - Tag prefix for building tag hash keys + * ARGV[2] - Raw key (without prefix, for hash field name) + * ARGV[3] - Tag hash suffix (":entries") + */ + protected function forgetWithTagsScript(): string + { + return <<<'LUA' + local key = KEYS[1] + local tagsKey = KEYS[2] + local tagPrefix = ARGV[1] + local rawKey = ARGV[2] + local tagHashSuffix = ARGV[3] + + local tags = redis.call('SMEMBERS', tagsKey) + + for _, tag in ipairs(tags) do + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HDEL', tagHash, rawKey) + end + + if #tags > 0 then + redis.call('DEL', tagsKey) + end + + return redis.call('DEL', key) + LUA; + } +} +``` + +**5.2 Register** in `AnyTagOperations` (property + `forget(): Forget` accessor). + +**5.3 `RedisStore::forget()`** — route by mode: + +```php + /** + * Remove an item from the cache. + */ + public function forget(string $key): bool + { + if ($this->tagMode === TagMode::Any) { + return $this->anyTagOps()->forget()->execute($key); + } + + return $this->getForgetOperation()->execute($key); + } +``` + +The plain `Operations/Forget.php` stays as the all-mode path, unchanged. + +**Tests**: +- `tests/Cache/Redis/RedisStoreTest.php`: `forget()` routes by mode. +- Integration: any-mode — write key through tags, plain `Cache::forget()`, assert key + reverse index + tag hash field gone; **key-reuse proof**: write through tags, plain forget, write same key plain (untagged), tag flush, assert the new value survives; forget of an untagged key still deletes and returns true; forget of a missing key returns false (match plain `DEL` return semantics — assert against the current plain-op behavior). + +--- + +### Phase 6 — Stack tags + +**6.1 `StackStoreProxy`** — add accessors (place after the constructor): + +```php + /** + * Get the wrapped store. + */ + public function getStore(): Store + { + return $this->store; + } + + /** + * Get the layer TTL override in seconds, if configured. + */ + public function getTtl(): ?int + { + return $this->ttl; + } +``` + +**6.2 New `StackTagSet`** (`src/cache/src/StackTagSet.php`): + +```php +flush(); + } + + /** + * Flush all the tags in the set. + */ + public function flush(): void + { + foreach ($this->store->taggableLayers() as $layer) { + $layer->tags($this->names)->getTags()->flush(); + } + } +} +``` + +(Flushing via each layer's tag set — not its tagged cache `flush()` — avoids duplicate flush events; the stack-level `TaggedCache::flush()` fires the events once.) + +**6.3 `StackStore`** — the full set of changes: + +Class declaration: `class StackStore extends TaggableStore` (drop `implements Store`; `TaggableStore` implements it). Add `implements LockProvider, CanFlushLocks` in Phase 7 — for this phase just `extends TaggableStore`. + +Constructor normalization — internal code (TTL clamps, tagged writes, validation unwrapping) needs the uniform `StackStoreProxy` type, and a no-TTL proxy is behaviorally identical to the raw store, so the constructor normalizes instead of pushing wrapping onto every caller (`CacheManager` already wraps; direct construction in tests currently passes raw stores): + +```php + /** + * @var StackStoreProxy[] + */ + protected array $stores; + + /** + * @param array $stores + * + * @throws InvalidArgumentException when no layers are given + */ + public function __construct(array $stores) + { + if ($stores === []) { + throw new InvalidArgumentException('A cache stack requires at least one store layer.'); + } + + $this->stores = array_map( + static fn (Store $store) => $store instanceof StackStoreProxy ? $store : new StackStoreProxy($store), + $stores, + ); + } +``` + +The empty-array rejection is a constructor invariant (fail-fast): an empty stack is a pure misconfiguration, and Phase 7's bottom-layer delegation must always have a bottom layer. Verified: nothing in `src/` or `tests/` constructs an empty `StackStore`. Import `InvalidArgumentException`. + +New composition validation state and API (place after the constructor): + +```php + /** + * Memoized tag-composition validation error. + * + * Contains false before validation has run, null for a valid + * composition, and the error message for an invalid one. The layer + * array is immutable after construction, so this is computed once. + */ + protected false|null|string $tagCompositionError = false; +``` + +```php + /** + * Begin executing a new tags operation. + * + * @throws NotSupportedException when the layer composition cannot support tags + */ + public function tags(mixed $names): TaggedCache + { + if (! is_null($error = $this->tagCompositionError())) { + throw new NotSupportedException($error); + } + + return new StackTaggedCache($this, new StackTagSet($this, is_array($names) ? $names : func_get_args())); + } + + /** + * Determine if this store currently supports tags. + */ + public function supportsTags(): bool + { + return is_null($this->tagCompositionError()); + } + + /** + * Get the tag mode this store operates under. + * + * Only meaningful for a tag-capable composition — use supportsTags() + * to probe. Stack tags are always any-mode (tags index the shared + * layers; plain-key reads and backfill are untouched). + * + * @throws NotSupportedException when the layer composition cannot support tags + */ + public function getTagMode(): TagMode + { + if (! is_null($error = $this->tagCompositionError())) { + throw new NotSupportedException($error); + } + + return TagMode::Any; + } + + /** + * Get the memoized tag-composition validation error, if any. + */ + protected function tagCompositionError(): ?string + { + if ($this->tagCompositionError === false) { + $this->tagCompositionError = $this->validateTagComposition(); + } + + return $this->tagCompositionError; + } + + /** + * Validate the layer composition for tag support. + * + * Returns an error message, or null when the composition is valid: + * at least one taggable layer, and every layer from the first taggable + * layer down must be an any-mode taggable store. A non-taggable or + * all-mode layer below the taggable region would resurrect flushed + * values on read-through, silently undoing tag invalidation. + */ + protected function validateTagComposition(): ?string + { + $firstTaggable = null; + + foreach ($this->stores as $index => $proxy) { + $store = $proxy->getStore(); + + if ($firstTaggable === null) { + if ($store instanceof TaggableStore) { + $firstTaggable = $index; + } else { + continue; + } + } + + if (! $store instanceof TaggableStore) { + return sprintf( + 'Stack layer %d [%s] does not support tags. Layers below the first taggable layer must all be any-mode taggable stores.', + $index, + $store::class, + ); + } + + if (! $store->supportsTags() || $store->getTagMode() !== TagMode::Any) { + return sprintf( + 'Stack layer %d [%s] must be a taggable store in any mode to participate in stack tags.', + $index, + $store::class, + ); + } + } + + if ($firstTaggable === null) { + return 'The stack has no taggable layer; stack tags require at least one any-mode taggable store.'; + } + + return null; + } + + /** + * Get the underlying taggable layer stores, top to bottom. + * + * @return array + * + * @throws NotSupportedException when the layer composition cannot support tags + */ + public function taggableLayers(): array + { + if (! is_null($error = $this->tagCompositionError())) { + throw new NotSupportedException($error); + } + + $layers = []; + + foreach ($this->stores as $proxy) { + $store = $proxy->getStore(); + + if ($store instanceof TaggableStore) { + $layers[] = $store; + } + } + + return $layers; + } +``` + +Careful detail: `validateTagComposition()` calls `$store->supportsTags()` **before** `$store->getTagMode()`, so a nested invalid stack layer reports unsupported instead of throwing. + +Tagged record writes (place next to `putRecord()`/`putToStore()` so record logic reads as one unit): + +```php + /** + * Store a record in all layers, indexing it under the given tags in + * the taggable layers. + * + * @param array $tags + */ + public function putRecordTagged(array $tags, string $key, array $record): bool + { + return $this->callStores( + fn (StackStoreProxy $proxy) => $this->putToStoreTagged($proxy, $tags, $key, $record), + fn (StackStoreProxy $proxy) => $proxy->forget($key), + ); + } + + /** + * Store a record in a single layer, via the layer's tagged write path + * when the layer is taggable. + * + * Mirrors putToStore(), including the layer TTL clamp that + * StackStoreProxy::put() applies on the plain path — tagged writes + * bypass the proxy, so the clamp is replicated here. + */ + protected function putToStoreTagged(StackStoreProxy $proxy, array $tags, string $key, array $record): bool + { + $store = $proxy->getStore(); + + if (! $store instanceof TaggableStore) { + return $this->putToStore($proxy, $key, $record); + } + + if (! array_key_exists('value', $record)) { + return false; + } + + if (! array_key_exists('expiration', $record) && ! array_key_exists('ttl', $record)) { + if (is_null($proxyTtl = $proxy->getTtl())) { + return $store->tags($tags)->forever($key, $record); + } + + return $store->tags($tags)->put($key, $record, $proxyTtl); + } + + $currentTimestamp = Carbon::now()->getTimestamp(); + $value = $record['value']; + $expiration = $record['expiration'] ?? $currentTimestamp + $record['ttl']; + $ttl = $record['ttl'] ?? $record['expiration'] - $currentTimestamp; + $normalizedRecord = compact('value', 'expiration'); + + $proxyTtl = $proxy->getTtl(); + $effectiveTtl = is_null($proxyTtl) || $ttl < $proxyTtl ? $ttl : $proxyTtl; + + return $store->tags($tags)->put($key, $normalizedRecord, $effectiveTtl); + } + + /** + * Increment a record's value, indexing the write under the given tags. + * + * @param array $tags + */ + public function incrementTagged(array $tags, string $key, int $value = 1): bool|int + { + $record = $this->getOrRestoreRecord($key); + + if (is_null($record)) { + return tap($value, fn ($value) => $this->putRecordTagged($tags, $key, ['value' => $value])); + } + + $newValue = $record['value'] + $value; + $newRecord = ['value' => $newValue] + $record; + + if ($this->putRecordTagged($tags, $key, $newRecord)) { + return $newValue; + } + + return false; + } +``` + +Type note: with constructor normalization in place, update every internal closure and helper signature from `Store` to `StackStoreProxy` (`putToStore()`, the closures inside `get`/`forever`/`forget`/`flush`/`putRecord`/`getOrRestoreRecord`) — the narrow type is now guaranteed and `putToStoreTagged` needs the proxy accessors. `CacheManager::createStackDriver()` already passes proxies (verified); direct constructions with raw stores keep working through normalization, so `tests/Cache/CacheStackStoreTest.php` (which constructs `new StackStore([$array, $swoole, $redis])` with raw stores in several tests and proxies in others — verified) passes unchanged. + +Add imports: `Hypervel\Cache\Exceptions\NotSupportedException`, `Hypervel\Cache\TagMode` (same namespace — no import needed for `TagMode`, `TaggableStore`, `TaggedCache`, `StackTagSet`, `StackTaggedCache`; only the exception import from the sub-namespace). + +**6.4 New `StackTaggedCache`** (`src/cache/src/StackTaggedCache.php`): + +```php +putMany($key, $value); + } + + $key = enum_value($key); + + if ($ttl === null) { + return $this->forever($key, $value); + } + + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { + return $this->store->forget($key); + } + + $result = $this->store->putRecordTagged($this->tags->getNames(), $key, [ + 'value' => $value, + 'ttl' => $seconds, + ]); + + if ($result) { + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds)); + } + + return $result; + } + + /** + * Store an item in the cache if the key does not exist. + */ + public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInterface|int|null $ttl = null): bool + { + $key = enum_value($key); + + if (! is_null($this->store->get($key))) { + return false; + } + + return $this->put($key, $value, $ttl); + } + + /** + * Store an item in the cache indefinitely. + */ + public function forever(UnitEnum|string $key, mixed $value): bool + { + $key = enum_value($key); + + $result = $this->store->putRecordTagged($this->tags->getNames(), $key, ['value' => $value]); + + if ($result) { + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value))); + } + + return $result; + } + + /** + * Increment the value of an item in the cache. + */ + public function increment(UnitEnum|string $key, int $value = 1): bool|int + { + return $this->store->incrementTagged($this->tags->getNames(), enum_value($key), $value); + } + + /** + * Decrement the value of an item in the cache. + */ + public function decrement(UnitEnum|string $key, int $value = 1): bool|int + { + return $this->increment($key, $value * -1); + } + + /** + * Get an item from the cache, or execute the given Closure and store the result. + * + * Reads plain through the stack (L1 hit, else L2 with L1 backfill); + * writes through the tagged path on a miss. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * @return TCacheValue + */ + public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed + { + if ($ttl === null) { + return $this->rememberForever($key, $callback); + } + + $key = enum_value($key); + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { + return $callback(); + } + + $value = $this->store->get($key); + + if (! is_null($value)) { + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value))); + + return NullSentinel::unwrap($value); + } + + $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key)); + + $value = $callback(); + + $this->put($key, $value, $seconds); + + return NullSentinel::unwrap($value); + } + + /** + * Get an item from the cache, or execute the given Closure and store the result forever. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * @return TCacheValue + */ + public function rememberForever(UnitEnum|string $key, Closure $callback): mixed + { + $key = enum_value($key); + $value = $this->store->get($key); + + if (! is_null($value)) { + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value))); + + return NullSentinel::unwrap($value); + } + + $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key)); + + $value = $callback(); + + $this->forever($key, $value); + + return NullSentinel::unwrap($value); + } + + /** + * Get the tag set instance (covariant return type). + */ + public function getTags(): StackTagSet + { + return $this->tags; + } + + /** + * Store multiple items in the cache indefinitely. + */ + protected function putManyForever(array $values): bool + { + $result = true; + + foreach ($values as $key => $value) { + if (! $this->forever((string) $key, $value)) { + $result = false; + } + } + + return $result; + } +} +``` + +Notes: `putMany()` is inherited from the base `TaggedCache` (loops `put()` — correct here; the stack has no batched op). `flush()` is inherited (`tags->reset()` → `StackTagSet::flush()` + events — correct). `remember()`'s nullable variants ride on it via `Repository::rememberNullable()` wrapping the callback with the sentinel. Event `name` argument is `null`, matching `AnyTaggedCache`/`AllTaggedCache` (tagged caches don't carry a store name). + +**6.5 `Cache` facade docblock**: unchanged (`tags()` returns `TaggedCache` — still true). Verify with the facade-documenter if the repo workflow regenerates docblocks; do not hand-edit beyond need. + +**Tests** (new `tests/Cache/CacheStackStoreTagsTest.php`, matching the `Cache*Test` naming of `CacheStackStoreTest`/`CacheTaggedCacheTest`, plus updates): + +Unit (Mockery, no Redis): +- Composition validation: `[nonTaggable, anyModeTaggable]` valid; `[anyModeTaggable]` valid; `[nonTaggable, anyModeTaggable, anyModeTaggable]` valid; `[nonTaggable]` invalid (no taggable layer); `[anyModeTaggable, nonTaggable]` invalid (non-taggable below); `[nonTaggable, allModeTaggable]` invalid (mode); `[allModeTaggable, anyModeTaggable]` invalid (first taggable is all-mode); nested-stack layer probes `supportsTags()` without tripping `getTagMode()`. Build layers as `new StackStoreProxy(m::mock(TaggableStore::class), ttl)` with `getTagMode`/`supportsTags` expectations, plus `m::mock(Store::class)` for non-taggable. +- `supportsTags()` true/false; `getTagMode()` returns `Any` when valid, throws `NotSupportedException` when not; `tags()` throws with the layer-naming message; validation runs once (mock expectations `once()`). +- Tagged write mechanics: mock taggable layer expects `tags(['t'])` returning a mock tagged cache expecting `put($key, recordWithExpiration, clampedTtl)`; non-taggable upper layer expects plain `put($key, record, ttl)`; proxy TTL clamp applied to the tagged path (layer ttl 3, item ttl 300 → tagged put receives 3); forever records route to tagged `forever()` (no proxy ttl) / tagged `put(ttl)` (with proxy ttl); rollback: lower-layer failure triggers `forget` on written layers. +- `increment`/`decrement` read-modify-write through `putRecordTagged`. +- `remember` plain-read hit skips writes; miss writes tagged. +- Inherited contract: all `AnyModeTaggedCache` throwing methods + aliases (`getMultiple`, `delete`, `deleteMultiple`, `offsetGet/Exists/Unset`) throw; `setMultiple`/`offsetSet` write; `clear()` flushes tags not store. + +Integration (`tests/Integration/Cache/Redis/StackTaggedCacheIntegrationTest.php`, guarded by `InteractsWithRedis`, any-mode Redis store config + a `file` (non-taggable) L1 layer with a short ttl override — file is the simplest real non-taggable layer; add one swoole-L1 test if an existing swoole-store test demonstrates the table setup pattern, otherwise file-only is sufficient because layer interaction is identical through the proxy): +- Tagged write through the stack → value readable plain via the stack; Redis holds the record and the tag indexes; L1 holds the record. +- Tag flush → Redis keys gone; stack read within L1 TTL serves stale; after L1 TTL expiry, read misses (no resurrection). +- Resurrection guard: composition `[file, redis-any]` valid; assert `[redis-any, file]` throws. +- `remember` backfills L1 from L2. +- Key-reuse with plain forget (ties Phase 5 together end-to-end through the stack). + +--- + +### Phase 7 — Stack locks + honest `CanFlushLocks` + +**7.1 `CanFlushLocks`** (`src/contracts/src/Cache/CanFlushLocks.php`) — add: + +```php + /** + * Determine if the store can currently flush locks. + * + * Composite stores may implement the interface while delegating to a + * layer that cannot flush locks; this probe reports the real + * capability so supportsFlushingLocks() checks never lie. + */ + public function supportsFlushingLocks(): bool; +``` + +**7.2 Implementers** — `RedisStore`, `AbstractArrayStore`, `DatabaseStore`, `FileStore` each add: + +```php + /** + * Determine if the store can currently flush locks. + */ + public function supportsFlushingLocks(): bool + { + return true; + } +``` + +Place next to each class's existing `flushLocks()`/`hasSeparateLockStore()` methods. + +**7.3 `Repository::supportsFlushingLocks()`**: + +```php + /** + * Determine if the current store supports flushing locks. + */ + public function supportsFlushingLocks(): bool + { + return $this->store instanceof CanFlushLocks && $this->store->supportsFlushingLocks(); + } +``` + +`Repository::flushLocks()` keeps its structure; with the instanceof inline in the probe, check whether the phpstan-ignore on `$store->flushLocks()` can now be removed by assigning the narrowed store (`$store = $this->getStore(); if (! $store instanceof CanFlushLocks || ! $store->supportsFlushingLocks()) { throw ... }`) — restructure exactly like `tags()` so the ignore comes out. Same event flow otherwise. + +**7.4 `StackStore`** — add `implements LockProvider, CanFlushLocks` to the class declaration and: + +```php + /** + * Get a lock instance. + * + * Locks are delegated to the bottom layer's store and never touch the + * caching tiers — a lock cached in a microcache layer would not be a + * lock. The delegated lock has exactly the semantics of using the + * bottom store directly. + * + * @throws NotSupportedException when the bottom layer is not a lock provider + */ + public function lock(string $name, int $seconds = 0, ?string $owner = null): Lock + { + return $this->bottomLockProvider()->lock($name, $seconds, $owner); + } + + /** + * Restore a lock instance using the owner identifier. + * + * @throws NotSupportedException when the bottom layer is not a lock provider + */ + public function restoreLock(string $name, string $owner): Lock + { + return $this->bottomLockProvider()->restoreLock($name, $owner); + } + + /** + * Determine if the store can currently flush locks. + */ + public function supportsFlushingLocks(): bool + { + $store = $this->bottomStore(); + + return $store instanceof CanFlushLocks && $store->supportsFlushingLocks(); + } + + /** + * Flush all locks managed by the store. + * + * @throws NotSupportedException when the bottom layer cannot flush locks + */ + public function flushLocks(): bool + { + $store = $this->bottomStore(); + + if (! $store instanceof CanFlushLocks || ! $store->supportsFlushingLocks()) { + throw new NotSupportedException(sprintf( + 'The stack\'s bottom layer [%s] does not support flushing locks.', + $store::class, + )); + } + + return $store->flushLocks(); + } + + /** + * Determine if the lock store is separate from the cache store. + */ + public function hasSeparateLockStore(): bool + { + $store = $this->bottomStore(); + + return $store instanceof CanFlushLocks && $store->hasSeparateLockStore(); + } + + /** + * Get the bottom layer's underlying store. + */ + protected function bottomStore(): Store + { + // array_key_last() instead of end() — end() mutates the array's internal pointer. + return $this->stores[array_key_last($this->stores)]->getStore(); + } + + /** + * Get the bottom layer's store as a lock provider. + * + * @throws NotSupportedException when the bottom layer is not a lock provider + */ + protected function bottomLockProvider(): LockProvider + { + $store = $this->bottomStore(); + + if (! $store instanceof LockProvider) { + throw new NotSupportedException(sprintf( + 'The stack\'s bottom layer [%s] does not support locks. Use a lock-capable store (e.g. redis) as the bottom layer.', + $store::class, + )); + } + + return $store; + } +``` + +Imports: `Hypervel\Contracts\Cache\CanFlushLocks`, `Hypervel\Contracts\Cache\Lock`, `Hypervel\Contracts\Cache\LockProvider`. Group lock methods together after the flush/tag region, mirroring how `RedisStore` orders its lock section. The Phase 6 constructor invariant guarantees at least one layer, so `array_key_last()` in `bottomStore()` is always defined. + +**Tests**: new `tests/Cache/CacheStackStoreLocksTest.php` — `lock()`/`restoreLock()` delegate to a bottom `LockProvider` mock with args passed through; non-provider bottom throws `NotSupportedException` naming the class; `supportsFlushingLocks()` reflects the bottom's probe (true/false/non-CanFlushLocks); `flushLocks()` delegates or throws; `hasSeparateLockStore()` delegates; empty-layer construction throws `InvalidArgumentException` (add to `CacheStackStoreTest`, since the invariant belongs to Phase 6's constructor). `tests/Cache/CacheRepositoryTest.php` — `supportsFlushingLocks()` uses the store probe. + +`tests/Cache/FunnelUnsupportedStoresTest.php` contains `testStackStoreDoesNotImplementLockProvider` (line 20), which asserts `StackStore` is *not* a `LockProvider` — it documents exactly the gap this phase closes, so the design inverts it. Remove that test method and cover the inverse in `CacheStackStoreLocksTest` (delegation when the bottom supports locks, `NotSupportedException` when it doesn't — which is the funnel-relevant failure mode that test guarded). Read the rest of `FunnelUnsupportedStoresTest` for other stack-related assumptions and update them to the delegation behavior. This is a deliberate, plan-approved test removal, not a workaround. + +Verify all four `CanFlushLocks` implementers' existing lock tests still pass (they gain a trivial probe method). + +--- + +### Phase 8 — JWT dual-mode blacklist storage + +**8.1 `src/jwt/src/Storage/TaggedCache.php`** — full replacement of the class body: + +```php +getStore(); + + $this->directKeyMode = $store instanceof TaggableStore + && $store->supportsTags() + && $store->getTagMode()->supportsDirectGet(); + } + + /** + * Add a new item into storage. + */ + public function add(string $key, mixed $value, int $minutes): void + { + /* @phpstan-ignore-next-line */ + $this->cache->tags([$this->tag])->put($this->storageKey($key), $value, $minutes * 60); + } + + /** + * Add a new item into storage forever. + */ + public function forever(string $key, mixed $value): void + { + /* @phpstan-ignore-next-line */ + $this->cache->tags([$this->tag])->forever($this->storageKey($key), $value); + } + + /** + * Get an item from storage. + */ + public function get(string $key): mixed + { + if ($this->directKeyMode) { + return $this->cache->get($this->storageKey($key)); + } + + /* @phpstan-ignore-next-line */ + return $this->cache->tags([$this->tag])->get($key); + } + + /** + * Remove an item from storage. + */ + public function destroy(string $key): bool + { + if ($this->directKeyMode) { + return $this->cache->forget($this->storageKey($key)); + } + + /* @phpstan-ignore-next-line */ + return $this->cache->tags([$this->tag])->forget($key); + } + + /** + * Remove all items associated with the tag. + */ + public function flush(): void + { + /* @phpstan-ignore-next-line */ + $this->cache->tags([$this->tag])->flush(); + } + + /** + * Get the storage key for a logical blacklist key. + */ + protected function storageKey(string $key): string + { + return $this->directKeyMode ? self::DIRECT_KEY_PREFIX . $key : $key; + } +} +``` + +Constructor probe note: the probe follows the honest ordering (type → capability → mode) because the provider gate (below) only enforces `supportsTags()` when the blacklist is enabled — with the blacklist disabled, the storage can be constructed over any store, including a `TaggableStore` whose `getTagMode()` throws (an invalid stack composition). `getTagMode()` is never a probe (D5); `supportsTags()` guards it here exactly as in the auth gate and `Repository::tags()`. `storageKey()` applies the prefix only in direct-key mode; the tag name is never prefixed. + +**8.2 `JWTServiceProvider::cacheStoreForJwtBlacklist()`** — the `supportsTags()` gate is now composition-aware; only the message changes: + +```php + if ($blacklistEnabled && ! $repository->supportsTags()) { + throw new RuntimeException( + 'The JWT blacklist requires a taggable cache store (all-mode or any-mode). ' + . 'Use a taggable store or set a custom jwt.providers.storage.' + ); + } +``` + +**Tests**: `tests/JWT/Storage/TaggedCacheTest.php` — split into all-mode and any-mode cases (store mock returning `TagMode::All` / `TagMode::Any`): all mode preserves current expectations verbatim; any mode expects tagged writes with prefixed keys, plain `get`/`forget` with prefixed keys, tagged flush, and asserts the tag name is unprefixed. Collision isolation: any-mode `get('abc')` reads `jwt_blacklist:abc`, never `abc`. `tests/JWT/JWTServiceProviderTest.php` — gate accepts a taggable any-mode store and a valid composition; rejects non-taggable; message updated. + +--- + +### Phase 9 — Auth gate ordering + +`EloquentUserProvider::ensureTaggableAnyModeStore()` — probe order: type → capability → mode: + +```php + protected function ensureTaggableAnyModeStore(CacheRepository $cache): void + { + $store = $cache->getStore(); + + if (! $store instanceof TaggableStore || ! $store->supportsTags()) { + throw new InvalidArgumentException(sprintf( + 'Auth user caching tags require a store that supports tags; got [%s]. See the auth cache documentation for supported stores.', + $store::class, + )); + } + + if ($store->getTagMode() !== TagMode::Any) { + throw new InvalidArgumentException(sprintf( + 'Auth user caching tags require a store configured in TagMode::Any; got [%s] in mode [%s]. Configure a Redis store with tag_mode=any (or a stack over one) for auth caching.', + $store::class, + $store->getTagMode()->value, + )); + } + } +``` + +Update the method docblock's rationale bullets to stay accurate. No whitelist change (`StackStore::class` already present). Keep the phpstan-ignore at the `tags()` call site with its existing comment. + +**Tests**: extend the existing auth cache tests: a valid mock any-mode stack composition passes the gate; an invalid stack (supportsTags false) is rejected by the capability check without `getTagMode()` being called (expectation ordering proves it). + +--- + +### Phase 10 — Documentation + +All edits with `Edit` (never rewrite files). Locations verified; re-grep before editing. + +1. **`src/boost/docs/cache.md` tags support note (line ~531)**: replace the note with: tags supported by `redis`, `array`, `failover`, `null`, and `stack` (any-mode compositions only — see Tagged Cache Stacks); not supported by `file`, `database`, `swoole`, `session`, or `memo`. +2. **`src/boost/docs/cache.md` — new "Tagged Cache Stacks" section** (anchor `tagged-cache-stacks`, placed after "Any Tag Mode", added to the TOC): covers — any-mode-only semantics (writes/flush through `tags()`, reads plain); the composition rule with a valid and an invalid example config and the resurrection rationale; the bounded-staleness tradeoff after tag flush (per-node L1 serves stale up to its TTL); **strong guidance** that non-taggable microcache layers should carry a short `ttl` override, and what happens without one (staleness bounded only by item TTL); `supportsTags()` as the probe. +3. **`src/boost/docs/cache.md` — Any Tag Mode section**: add the plain-write contract paragraph, precise about which paths sync metadata: tag membership persists until the key is deleted or re-tagged; plain `forget()` removes membership; a finite-TTL `Cache::touch($key, $ttl)` on an any-mode Redis store keeps tag metadata in sync; `touch($key, null)`, plain `put()`, and plain `forever()` are plain rewrites that do not re-tag or re-sync tag TTL metadata — change a tagged item's TTL or tags by writing through `tags()`. Add: tagged `touch()` throws. The Tagged Cache Stacks section states the stack-specific boundary: direct stack `touch()` rewrites records through plain layer writes, so for tagged stack items use a tagged re-put when tag metadata must stay authoritative. +4. **`src/boost/docs/cache.md` flushLocks note (line ~768)**: add `stack` (delegates to its bottom layer; supported when the bottom layer supports flushing locks). Mention stack lock delegation in the Building Cache Stacks section (one paragraph: locks and lock helpers work on stacks by bottom-layer delegation; bottom layer must be lock-capable). +5. **`src/boost/docs/authentication.md` (line ~300)**: update "Redis is the stock store that supports configurable tag modes" guidance to include any-mode stacks as valid auth-tag stores. +6. **`src/foundation/config/auth.php` + `src/testbench/hypervel/config/auth.php`**: update the cache tags comment (currently "Requires a TaggableStore configured in TagMode::Any (e.g. a Redis store with tag_mode=any)") to mention stack compositions; revisit the "only the outer store is validated" caveat — it remains true for the non-tags store whitelist, but tags now validate the composition; reword so both facts are stated accurately. +7. **JWT docs**: `grep -rn 'blacklist' src/boost/docs/` — update any JWT blacklist storage docs to describe both modes and add the explicit security caveat: on a stack (or any store with a node-local tier), a revoked token may still validate for up to the L1 TTL on other nodes; choose blacklist stores accordingly. If no doc file covers JWT blacklist storage, add the caveat to the jwt config comment above `blacklist_enabled` instead. +8. **`docs/todo.md`**: add under a Cache heading (create it in alphabetical position if absent): an entry for opt-in metadata-aware plain `put()`/`forever()` on any-mode Redis stores — plain value-rewrites deliberately don't re-sync tag metadata TTLs (hot-path cost; the contract is documented in cache.md), and a future opt-in could close that for stores willing to pay it. Write the entry in the file's existing style. + +### Phase 11 — Full verification + +1. `composer test:parallel` from the repo root — full suite green. Investigate all failures per AGENTS.md rules (straightforward fixes proceed; anything behavioral stops for approval). +2. `./vendor/bin/phpstan` — zero new errors; confirm the two removed ignores stay removed. +3. `./vendor/bin/php-cs-fixer fix` — run without flags; commit-ready formatting. +4. Grep sweeps (must all come back clean): + - `grep -rn 'method_exists.*tags' src/` — no capability probing by method_exists remains. + - `grep -rn 'uniqid' src/cache/src/` — only `VersionedTagSet`. + - `grep -rn 'Key differences from AllTagSet' src/` — gone. + - `grep -rn 'new TaggedCache(' src/cache tests/Cache` — none (abstract). (`src/jwt` legitimately matches on its own `JWT\Storage\TaggedCache` — different class.) + - `grep -rn 'instanceof RedisStore' src/jwt src/auth` — none. + +## Comment Guidance + +Required comments (non-obvious WHY only — all already embedded in the snippets above): + +- `AnyModeTaggedCache` class docblock: the any-mode contract (single source of truth for it). +- `AnyTag\Forget` / `AnyTag\Touch` / `AllTag\Touch` class docblocks: why metadata participates (drift/reuse failure modes) and, for `Forget`, why the registry is untouched. +- `StackStore::validateTagComposition()`: the resurrection rationale. +- `StackStore` lock methods: why locks never touch cache tiers. +- `StackStore::putToStoreTagged()`: why the proxy clamp is replicated. +- `StackTagSet::flush()`: why upper layers are not flushed. +- `StackTaggedCache` class docblock + `remember()`: plain-read/tagged-write shape. +- `TaggedCache::clear()`: one line — PSR clear scope is the tag set. +- `JWT\Storage\TaggedCache`: prefix rationale and direct-key mode explanation. +- Cluster branches: keep the existing style of slot commentary (mirror `AnyTag\Put`). + +Forbidden: comments narrating the refactor ("moved from TaggedCache", "was previously..."), annotations on routine type modernizations, restating method names, framework-divergence notes. Docblocks are Laravel-style title-first imperative; no `@param`/`@return` where the signature already says it. No `Boot-only.`/`Tests only.` warnings are needed on any method in this plan (none of the new methods mutate worker-lifetime state outside construction; `RedisStore::setTagMode()` already carries its warning). + +## Test Plan Summary + +Ordered by phase; every file run individually right after it's written or updated (`./vendor/bin/phpunit --no-progress `). + +| Phase | File | Proves | +|---|---|---| +| 1+2 | `tests/Cache/CacheTaggedCacheTest.php` (update) | Generic path behavior identical; `clear()` scoped to tags; instanceof `TaggedCache` holds | +| 1+2 | `tests/Cache/Redis/AnyTagSetTest.php`, `AllTagSetTest.php` (update) | Hierarchy split; walkers; deleted neutralizers gone | +| 1+2 | `tests/Cache/Redis/AnyTaggedCacheTest.php` (update) | Inherited throw contract incl. `touch()` + PSR/ArrayAccess aliases; `clear()`; zero-TTL put deletes | +| 1+2 | `tests/Cache/Redis/AllTaggedCacheTest.php` (update) | `clear()` via tagged flush | +| 3 | `tests/Cache/CacheRepositoryTest.php` (update) | `supportsTags()` probe truth table; `tags()` throws | +| 4 | `tests/Cache/Redis/RedisStoreTest.php` (update) | `touch()`/`forget()` mode routing | +| 4 | `tests/Cache/Redis/AllTaggedCacheTest.php` (update) | touch override routing incl. `touch(null)` → forever | +| 4 | Integration: touch cases in the tagged-operations suite | Score sync both directions; any-mode metadata extension; prune survival | +| 5 | Integration: forget cases | Metadata cleanup; key-reuse proof; untagged forget unchanged | +| 6 | `tests/Cache/CacheStackStoreTagsTest.php` (new) | Composition truth table; probe honesty; write mechanics with clamps; rollback; increment; remember; inherited contract | +| 6 | `tests/Cache/CacheStackStoreTest.php` (verify) | Raw-store constructions still pass via proxy normalization | +| 6 | `tests/Integration/Cache/Redis/StackTaggedCacheIntegrationTest.php` (new) | End-to-end write/read/backfill/flush/staleness/no-resurrection | +| 7 | `tests/Cache/CacheStackStoreLocksTest.php` (new) | Delegation, throws, probe honesty | +| 7 | `tests/Cache/FunnelUnsupportedStoresTest.php` (update) | Stack lock-gap assertion replaced by delegation coverage | +| 8 | `tests/JWT/Storage/TaggedCacheTest.php` (update) | Both modes; prefix isolation | +| 8 | `tests/JWT/JWTServiceProviderTest.php` (update) | Gate behavior + message | +| 9 | Auth cache tests (update) | Gate ordering; stack acceptance | +| 11 | `composer test:parallel` | Whole suite | + +Unit tests use Mockery (`use Mockery as m;`) per repo convention; integration tests use `InteractsWithRedis` and live in `tests/Integration/Cache/Redis/` matching the existing suites' structure (read `TaggedOperationsIntegrationTest.php` before writing new integration files and mirror its setup/teardown and store-config helpers). All test classes extend `Hypervel\Tests\TestCase` or the appropriate integration base already used by sibling files — never raw PHPUnit. + +## Execution Rules + +- Work from the components repo root. One file at a time. Edit, never rewrite existing files. +- Before creating each new operation class, read its op-family siblings in full (`AnyTag/Put.php`, `AnyTag/Increment.php`, `AllTag/Put.php`, `AllTag/FlushStale.php`, plain `Put.php`/`Forget.php`) and match conventions exactly — including how the client exposes `HSETEX`/`HEXPIRE` (verify the `hexpire` call signature against how `Put.php` invokes `hsetex` and against `RedisConnection`; if it differs from this plan's snippet, follow the client's actual API and note it). +- Before editing each existing class, read it in full. +- Line numbers in this plan are anchors, not gospel — grep before editing. +- Any test failure that isn't a straightforward mechanical fix: stop and report root cause with a recommended fix. +- If any verified fact in this plan turns out to be wrong at implementation time, stop and report before proceeding — do not silently adapt. diff --git a/src/auth/src/EloquentUserProvider.php b/src/auth/src/EloquentUserProvider.php index c59730789..3f7b8432b 100755 --- a/src/auth/src/EloquentUserProvider.php +++ b/src/auth/src/EloquentUserProvider.php @@ -294,7 +294,7 @@ public function rehashPasswordIfRequired(UserContract $user, #[SensitiveParamete * Boot-only. User providers are held by cached guards; runtime use mutates * the provider used by every subsequent authentication lookup. * - * @param null|array $tags optional tag names enabling tag-based bulk flush; requires a TaggableStore in TagMode::Any + * @param null|array $tags optional tag names enabling tag-based bulk flush; requires any-mode tag support * * @throws InvalidArgumentException when the resolved store is not supported */ @@ -426,11 +426,13 @@ protected function ensureSupportedAuthCacheStore(CacheRepository $cache): void } /** - * Ensure the resolved cache store supports tags and is in any-mode. + * Ensure the resolved cache store supports tags in any-mode. * * Auth caching only supports tag-based bulk flush via any-mode because: * - any-mode keys are independent of tags, so reads and per-user * forgets stay on the plain repo (no mode branching in the hot path); + * - stack caches can layer short-lived local reads over shared any-mode + * tag indexes without changing auth's plain-key read path; * - the dynamic tag resolver can't cause cross-context invalidation * bugs since the auto-invalidation listener never touches tags. * @@ -440,18 +442,20 @@ protected function ensureTaggableAnyModeStore(CacheRepository $cache): void { $store = $cache->getStore(); - if (! $store instanceof TaggableStore) { + if (! $store instanceof TaggableStore || ! $store->supportsTags()) { throw new InvalidArgumentException(sprintf( - 'Auth user caching tags require a TaggableStore; got [%s]. See the auth cache documentation for supported stores.', + 'Auth user caching tags require a store that supports tags; got [%s]. See the auth cache documentation for supported stores.', $store::class, )); } - if ($store->getTagMode() !== TagMode::Any) { + $mode = $store->getTagMode(); + + if ($mode !== TagMode::Any) { throw new InvalidArgumentException(sprintf( - 'Auth user caching tags require a store configured in TagMode::Any; got [%s] in mode [%s]. Configure a separate Redis store with tag_mode=any for auth caching.', + 'Auth user caching tags require a store configured in TagMode::Any; got [%s] in mode [%s]. Configure a Redis store with tag_mode=any (or a stack over one) for auth caching.', $store::class, - $store->getTagMode()->value, + $mode->value, )); } } diff --git a/src/boost/docs/authentication.md b/src/boost/docs/authentication.md index 85feb7bac..7d7d2064a 100644 --- a/src/boost/docs/authentication.md +++ b/src/boost/docs/authentication.md @@ -221,7 +221,7 @@ AUTH_USERS_CACHE_ENABLED=true AUTH_USERS_CACHE_STORE=stack ``` -Supported stores are `redis`, `database`, `file`, `swoole`, and `stack`. The `array`, `null`, `session`, and `failover` stores are rejected when the guard is resolved because they are either request-local, user-local, discard writes, or have fallback behavior that is not appropriate for authentication data. If you use `stack`, Hypervel only validates the outer stack store; unsupported inner stores such as `array`, `null`, `session`, or `failover` can still cause stale, missing, or unsafe auth cache behavior. Choose supported inner stores such as `swoole` and `redis`. +Supported stores are `redis`, `database`, `file`, `swoole`, and `stack`. The `array`, `null`, `session`, and `failover` stores are rejected when the guard is resolved because they are either request-local, user-local, discard writes, or have fallback behavior that is not appropriate for authentication data. For untagged auth caching, Hypervel validates the outer stack store; unsupported inner stores such as `array`, `null`, `session`, or `failover` can still cause stale, missing, or unsafe auth cache behavior. Choose supported inner stores such as `swoole` and `redis`. When auth cache tags are configured, the stack's tag composition is also validated. When using a node-local store such as `swoole` or `file`, invalidation is local to that node. In a multi-node deployment using `stack` with a Swoole L1, a user update clears the current node's L1 and the shared backing store, while other nodes may serve their L1 entry until its short TTL expires. Use plain `redis` or `database` if you need strict cross-node consistency. @@ -297,7 +297,7 @@ Then flush the tagged entries: Cache::store('auth')->tags(['auth_users'])->flush(); ``` -Auth cache tags require a taggable store configured in `any` mode. Redis is the stock store that supports configurable tag modes. The default Redis tag mode is `all`, so use a separate Redis store with `tag_mode` set to `any` when enabling auth cache tags. +Auth cache tags require a store that supports tags in `any` mode. Use a Redis store with `tag_mode` set to `any`, or a valid cache stack whose taggable layers are all any-mode stores. The default Redis tag mode is `all`, so use a separate Redis store or stack when enabling auth cache tags. You may also add per-request dynamic tags. This is useful when every cached user should keep a broad static tag, such as `auth_users`, plus a narrower request-specific tag, such as the current tenant: diff --git a/src/boost/docs/cache.md b/src/boost/docs/cache.md index f2079fe29..ee16439c7 100644 --- a/src/boost/docs/cache.md +++ b/src/boost/docs/cache.md @@ -20,6 +20,7 @@ - [Redis Tag Modes](#redis-tag-modes) - [All Tag Mode](#all-tag-mode) - [Any Tag Mode](#any-tag-mode) + - [Tagged Cache Stacks](#tagged-cache-stacks) - [Pruning Stale Tag Entries](#pruning-stale-tag-entries) - [Atomic Locks](#atomic-locks) - [Managing Locks](#managing-locks) @@ -168,6 +169,8 @@ This configuration aggregates two other cache stores: `swoole` and `redis`. When When retrieving data, if there is a cache hit in the `swoole` layer, the data will be returned immediately and the Redis cache will not be queried. If there is a cache miss in the `swoole` layer, the stack driver will check the Redis layer. If Redis contains the value, the value will be returned and backfilled into the Swoole layer for future requests. +Locks and lock-backed helpers, such as `Cache::lock`, `Cache::funnel`, and `Cache::withoutOverlapping`, are delegated to the bottom layer of the stack. Use a lock-capable bottom store, such as Redis, when a stack is your default cache store. + ### Session Cache @@ -528,7 +531,7 @@ cache()->remember('users', $seconds, function () { ## Cache Tags > [!WARNING] -> Cache tags are supported by the `redis`, `array`, `failover`, and `null` cache drivers. They are not supported by the `file`, `database`, `swoole`, `stack`, `session`, or `memo` drivers. +> Cache tags are supported by the `redis`, `array`, `failover`, `null`, and `stack` cache drivers. Stack tags require an any-mode composition; see [Tagged Cache Stacks](#tagged-cache-stacks). Cache tags are not supported by the `file`, `database`, `swoole`, `session`, or `memo` drivers. ### Redis Tag Modes @@ -598,8 +601,10 @@ Because tags are invalidation indexes in `any` mode, flushing any one tag remove Cache::tags(['user:42'])->flush(); ``` +Tag membership is synchronized by tagged writes. Plain `Cache::forget($key)` removes any-mode tag membership for that key, and a finite `Cache::touch($key, $ttl)` keeps the key and tag metadata TTLs in sync. Plain `put`, plain `forever`, and `touch($key, null)` are plain rewrites; they do not add tags or refresh tag metadata for an already-tagged value. To change a tagged value's TTL or tags, write it again through `tags()`. + > [!WARNING] -> In `any` mode, attempting to retrieve, check, pull, forget, or retrieve many cache items through a tagged cache will throw a `BadMethodCallException`. Use the direct `Cache::get`, `Cache::has`, `Cache::pull`, `Cache::forget`, and `Cache::many` methods with the full cache key instead. +> In `any` mode, attempting to retrieve, check, pull, forget, touch, or retrieve many cache items through a tagged cache will throw a `BadMethodCallException`. Use the direct `Cache::get`, `Cache::has`, `Cache::pull`, `Cache::forget`, `Cache::touch`, and `Cache::many` methods with the full cache key instead. The `items` method returns a generator yielding all key / value pairs indexed by the given tags. This can be useful for debugging or bulk operations: @@ -609,6 +614,43 @@ foreach (Cache::tags(['user:42'])->items() as $key => $value) { } ``` + +### Tagged Cache Stacks + +The `stack` driver supports cache tags when its taggable layers are all configured in `any` mode. Tagged stack writes record tag indexes in the taggable layers, while reads continue to use the stack's plain-key path: an L1 hit returns immediately, and an L2 hit backfills upper layers. Flush tagged entries through `tags()`, then read them through the plain cache key: + +```php +Cache::store('stack')->tags(['user:42'])->put('profile:42', $profile, 3600); + +$profile = Cache::store('stack')->get('profile:42'); + +Cache::store('stack')->tags(['user:42'])->flush(); +``` + +A stack supports tags when it has at least one taggable layer, and every layer from the first taggable layer down to the bottom is an any-mode taggable store. Non-taggable microcache layers may sit above that region: + +```php +'auth-stack' => [ + 'driver' => 'stack', + 'stores' => [ + 'swoole' => ['ttl' => 3], + 'redis-any', + ], +], + +'redis-any' => [ + 'driver' => 'redis', + 'connection' => 'cache', + 'tag_mode' => 'any', +], +``` + +Putting a non-taggable layer below the taggable region is rejected because it can resurrect flushed values. For example, a `[redis-any, file]` stack could flush Redis, then miss Redis, read the old value from `file`, and backfill the flushed value into Redis again. + +After a tag flush, non-taggable upper layers may still serve their cached value until that layer's TTL expires. This bounded staleness is the normal microcache tradeoff. Configure node-local upper layers with a short `ttl` override; without one, staleness is bounded by the cached item's own TTL. Use `Cache::store(...)->supportsTags()` to test whether a configured stack currently supports tags. + +Direct stack writes and `Cache::touch()` use plain layer writes. For tagged stack items, write the value through `tags()` again when tag metadata must remain authoritative for a TTL or tag change. + ### Pruning Stale Tag Entries @@ -626,7 +668,7 @@ You may schedule this command to run periodically based on how often tagged cach ## Atomic Locks > [!WARNING] -> To utilize this feature, your application must be using the `redis`, `database`, `file`, or `array` cache driver as your application's default cache driver. For distributed locks, all servers must be communicating with the same central cache server. +> To utilize this feature, your application must be using the `redis`, `database`, `file`, `array`, or `stack` cache driver as your application's default cache driver. Stack locks are delegated to the bottom layer, so the bottom store must support locks. For distributed locks, all servers must be communicating with the same central cache server. ### Managing Locks @@ -765,7 +807,7 @@ You may clear all atomic locks in the cache using the `flushLocks` method: Cache::flushLocks(); ``` -The `flushLocks` method is supported by the `redis`, `database`, `file`, and `array` cache drivers. Redis, database, and file stores only support flushing locks when lock storage is configured separately from regular cache storage. If lock storage is shared with regular cache storage, Hypervel will throw a `RuntimeException`. If a store does not support flushing locks, Hypervel will throw a `BadMethodCallException`. +The `flushLocks` method is supported by the `redis`, `database`, `file`, `array`, and `stack` cache drivers when their current configuration can flush locks. Stack stores delegate lock flushing to the bottom layer and support it only when that bottom layer supports flushing locks. Redis, database, and file stores only support flushing locks when lock storage is configured separately from regular cache storage. If the repository's configured store cannot currently flush locks, Hypervel will throw a `BadMethodCallException`. Direct store-level `flushLocks` calls still throw a `RuntimeException` when lock storage is shared with regular cache storage. > [!WARNING] > The `flushLocks` method removes every lock in the lock store, regardless of which application or process owns the lock. Use it carefully in shared environments. diff --git a/src/boost/docs/jwt.md b/src/boost/docs/jwt.md index 9443e4230..55a072ef4 100644 --- a/src/boost/docs/jwt.md +++ b/src/boost/docs/jwt.md @@ -336,6 +336,10 @@ The blacklist uses the configured storage provider: ], ``` +The default tagged-cache storage requires your default cache store to support tags. Both all-mode and any-mode tagged stores are supported. When using any-mode tags, blacklist entries are written through tags but read and removed by a private plain-key prefix. + +If the blacklist store uses a cache stack or any node-local tier, a revoked token may still validate on another node until that node's local cache entry expires. Keep the upper-tier TTL short, or use a fully shared store such as Redis when revocation must be visible immediately across all nodes. + You may configure a grace period for concurrent requests that are using the same token while a refresh is in progress: ```php diff --git a/src/cache/src/AbstractArrayStore.php b/src/cache/src/AbstractArrayStore.php index 8588af441..6301051a2 100644 --- a/src/cache/src/AbstractArrayStore.php +++ b/src/cache/src/AbstractArrayStore.php @@ -165,6 +165,14 @@ public function flush(): bool return true; } + /** + * Determine if the store can currently flush locks. + */ + public function supportsFlushingLocks(): bool + { + return true; + } + /** * Remove all locks from the store. * diff --git a/src/cache/src/AnyModeTaggedCache.php b/src/cache/src/AnyModeTaggedCache.php new file mode 100644 index 000000000..c8ec605dc --- /dev/null +++ b/src/cache/src/AnyModeTaggedCache.php @@ -0,0 +1,121 @@ +hasSeparateLockStore(); + } + /** * Remove all locks from the store. * diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index 1c4f83a79..42089451d 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -32,7 +32,7 @@ class FileStore implements CanFlushLocks, LockProvider, Store /** * The file cache lock directory. */ - protected ?string $lockDirectory; + protected ?string $lockDirectory = null; /** * Octal representation of the cache file permissions. @@ -224,6 +224,14 @@ public function flush(): bool return true; } + /** + * Determine if the store can currently flush locks. + */ + public function supportsFlushingLocks(): bool + { + return $this->hasSeparateLockStore(); + } + /** * Remove all locks from the store. * diff --git a/src/cache/src/NamespacedTagSet.php b/src/cache/src/NamespacedTagSet.php new file mode 100644 index 000000000..89f0b1580 --- /dev/null +++ b/src/cache/src/NamespacedTagSet.php @@ -0,0 +1,42 @@ +tagIds()); + } + + /** + * Get an array of tag identifiers for all of the tags in the set. + * + * @return array + */ + public function tagIds(): array + { + return array_map([$this, 'tagId'], $this->names); + } + + /** + * Get the unique tag identifier for a given tag. + */ + abstract public function tagId(string $name): string; + + /** + * Get the tag identifier key for a given tag. + */ + abstract public function tagKey(string $name): string; +} diff --git a/src/cache/src/NamespacedTaggedCache.php b/src/cache/src/NamespacedTaggedCache.php new file mode 100644 index 000000000..33124b3f6 --- /dev/null +++ b/src/cache/src/NamespacedTaggedCache.php @@ -0,0 +1,55 @@ +tags->getNamespace()) . ':' . $key; + } + + /** + * Get the tag set instance. + */ + public function getTags(): NamespacedTagSet + { + return $this->tags; + } + + /** + * Format the key for a cache item. + */ + protected function itemKey(string $key): string + { + return $this->taggedItemKey($key); + } +} diff --git a/src/cache/src/Redis/AllTagSet.php b/src/cache/src/Redis/AllTagSet.php index a280ef1bb..25ed1e14d 100644 --- a/src/cache/src/Redis/AllTagSet.php +++ b/src/cache/src/Redis/AllTagSet.php @@ -4,12 +4,12 @@ namespace Hypervel\Cache\Redis; +use Hypervel\Cache\NamespacedTagSet; use Hypervel\Cache\RedisStore; -use Hypervel\Cache\TagSet; use Hypervel\Contracts\Cache\Store; use Hypervel\Support\LazyCollection; -class AllTagSet extends TagSet +class AllTagSet extends NamespacedTagSet { /** * The cache store implementation. @@ -39,11 +39,11 @@ public function entries(): LazyCollection } /** - * Flush the tag from the cache. + * Reset all tags in the set. */ - public function flushTag(string $name): string + public function reset(): void { - return $this->resetTag($name); + array_walk($this->names, [$this, 'resetTag']); } /** @@ -56,6 +56,22 @@ public function resetTag(string $name): string return $this->tagId($name); } + /** + * Flush all the tags in the set. + */ + public function flush(): void + { + array_walk($this->names, [$this, 'flushTag']); + } + + /** + * Flush the tag from the cache. + */ + public function flushTag(string $name): string + { + return $this->resetTag($name); + } + /** * Get the unique tag identifier for a given tag. * diff --git a/src/cache/src/Redis/AllTaggedCache.php b/src/cache/src/Redis/AllTaggedCache.php index c35edbfca..996aa132a 100644 --- a/src/cache/src/Redis/AllTaggedCache.php +++ b/src/cache/src/Redis/AllTaggedCache.php @@ -12,15 +12,16 @@ use Hypervel\Cache\Events\CacheHit; use Hypervel\Cache\Events\CacheMissed; use Hypervel\Cache\Events\KeyWritten; +use Hypervel\Cache\NamespacedTaggedCache; use Hypervel\Cache\NullSentinel; use Hypervel\Cache\RedisStore; -use Hypervel\Cache\TaggedCache; +use Hypervel\Cache\TagSet; use Hypervel\Contracts\Cache\Store; use UnitEnum; use function Hypervel\Support\enum_value; -class AllTaggedCache extends TaggedCache +class AllTaggedCache extends NamespacedTaggedCache { /** * The cache store implementation. @@ -34,7 +35,7 @@ class AllTaggedCache extends TaggedCache * * @var AllTagSet */ - protected \Hypervel\Cache\TagSet $tags; + protected TagSet $tags; /** * The all-mode tagged item key prefix. @@ -131,7 +132,7 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ $seconds = $this->getSeconds($ttl); if ($seconds <= 0) { - return false; + return $this->deleteMultiple(array_map(static fn ($key) => (string) $key, array_keys($values))); } $result = $this->store->allTagOps()->putMany()->execute( @@ -150,6 +151,29 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ return $result; } + /** + * Set the expiration of a cached item; null TTL will retain the item forever. + */ + public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl = null): bool + { + $key = enum_value($key); + $value = $this->getRaw($key); + + if (is_null($value)) { + return false; + } + + if (is_null($ttl)) { + return $this->forever($key, $value); + } + + return $this->store->allTagOps()->touch()->execute( + $this->itemKey($key), + $this->getSeconds($ttl), + $this->tags->tagIds() + ); + } + /** * Increment the value of an item in the cache. */ @@ -236,6 +260,7 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in return $this->rememberForever($key, $callback); } + $key = enum_value($key); $seconds = $this->getSeconds($ttl); if ($seconds <= 0) { @@ -273,6 +298,8 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in */ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed { + $key = enum_value($key); + [$value, $wasHit] = $this->store->allTagOps()->rememberForever()->execute( $this->itemKey($key), $callback, diff --git a/src/cache/src/Redis/AnyTagSet.php b/src/cache/src/Redis/AnyTagSet.php index 8e2cdc0e1..501c3b15e 100644 --- a/src/cache/src/Redis/AnyTagSet.php +++ b/src/cache/src/Redis/AnyTagSet.php @@ -12,14 +12,9 @@ /** * Any-mode tag set for Redis 8.0+ enhanced tagging. * - * Key differences from AllTagSet: - * - Tag IDs are just the tag names (no random UUID versioning) - * - Uses hashes with HSETEX for automatic field expiration - * - Tags track which keys belong to them (for any flush semantics) - * - Flush affects items with ANY of the specified tags (any), not ALL (all) - * - * This class is intentionally simple - it delegates most work to RedisStore - * and the tagged operation classes in Redis/Operations/. + * Tags are identified by their names, and hashes track membership with + * HSETEX field expiration. Flush deletes the actual cache keys written + * with any of the specified tags. */ class AnyTagSet extends TagSet { @@ -38,26 +33,6 @@ public function __construct(RedisStore $store, array $names = []) parent::__construct($store, $names); } - /** - * Get the unique tag identifier for a given tag. - * - * Unlike AllTagSet which uses random UUIDs, any mode - * uses the tag name directly. This means tags don't get "versioned" - * on flush - actual cache keys are deleted instead. - */ - public function tagId(string $name): string - { - return $name; - } - - /** - * Get an array of tag identifiers for all of the tags in the set. - */ - public function tagIds(): array - { - return $this->names; - } - /** * Get the hash key for a tag. * @@ -92,8 +67,8 @@ public function entries(): Generator /** * Reset the tag set. * - * In any mode, this actually deletes the cached items, - * unlike all mode which just changes the tag version. + * In any mode, this deletes the cached items themselves, unlike + * namespaced tag sets where reset only invalidates tag tracking. */ public function reset(): void { @@ -118,40 +93,6 @@ public function flushTag(string $name): string { $this->getRedisStore()->anyTagOps()->flush()->execute([$name]); - return $this->tagKey($name); - } - - /** - * Get a unique namespace that changes when any of the tags are flushed. - * - * Not used in any mode since we don't namespace keys by tags. - * Returns empty string for compatibility with TaggedCache. - */ - public function getNamespace(): string - { - return ''; - } - - /** - * Reset the tag and return the new tag identifier. - * - * In any mode, this flushes the tag and returns the tag name. - * The tag name never changes (unlike all mode's UUIDs). - */ - public function resetTag(string $name): string - { - $this->flushTag($name); - - return $name; - } - - /** - * Get the tag key for a given tag name. - * - * Returns the hash key for the tag (same as tagHashKey). - */ - public function tagKey(string $name): string - { return $this->tagHashKey($name); } diff --git a/src/cache/src/Redis/AnyTaggedCache.php b/src/cache/src/Redis/AnyTaggedCache.php index 6abe37b97..a8a0fecfc 100644 --- a/src/cache/src/Redis/AnyTaggedCache.php +++ b/src/cache/src/Redis/AnyTaggedCache.php @@ -4,19 +4,17 @@ namespace Hypervel\Cache\Redis; -use BadMethodCallException; use Closure; use DateInterval; use DateTimeInterface; use Generator; -use Hypervel\Cache\Events\CacheFlushed; -use Hypervel\Cache\Events\CacheFlushing; +use Hypervel\Cache\AnyModeTaggedCache; use Hypervel\Cache\Events\CacheHit; use Hypervel\Cache\Events\CacheMissed; use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\NullSentinel; use Hypervel\Cache\RedisStore; -use Hypervel\Cache\TaggedCache; +use Hypervel\Cache\TagSet; use Hypervel\Contracts\Cache\Store; use UnitEnum; @@ -25,13 +23,10 @@ /** * Any-mode tagged cache for Redis 8.0+ enhanced tagging. * - * Key differences from AllTaggedCache: - * - Tags are for WRITING and FLUSHING only, not for scoped reads - * - get() throws exception - use Cache::get() directly - * - flush() deletes items with ANY of the specified tags (any semantics) - * - Uses HSETEX for automatic hash field expiration + * Uses Redis hashes with field expiration and single-connection operations + * for tagged writes and remember-style cache misses. */ -class AnyTaggedCache extends TaggedCache +class AnyTaggedCache extends AnyModeTaggedCache { /** * The cache store implementation. @@ -45,7 +40,7 @@ class AnyTaggedCache extends TaggedCache * * @var AnyTagSet */ - protected \Hypervel\Cache\TagSet $tags; + protected TagSet $tags; /** * Create a new tagged cache instance. @@ -57,93 +52,6 @@ public function __construct( parent::__construct($store, $tags); } - /** - * Retrieve an item from the cache by key. - * - * @throws BadMethodCallException Always - tags are for writing/flushing only - */ - public function get(array|UnitEnum|string $key, mixed $default = null): mixed - { - throw new BadMethodCallException( - 'Cannot get items via tags in any mode. Tags are for writing and flushing only. ' - . 'Use Cache::get() directly with the full key.' - ); - } - - /** - * @throws BadMethodCallException Always - tags are for writing/flushing only - */ - public function getRaw(UnitEnum|string $key): mixed - { - throw new BadMethodCallException( - 'Cannot get items via tags in any mode. Tags are for writing and flushing only. ' - . 'Use Cache::get() directly with the full key.' - ); - } - - /** - * Retrieve multiple items from the cache by key. - * - * @throws BadMethodCallException Always - tags are for writing/flushing only - */ - public function many(array $keys): array - { - throw new BadMethodCallException( - 'Cannot get items via tags in any mode. Tags are for writing and flushing only. ' - . 'Use Cache::many() directly with the full keys.' - ); - } - - /** - * @throws BadMethodCallException Always - tags are for writing/flushing only - */ - public function manyRaw(array $keys): array - { - throw new BadMethodCallException( - 'Cannot get items via tags in any mode. Tags are for writing and flushing only. ' - . 'Use Cache::get() directly.' - ); - } - - /** - * Determine if an item exists in the cache. - * - * @throws BadMethodCallException Always - tags are for writing/flushing only - */ - public function has(array|UnitEnum|string $key): bool - { - throw new BadMethodCallException( - 'Cannot check existence via tags in any mode. Tags are for writing and flushing only. ' - . 'Use Cache::has() directly with the full key.' - ); - } - - /** - * Retrieve an item from the cache and delete it. - * - * @throws BadMethodCallException Always - tags are for writing/flushing only - */ - public function pull(UnitEnum|string $key, mixed $default = null): mixed - { - throw new BadMethodCallException( - 'Cannot pull items via tags in any mode. Tags are for writing and flushing only. ' - . 'Use Cache::pull() directly with the full key.' - ); - } - - /** - * Remove an item from the cache. - * - * @throws BadMethodCallException Always - tags are for writing/flushing only - */ - public function forget(UnitEnum|string $key): bool - { - throw new BadMethodCallException( - 'Cannot forget items via tags in any mode. Tags are for writing and flushing only. ' - . 'Use Cache::forget() directly with the full key, or flush() to remove all tagged items.' - ); - } - /** * Store an item in the cache. */ @@ -162,8 +70,7 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT $seconds = $this->getSeconds($ttl); if ($seconds <= 0) { - // Can't forget via tags, just return false - return false; + return $this->store->forget($key); } $result = $this->store->anyTagOps()->put()->execute($key, $value, $seconds, $this->tags->getNames()); @@ -187,7 +94,15 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ $seconds = $this->getSeconds($ttl); if ($seconds <= 0) { - return false; + $result = true; + + foreach (array_keys($values) as $key) { + if (! $this->store->forget((string) $key)) { + $result = false; + } + } + + return $result; } $result = $this->store->anyTagOps()->putMany()->execute($values, $seconds, $this->tags->getNames()); @@ -254,20 +169,6 @@ public function decrement(UnitEnum|string $key, int $value = 1): bool|int return $this->store->anyTagOps()->decrement()->execute(enum_value($key), $value, $this->tags->getNames()); } - /** - * Remove all items from the cache that have any of the specified tags. - */ - public function flush(): bool - { - $this->event(CacheFlushing::class, fn (): CacheFlushing => new CacheFlushing(null)); - - $this->tags->flush(); - - $this->event(CacheFlushed::class, fn (): CacheFlushed => new CacheFlushed(null)); - - return true; - } - /** * Get all items (keys and values) tagged with the current tags. * @@ -297,6 +198,7 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in return $this->rememberForever($key, $callback); } + $key = enum_value($key); $seconds = $this->getSeconds($ttl); if ($seconds <= 0) { @@ -334,6 +236,8 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in */ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed { + $key = enum_value($key); + [$value, $wasHit] = $this->store->anyTagOps()->rememberForever()->execute( $key, $callback, @@ -358,17 +262,6 @@ public function getTags(): AnyTagSet return $this->tags; } - /** - * Format the key for a cache item. - * - * In any mode, keys are NOT namespaced by tags. - * Tags are only for invalidation, not for scoping reads. - */ - protected function itemKey(string $key): string - { - return $key; - } - /** * Store multiple items in the cache indefinitely. */ diff --git a/src/cache/src/Redis/Console/Doctor/Checks/HashFieldExpirationCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/HashFieldExpirationCheck.php new file mode 100644 index 000000000..c256866d9 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/HashFieldExpirationCheck.php @@ -0,0 +1,78 @@ +taggingMode === 'all') { + $result->assert(true, 'Hash-field expiration check skipped (not required for all mode)'); + + return $result; + } + + $testKey = 'erc:doctor:hash-field-expiration-test:' . bin2hex(random_bytes(4)); + + try { + $hsetexResult = $this->redis->hsetex($testKey, ['field' => '1'], ['EX' => 60]); + $hexpireResult = $this->redis->hexpire($testKey, 60, ['field']); + + $this->available = $hsetexResult !== false && $hexpireResult !== false; + $result->assert($this->available, 'HSETEX and HEXPIRE commands are available'); + } catch (Throwable) { + $this->available = false; + $result->assert(false, 'HSETEX and HEXPIRE commands are available'); + } finally { + try { + $this->redis->del($testKey); + } catch (Throwable) { + // The command probe result above is the failure that matters. + } + } + + return $result; + } + + public function getFixInstructions(): ?string + { + if ($this->taggingMode === 'all') { + return null; + } + + if (! $this->available) { + return 'Any tagging mode requires Redis 8.0+ or Valkey 9.0+ for hash-field expiration commands such as HSETEX and HEXPIRE. Upgrade your Redis/Valkey server, or switch to all tagging mode.'; + } + + return null; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/HexpireCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/HexpireCheck.php deleted file mode 100644 index 2da34b086..000000000 --- a/src/cache/src/Redis/Console/Doctor/Checks/HexpireCheck.php +++ /dev/null @@ -1,75 +0,0 @@ -taggingMode === 'all') { - $result->assert(true, 'HEXPIRE check skipped (not required for all mode)'); - - return $result; - } - - try { - // Try to use HEXPIRE on a test key - $testKey = 'erc:doctor:hexpire-test:' . bin2hex(random_bytes(4)); - - $this->redis->hSet($testKey, 'field', '1'); - $this->redis->hexpire($testKey, 60, ['field']); - $this->redis->del($testKey); - - $this->available = true; - $result->assert(true, 'HEXPIRE command is available'); - } catch (Throwable) { - $this->available = false; - $result->assert(false, 'HEXPIRE command is available'); - } - - return $result; - } - - public function getFixInstructions(): ?string - { - if ($this->taggingMode === 'all') { - return null; - } - - if (! $this->available) { - return 'HEXPIRE requires Redis 8.0+ or Valkey 9.0+. Upgrade your Redis/Valkey server, or switch to all tagging mode.'; - } - - return null; - } -} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php index d8820956e..40769cd2f 100644 --- a/src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php +++ b/src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php @@ -11,7 +11,8 @@ /** * Checks that Redis/Valkey version meets requirements. * - * For any mode: Requires Redis 8.0+ or Valkey 9.0+ for HEXPIRE support. + * For any mode: Requires Redis 8.0+ or Valkey 9.0+ for hash-field + * expiration commands including HSETEX and HEXPIRE. * For all mode: Any version is acceptable (just verifies connection). */ final class RedisVersionCheck implements EnvironmentCheckInterface diff --git a/src/cache/src/Redis/Console/Doctor/DoctorContext.php b/src/cache/src/Redis/Console/Doctor/DoctorContext.php index 71b4bc289..5d95212a4 100644 --- a/src/cache/src/Redis/Console/Doctor/DoctorContext.php +++ b/src/cache/src/Redis/Console/Doctor/DoctorContext.php @@ -4,11 +4,13 @@ namespace Hypervel\Cache\Redis\Console\Doctor; +use Hypervel\Cache\NamespacedTaggedCache; use Hypervel\Cache\Redis\Support\TagKeyBuilder; use Hypervel\Cache\RedisStore; use Hypervel\Cache\Repository; use Hypervel\Cache\TagMode; use Hypervel\Redis\RedisConnection; +use LogicException; /** * Context object holding shared state for Doctor checks. @@ -67,9 +69,13 @@ public function tagHashKey(string $tag): string */ public function namespacedKey(array $tags, string $key): string { - $namespace = hash('xxh128', $this->cache->tags($tags)->getTags()->getNamespace()); + $taggedCache = $this->cache->tags($tags); - return $namespace . ':' . $key; + if (! $taggedCache instanceof NamespacedTaggedCache) { + throw new LogicException('Namespaced keys are only available for namespaced tagged caches.'); + } + + return $taggedCache->taggedItemKey($key); } /** diff --git a/src/cache/src/Redis/Console/DoctorCommand.php b/src/cache/src/Redis/Console/DoctorCommand.php index 2e61d3b3c..5360e2baf 100644 --- a/src/cache/src/Redis/Console/DoctorCommand.php +++ b/src/cache/src/Redis/Console/DoctorCommand.php @@ -19,8 +19,8 @@ use Hypervel\Cache\Redis\Console\Doctor\Checks\ExpirationCheck; use Hypervel\Cache\Redis\Console\Doctor\Checks\FlushBehaviorCheck; use Hypervel\Cache\Redis\Console\Doctor\Checks\ForeverStorageCheck; +use Hypervel\Cache\Redis\Console\Doctor\Checks\HashFieldExpirationCheck; use Hypervel\Cache\Redis\Console\Doctor\Checks\HashStructuresCheck; -use Hypervel\Cache\Redis\Console\Doctor\Checks\HexpireCheck; use Hypervel\Cache\Redis\Console\Doctor\Checks\IncrementDecrementCheck; use Hypervel\Cache\Redis\Console\Doctor\Checks\LargeDatasetCheck; use Hypervel\Cache\Redis\Console\Doctor\Checks\MemoryLeakPreventionCheck; @@ -155,7 +155,7 @@ protected function getEnvironmentChecks(string $storeName, RedisStore $store, st return [ new PhpRedisCheck, new RedisVersionCheck($redis, $tagMode), - new HexpireCheck($redis, $tagMode), + new HashFieldExpirationCheck($redis, $tagMode), new CacheStoreCheck($storeName, 'redis', $tagMode), ]; } diff --git a/src/cache/src/Redis/Operations/AllTag/Touch.php b/src/cache/src/Redis/Operations/AllTag/Touch.php new file mode 100644 index 000000000..2a5d3b129 --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/Touch.php @@ -0,0 +1,118 @@ + $tagIds Array of tag identifiers + */ + public function execute(string $key, int $seconds, array $tagIds): bool + { + if ($this->context->isCluster()) { + return $this->executeCluster($key, $seconds, $tagIds); + } + + return $this->executeUsingLua($key, $seconds, $tagIds); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(string $key, int $seconds, array $tagIds): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds, $tagIds) { + $prefix = $this->context->prefix(); + $seconds = max(1, $seconds); + + if (! $connection->expire($prefix . $key, $seconds)) { + return false; + } + + $score = now()->addSeconds($seconds)->getTimestamp(); + + foreach ($tagIds as $tagId) { + $connection->zadd($prefix . $tagId, $score, $key); + } + + return true; + }); + } + + /** + * Execute using Lua script for performance. + */ + private function executeUsingLua(string $key, int $seconds, array $tagIds): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds, $tagIds) { + $seconds = max(1, $seconds); + + // Static tag ZSET keys belong in KEYS so phpredis applies + // OPT_PREFIX; ARGV-built keys are only for dynamic Lua paths. + $keys = [ + $this->context->prefix() . $key, + ...array_map(fn (string $tagId) => $this->context->prefix() . $tagId, $tagIds), + ]; + + $args = [ + $seconds, + now()->addSeconds($seconds)->getTimestamp(), + $key, + ]; + + return (bool) $connection->evalWithShaCache($this->touchTaggedScript(), $keys, $args); + }); + } + + /** + * Get the Lua script for touching a tagged value and its ZSET scores. + * + * KEYS[1] - The cache key (prefixed, namespaced) + * KEYS[2...] - Prefixed tag ZSET keys + * ARGV[1] - TTL in seconds + * ARGV[2] - New expiry score + * ARGV[3] - Raw namespaced key (ZSET member) + */ + protected function touchTaggedScript(): string + { + return <<<'LUA' + local key = KEYS[1] + local ttl = tonumber(ARGV[1]) + local score = tonumber(ARGV[2]) + local member = ARGV[3] + + if redis.call('EXPIRE', key, ttl) == 0 then + return false + end + + for i = 2, #KEYS do + redis.call('ZADD', KEYS[i], score, member) + end + + return true + LUA; + } +} diff --git a/src/cache/src/Redis/Operations/AllTagOperations.php b/src/cache/src/Redis/Operations/AllTagOperations.php index 7f352f6ce..935a4ec7b 100644 --- a/src/cache/src/Redis/Operations/AllTagOperations.php +++ b/src/cache/src/Redis/Operations/AllTagOperations.php @@ -17,6 +17,7 @@ use Hypervel\Cache\Redis\Operations\AllTag\PutMany; use Hypervel\Cache\Redis\Operations\AllTag\Remember; use Hypervel\Cache\Redis\Operations\AllTag\RememberForever; +use Hypervel\Cache\Redis\Operations\AllTag\Touch; use Hypervel\Cache\Redis\Support\Serialization; use Hypervel\Cache\Redis\Support\StoreContext; @@ -39,6 +40,8 @@ class AllTagOperations private ?Forever $forever = null; + private ?Touch $touch = null; + private ?Increment $increment = null; private ?Decrement $decrement = null; @@ -96,6 +99,14 @@ public function forever(): Forever return $this->forever ??= new Forever($this->context, $this->serialization); } + /** + * Get the Touch operation for adjusting tagged item expiration. + */ + public function touch(): Touch + { + return $this->touch ??= new Touch($this->context); + } + /** * Get the Increment operation for incrementing values with tag tracking. */ @@ -193,6 +204,7 @@ public function clear(): void $this->putMany = null; $this->add = null; $this->forever = null; + $this->touch = null; $this->increment = null; $this->decrement = null; $this->addEntry = null; diff --git a/src/cache/src/Redis/Operations/AnyTag/Forget.php b/src/cache/src/Redis/Operations/AnyTag/Forget.php new file mode 100644 index 000000000..9a8375dc8 --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/Forget.php @@ -0,0 +1,114 @@ +context->isCluster()) { + return $this->executeCluster($key); + } + + return $this->executeUsingLua($key); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(string $key): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key) { + $tagsKey = $this->context->reverseIndexKey($key); + $tags = $connection->smembers($tagsKey); + + foreach ($tags as $tag) { + $connection->hdel($this->context->tagHashKey((string) $tag), $key); + } + + if (! empty($tags)) { + $connection->del($tagsKey); + } + + return (bool) $connection->del($this->context->prefix() . $key); + }); + } + + /** + * Execute using Lua script for performance. + */ + private function executeUsingLua(string $key): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key) { + $keys = [ + $this->context->prefix() . $key, + $this->context->reverseIndexKey($key), + ]; + + $args = [ + $this->context->fullTagPrefix(), + $key, + $this->context->tagHashSuffix(), + ]; + + return (bool) $connection->evalWithShaCache($this->forgetWithTagsScript(), $keys, $args); + }); + } + + /** + * Get the Lua script for deleting a value and its tag membership. + * + * KEYS[1] - The cache key (prefixed) + * KEYS[2] - The reverse index key + * ARGV[1] - Tag prefix for building tag hash keys + * ARGV[2] - Raw key (without prefix, for hash field name) + * ARGV[3] - Tag hash suffix (":entries") + */ + protected function forgetWithTagsScript(): string + { + return <<<'LUA' + local key = KEYS[1] + local tagsKey = KEYS[2] + local tagPrefix = ARGV[1] + local rawKey = ARGV[2] + local tagHashSuffix = ARGV[3] + + local tags = redis.call('SMEMBERS', tagsKey) + + for _, tag in ipairs(tags) do + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HDEL', tagHash, rawKey) + end + + if #tags > 0 then + redis.call('DEL', tagsKey) + end + + return redis.call('DEL', key) + LUA; + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/Touch.php b/src/cache/src/Redis/Operations/AnyTag/Touch.php new file mode 100644 index 000000000..2d63444ae --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/Touch.php @@ -0,0 +1,150 @@ +context->isCluster()) { + return $this->executeCluster($key, $seconds); + } + + return $this->executeUsingLua($key, $seconds); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(string $key, int $seconds): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds) { + $seconds = max(1, $seconds); + + if (! $connection->expire($this->context->prefix() . $key, $seconds)) { + return false; + } + + $tagsKey = $this->context->reverseIndexKey($key); + $tags = $connection->smembers($tagsKey); + + if (empty($tags)) { + return true; + } + + $connection->expire($tagsKey, $seconds); + + foreach ($tags as $tag) { + $connection->hexpire($this->context->tagHashKey((string) $tag), $seconds, [$key]); + } + + $expiry = time() + $seconds; + $zaddArgs = []; + + foreach ($tags as $tag) { + $zaddArgs[] = $expiry; + $zaddArgs[] = (string) $tag; + } + + $connection->zadd($this->context->registryKey(), ['GT'], ...$zaddArgs); + + return true; + }); + } + + /** + * Execute using Lua script for performance. + */ + private function executeUsingLua(string $key, int $seconds): bool + { + return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds) { + $keys = [ + $this->context->prefix() . $key, + $this->context->reverseIndexKey($key), + ]; + + $args = [ + max(1, $seconds), + $this->context->fullTagPrefix(), + $this->context->fullRegistryKey(), + time(), + $key, + $this->context->tagHashSuffix(), + ]; + + return (bool) $connection->evalWithShaCache($this->touchWithTagsScript(), $keys, $args); + }); + } + + /** + * Get the Lua script for touching a value and its tag metadata. + * + * KEYS[1] - The cache key (prefixed) + * KEYS[2] - The reverse index key + * ARGV[1] - TTL in seconds + * ARGV[2] - Tag prefix for building tag hash keys + * ARGV[3] - Tag registry key + * ARGV[4] - Current timestamp + * ARGV[5] - Raw key (without prefix, for hash field name) + * ARGV[6] - Tag hash suffix (":entries") + */ + protected function touchWithTagsScript(): string + { + return <<<'LUA' + local key = KEYS[1] + local tagsKey = KEYS[2] + local ttl = tonumber(ARGV[1]) + local tagPrefix = ARGV[2] + local registryKey = ARGV[3] + local now = tonumber(ARGV[4]) + local rawKey = ARGV[5] + local tagHashSuffix = ARGV[6] + + if redis.call('EXPIRE', key, ttl) == 0 then + return false + end + + local tags = redis.call('SMEMBERS', tagsKey) + + if #tags == 0 then + return true + end + + redis.call('EXPIRE', tagsKey, ttl) + + local expiry = now + ttl + + for _, tag in ipairs(tags) do + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HEXPIRE', tagHash, ttl, 'FIELDS', 1, rawKey) + redis.call('ZADD', registryKey, 'GT', expiry, tag) + end + + return true + LUA; + } +} diff --git a/src/cache/src/Redis/Operations/AnyTagOperations.php b/src/cache/src/Redis/Operations/AnyTagOperations.php index ab105058a..94075aef0 100644 --- a/src/cache/src/Redis/Operations/AnyTagOperations.php +++ b/src/cache/src/Redis/Operations/AnyTagOperations.php @@ -8,6 +8,7 @@ use Hypervel\Cache\Redis\Operations\AnyTag\Decrement; use Hypervel\Cache\Redis\Operations\AnyTag\Flush; use Hypervel\Cache\Redis\Operations\AnyTag\Forever; +use Hypervel\Cache\Redis\Operations\AnyTag\Forget; use Hypervel\Cache\Redis\Operations\AnyTag\GetTaggedKeys; use Hypervel\Cache\Redis\Operations\AnyTag\GetTagItems; use Hypervel\Cache\Redis\Operations\AnyTag\Increment; @@ -16,6 +17,7 @@ use Hypervel\Cache\Redis\Operations\AnyTag\PutMany; use Hypervel\Cache\Redis\Operations\AnyTag\Remember; use Hypervel\Cache\Redis\Operations\AnyTag\RememberForever; +use Hypervel\Cache\Redis\Operations\AnyTag\Touch; use Hypervel\Cache\Redis\Support\Serialization; use Hypervel\Cache\Redis\Support\StoreContext; @@ -37,6 +39,10 @@ class AnyTagOperations private ?Forever $forever = null; + private ?Touch $touch = null; + + private ?Forget $forget = null; + private ?Increment $increment = null; private ?Decrement $decrement = null; @@ -91,6 +97,22 @@ public function forever(): Forever return $this->forever ??= new Forever($this->context, $this->serialization); } + /** + * Get the Touch operation for adjusting item expiration and tag metadata. + */ + public function touch(): Touch + { + return $this->touch ??= new Touch($this->context); + } + + /** + * Get the Forget operation for deleting items and tag metadata. + */ + public function forget(): Forget + { + return $this->forget ??= new Forget($this->context); + } + /** * Get the Increment operation for incrementing values with tags. */ @@ -179,6 +201,8 @@ public function clear(): void $this->putMany = null; $this->add = null; $this->forever = null; + $this->touch = null; + $this->forget = null; $this->increment = null; $this->decrement = null; $this->getTaggedKeys = null; diff --git a/src/cache/src/Redis/Operations/Touch.php b/src/cache/src/Redis/Operations/Touch.php new file mode 100644 index 000000000..776dccbc5 --- /dev/null +++ b/src/cache/src/Redis/Operations/Touch.php @@ -0,0 +1,35 @@ +context->withConnection( + fn (RedisConnection $connection) => (bool) $connection->expire( + $this->context->prefix() . $key, + max(1, $seconds) + ) + ); + } +} diff --git a/src/cache/src/RedisStore.php b/src/cache/src/RedisStore.php index 8dd403567..ea21a5ae4 100644 --- a/src/cache/src/RedisStore.php +++ b/src/cache/src/RedisStore.php @@ -24,6 +24,7 @@ use Hypervel\Cache\Redis\Operations\PutMany; use Hypervel\Cache\Redis\Operations\Remember; use Hypervel\Cache\Redis\Operations\RememberForever; +use Hypervel\Cache\Redis\Operations\Touch; use Hypervel\Cache\Redis\Support\Serialization; use Hypervel\Cache\Redis\Support\StoreContext; use Hypervel\Container\Container; @@ -31,7 +32,6 @@ use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Redis\Factory as Redis; use Hypervel\Redis\Pool\PoolFactory; -use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use RuntimeException; @@ -91,6 +91,8 @@ class RedisStore extends TaggableStore implements CanFlushLocks, LockProvider private ?Forget $forgetOperation = null; + private ?Touch $touchOperation = null; + private ?Increment $incrementOperation = null; private ?Decrement $decrementOperation = null; @@ -216,12 +218,11 @@ public function restoreLock(string $name, string $owner): RedisLock */ public function touch(string $key, int $seconds): bool { - return $this->getContext()->withConnection( - fn (RedisConnection $connection) => (bool) $connection->expire( - $this->prefix . $key, - (int) max(1, $seconds) - ) - ); + if ($this->tagMode === TagMode::Any) { + return $this->anyTagOps()->touch()->execute($key, $seconds); + } + + return $this->getTouchOperation()->execute($key, $seconds); } /** @@ -229,6 +230,10 @@ public function touch(string $key, int $seconds): bool */ public function forget(string $key): bool { + if ($this->tagMode === TagMode::Any) { + return $this->anyTagOps()->forget()->execute($key); + } + return $this->getForgetOperation()->execute($key); } @@ -240,6 +245,14 @@ public function flush(): bool return $this->getFlushOperation()->execute(); } + /** + * Determine if the store can currently flush locks. + */ + public function supportsFlushingLocks(): bool + { + return $this->hasSeparateLockStore(); + } + /** * Remove all locks from the store. * @@ -549,6 +562,7 @@ private function clearCachedInstances(): void $this->addOperation = null; $this->foreverOperation = null; $this->forgetOperation = null; + $this->touchOperation = null; $this->incrementOperation = null; $this->decrementOperation = null; $this->flushOperation = null; @@ -613,6 +627,11 @@ private function getForgetOperation(): Forget return $this->forgetOperation ??= new Forget($this->getContext()); } + private function getTouchOperation(): Touch + { + return $this->touchOperation ??= new Touch($this->getContext()); + } + private function getIncrementOperation(): Increment { return $this->incrementOperation ??= new Increment($this->getContext()); diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 0b2b7b26e..6cd9aa152 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -26,6 +26,7 @@ use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\Events\WritingKey; use Hypervel\Cache\Events\WritingManyKeys; +use Hypervel\Cache\Exceptions\NotSupportedException; use Hypervel\Cache\Limiters\ConcurrencyLimiterBuilder; use Hypervel\Contracts\Cache\CanFlushLocks; use Hypervel\Contracts\Cache\LockProvider; @@ -668,7 +669,8 @@ public function flexibleNullable(UnitEnum|string $key, array $ttl, mixed $callba */ public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl = null): bool { - $value = $this->get($key); + $key = enum_value($key); + $value = $this->getRaw($key); if (is_null($value)) { return false; @@ -772,13 +774,13 @@ public function flushLocks(): bool { $store = $this->getStore(); - if (! $this->supportsFlushingLocks()) { + if (! $store instanceof CanFlushLocks || ! $store->supportsFlushingLocks()) { throw new BadMethodCallException('This cache store does not support flushing locks.'); } $this->event(CacheLocksFlushing::class, fn (): CacheLocksFlushing => new CacheLocksFlushing($this->getName())); - $result = $store->flushLocks(); // @phpstan-ignore method.notFound (flushLocks() is on CanFlushLocks, verified by supportsFlushingLocks() above) + $result = $store->flushLocks(); if ($result) { $this->event( @@ -799,18 +801,20 @@ public function flushLocks(): bool * Begin executing a new tags operation if the store supports it. * * @throws BadMethodCallException + * @throws NotSupportedException */ public function tags(mixed $names): TaggedCache { - if (! $this->supportsTags()) { + $store = $this->store; + + if (! $store instanceof TaggableStore) { throw new BadMethodCallException('This cache store does not support tagging.'); } $names = is_array($names) ? $names : func_get_args(); $names = array_map(fn ($name) => enum_value($name), $names); - /* @phpstan-ignore-next-line */ - $cache = $this->store->tags($names); + $cache = $store->tags($names); $cache->config = $this->config; @@ -826,7 +830,7 @@ public function tags(mixed $names): TaggedCache */ public function supportsTags(): bool { - return method_exists($this->store, 'tags'); + return $this->store instanceof TaggableStore && $this->store->supportsTags(); } /** @@ -834,7 +838,7 @@ public function supportsTags(): bool */ public function supportsFlushingLocks(): bool { - return $this->store instanceof CanFlushLocks; + return $this->store instanceof CanFlushLocks && $this->store->supportsFlushingLocks(); } /** diff --git a/src/cache/src/StackStore.php b/src/cache/src/StackStore.php index 150e833b4..12d207e03 100644 --- a/src/cache/src/StackStore.php +++ b/src/cache/src/StackStore.php @@ -5,16 +5,44 @@ namespace Hypervel\Cache; use Closure; +use Hypervel\Cache\Exceptions\NotSupportedException; +use Hypervel\Contracts\Cache\CanFlushLocks; +use Hypervel\Contracts\Cache\Lock; +use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Cache\Store; use Hypervel\Support\Carbon; +use InvalidArgumentException; -class StackStore implements Store +class StackStore extends TaggableStore implements CanFlushLocks, LockProvider { /** - * @param Store[] $stores + * @var StackStoreProxy[] */ - public function __construct(protected array $stores) + protected array $stores; + + /** + * Memoized tag-composition validation error. + * + * false means validation has not run, null means the composition is valid, + * and a string contains the validation error for an invalid composition. + */ + protected false|string|null $tagCompositionError = false; + + /** + * @param array $stores + * + * @throws InvalidArgumentException when no layers are given + */ + public function __construct(array $stores) { + if ($stores === []) { + throw new InvalidArgumentException('A cache stack requires at least one store layer.'); + } + + $this->stores = array_map( + static fn (Store $store) => $store instanceof StackStoreProxy ? $store : new StackStoreProxy($store), + $stores + ); } public function get(string $key): mixed @@ -78,8 +106,8 @@ public function forever(string $key, mixed $value): bool $record = compact('value'); return $this->callStores( - fn (Store $store) => $store->forever($key, $record), - fn (Store $store) => $store->forget($key), + fn (StackStoreProxy $store) => $store->forever($key, $record), + fn (StackStoreProxy $store) => $store->forget($key), ); } @@ -103,7 +131,7 @@ public function touch(string $key, int $seconds): bool public function forget(string $key): bool { return $this->callStores( - fn (Store $store) => $store->forget($key), + fn (StackStoreProxy $store) => $store->forget($key), force: true ); } @@ -111,20 +139,137 @@ public function forget(string $key): bool public function flush(): bool { return $this->callStores( - static fn (Store $store) => $store->flush(), + static fn (StackStoreProxy $store) => $store->flush(), force: true ); } + /** + * Get a lock instance. + * + * Locks are delegated to the bottom layer and never touch the cache tiers. + * + * @throws NotSupportedException when the bottom layer is not a lock provider + */ + public function lock(string $name, int $seconds = 0, ?string $owner = null): Lock + { + return $this->bottomLockProvider()->lock($name, $seconds, $owner); + } + + /** + * Restore a lock instance using the owner identifier. + * + * @throws NotSupportedException when the bottom layer is not a lock provider + */ + public function restoreLock(string $name, string $owner): Lock + { + return $this->bottomLockProvider()->restoreLock($name, $owner); + } + + /** + * Determine if the store can currently flush locks. + */ + public function supportsFlushingLocks(): bool + { + $store = $this->bottomStore(); + + return $store instanceof CanFlushLocks && $store->supportsFlushingLocks(); + } + + /** + * Flush all locks managed by the store. + * + * @throws NotSupportedException when the bottom layer cannot flush locks + */ + public function flushLocks(): bool + { + $store = $this->bottomStore(); + + if (! $store instanceof CanFlushLocks || ! $store->supportsFlushingLocks()) { + throw new NotSupportedException(sprintf( + 'The stack\'s bottom layer [%s] does not support flushing locks.', + $store::class + )); + } + + return $store->flushLocks(); + } + + /** + * Determine if the lock store is separate from the cache store. + */ + public function hasSeparateLockStore(): bool + { + $store = $this->bottomStore(); + + return $store instanceof CanFlushLocks && $store->hasSeparateLockStore(); + } + + /** + * Begin executing a new tags operation. + * + * @throws NotSupportedException when the layer composition cannot support tags + */ + public function tags(mixed $names): TaggedCache + { + $this->ensureTagCompositionIsValid(); + + return new StackTaggedCache($this, new StackTagSet($this, is_array($names) ? $names : func_get_args())); + } + + /** + * Determine if this store currently supports tags. + */ + public function supportsTags(): bool + { + return is_null($this->tagCompositionError()); + } + + /** + * Get the tag mode this store operates under. + * + * @throws NotSupportedException when the layer composition cannot support tags + */ + public function getTagMode(): TagMode + { + $this->ensureTagCompositionIsValid(); + + return TagMode::Any; + } + public function getPrefix(): string { return ''; } + /** + * Get the underlying taggable layer stores, top to bottom. + * + * @return array + * + * @throws NotSupportedException when the layer composition cannot support tags + */ + public function taggableLayers(): array + { + $this->ensureTagCompositionIsValid(); + + $layers = []; + + foreach ($this->stores as $proxy) { + $store = $proxy->getStore(); + + if ($store instanceof TaggableStore) { + $layers[] = $store; + } + } + + return $layers; + } + protected function getOrRestoreRecord(string $key): mixed { return $this->callStoresStacked( - function (Store $store, Closure $next) use ($key): ?array { + function (StackStoreProxy $store, Closure $next) use ($key): ?array { if (! is_null($record = $store->get($key))) { return (array) $record; } @@ -146,12 +291,25 @@ function (Store $store, Closure $next) use ($key): ?array { protected function putRecord(string $key, array $record): bool { return $this->callStores( - fn (Store $store) => $this->putToStore($store, $key, $record), - fn (Store $store) => $store->forget($key), + fn (StackStoreProxy $store) => $this->putToStore($store, $key, $record), + fn (StackStoreProxy $store) => $store->forget($key), ); } - protected function putToStore(Store $store, string $key, array $record): bool + /** + * Store a record in all layers, indexing it under the given tags in the taggable layers. + * + * @param array $tags + */ + public function putRecordTagged(array $tags, string $key, array $record): bool + { + return $this->callStores( + fn (StackStoreProxy $store) => $this->putToStoreTagged($store, $tags, $key, $record), + fn (StackStoreProxy $store) => $store->forget($key), + ); + } + + protected function putToStore(StackStoreProxy $store, string $key, array $record): bool { if (! array_key_exists('value', $record)) { return false; @@ -170,6 +328,161 @@ protected function putToStore(Store $store, string $key, array $record): bool return $store->put($key, $normalizedRecord, $ttl); } + /** + * Store a record in a single layer via the layer's tagged write path when possible. + * + * Tagged writes bypass StackStoreProxy, so this mirrors putToStore()'s TTL clamp. + * + * @param array $tags + */ + protected function putToStoreTagged(StackStoreProxy $proxy, array $tags, string $key, array $record): bool + { + $store = $proxy->getStore(); + + if (! $store instanceof TaggableStore) { + return $this->putToStore($proxy, $key, $record); + } + + if (! array_key_exists('value', $record)) { + return false; + } + + if (! array_key_exists('expiration', $record) && ! array_key_exists('ttl', $record)) { + if (is_null($proxyTtl = $proxy->getTtl())) { + return $store->tags($tags)->forever($key, $record); + } + + return $store->tags($tags)->put($key, $record, $proxyTtl); + } + + $currentTimestamp = Carbon::now()->getTimestamp(); + $value = $record['value']; + $expiration = $record['expiration'] ?? $currentTimestamp + $record['ttl']; + $ttl = $record['ttl'] ?? $record['expiration'] - $currentTimestamp; + $normalizedRecord = compact('value', 'expiration'); + + $proxyTtl = $proxy->getTtl(); + $effectiveTtl = is_null($proxyTtl) || $ttl < $proxyTtl ? $ttl : $proxyTtl; + + return $store->tags($tags)->put($key, $normalizedRecord, $effectiveTtl); + } + + /** + * Increment a record's value, indexing the write under the given tags. + * + * @param array $tags + */ + public function incrementTagged(array $tags, string $key, int $value = 1): bool|int + { + $record = $this->getOrRestoreRecord($key); + + if (is_null($record)) { + return tap($value, fn ($value) => $this->putRecordTagged($tags, $key, ['value' => $value])); + } + + $newValue = $record['value'] + $value; + $newRecord = ['value' => $newValue] + $record; + + if ($this->putRecordTagged($tags, $key, $newRecord)) { + return $newValue; + } + + return false; + } + + /** + * Ensure the layer composition can support stack tags. + * + * @throws NotSupportedException when the layer composition cannot support tags + */ + protected function ensureTagCompositionIsValid(): void + { + if (! is_null($error = $this->tagCompositionError())) { + throw new NotSupportedException($error); + } + } + + protected function tagCompositionError(): ?string + { + if ($this->tagCompositionError === false) { + $this->tagCompositionError = $this->validateTagComposition(); + } + + return $this->tagCompositionError; + } + + /** + * Validate the layer composition for tag support. + * + * A non-taggable or all-mode layer below the taggable region would + * resurrect flushed values on read-through, silently undoing invalidation. + */ + protected function validateTagComposition(): ?string + { + $firstTaggable = null; + + foreach ($this->stores as $index => $proxy) { + $store = $proxy->getStore(); + + if ($firstTaggable === null) { + if ($store instanceof TaggableStore) { + $firstTaggable = $index; + } else { + continue; + } + } + + if (! $store instanceof TaggableStore) { + return sprintf( + 'Stack layer %d [%s] does not support tags. Layers below the first taggable layer must all be any-mode taggable stores.', + $index, + $store::class + ); + } + + if (! $store->supportsTags() || $store->getTagMode() !== TagMode::Any) { + return sprintf( + 'Stack layer %d [%s] must be a taggable store in any mode to participate in stack tags.', + $index, + $store::class + ); + } + } + + if ($firstTaggable === null) { + return 'The stack has no taggable layer; stack tags require at least one any-mode taggable store.'; + } + + return null; + } + + /** + * Get the bottom layer's underlying store. + */ + protected function bottomStore(): Store + { + return $this->stores[array_key_last($this->stores)]->getStore(); + } + + /** + * Get the bottom layer's store as a lock provider. + * + * @throws NotSupportedException when the bottom layer is not a lock provider + */ + protected function bottomLockProvider(): LockProvider + { + $store = $this->bottomStore(); + + if (! $store instanceof LockProvider) { + throw new NotSupportedException(sprintf( + 'The stack\'s bottom layer [%s] does not support locks. Use a lock-capable store as the bottom layer.', + $store::class + )); + } + + return $store; + } + protected function callStoresStacked(Closure $handler, Closure $bottomLayer): mixed { return array_reduce(array_reverse($this->stores), function ($stack, $store) use ($handler) { @@ -182,7 +495,7 @@ protected function callStoresStacked(Closure $handler, Closure $bottomLayer): mi protected function callStores(Closure $handler, ?Closure $rollback = null, bool $force = false): bool { return $this->callStoresStacked( - function (Store $store, Closure $next) use ($handler, $rollback, $force): bool { + function (StackStoreProxy $store, Closure $next) use ($handler, $rollback, $force): bool { if (! $handler($store) && ! $force) { return false; } diff --git a/src/cache/src/StackStoreProxy.php b/src/cache/src/StackStoreProxy.php index c872c5ddb..45cff703e 100644 --- a/src/cache/src/StackStoreProxy.php +++ b/src/cache/src/StackStoreProxy.php @@ -13,6 +13,22 @@ public function __construct(protected Store $store, protected ?int $ttl = null) { } + /** + * Get the wrapped store. + */ + public function getStore(): Store + { + return $this->store; + } + + /** + * Get the layer TTL override in seconds, if configured. + */ + public function getTtl(): ?int + { + return $this->ttl; + } + public function get(string $key): mixed { return $this->call(__FUNCTION__, func_get_args()); diff --git a/src/cache/src/StackTagSet.php b/src/cache/src/StackTagSet.php new file mode 100644 index 000000000..c72d9e108 --- /dev/null +++ b/src/cache/src/StackTagSet.php @@ -0,0 +1,51 @@ +flush(); + } + + /** + * Flush all the tags in the set. + */ + public function flush(): void + { + foreach ($this->store->taggableLayers() as $layer) { + // Flush via tag sets so the stack-level tagged cache emits events once. + $layer->tags($this->names)->getTags()->flush(); + } + } +} diff --git a/src/cache/src/StackTaggedCache.php b/src/cache/src/StackTaggedCache.php new file mode 100644 index 000000000..06c958d33 --- /dev/null +++ b/src/cache/src/StackTaggedCache.php @@ -0,0 +1,219 @@ +putMany($key, $value); + } + + $key = enum_value($key); + + if ($ttl === null) { + return $this->forever($key, $value); + } + + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { + return $this->store->forget($key); + } + + $result = $this->store->putRecordTagged($this->tags->getNames(), $key, [ + 'value' => $value, + 'ttl' => $seconds, + ]); + + if ($result) { + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds)); + } + + return $result; + } + + /** + * Store an item in the cache if the key does not exist. + */ + public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInterface|int|null $ttl = null): bool + { + $key = enum_value($key); + + if (! is_null($this->store->get($key))) { + return false; + } + + return $this->put($key, $value, $ttl); + } + + /** + * Store an item in the cache indefinitely. + */ + public function forever(UnitEnum|string $key, mixed $value): bool + { + $key = enum_value($key); + + $result = $this->store->putRecordTagged($this->tags->getNames(), $key, ['value' => $value]); + + if ($result) { + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value))); + } + + return $result; + } + + /** + * Increment the value of an item in the cache. + */ + public function increment(UnitEnum|string $key, int $value = 1): bool|int + { + return $this->store->incrementTagged($this->tags->getNames(), enum_value($key), $value); + } + + /** + * Decrement the value of an item in the cache. + */ + public function decrement(UnitEnum|string $key, int $value = 1): bool|int + { + return $this->increment($key, $value * -1); + } + + /** + * Get an item from the cache, or execute the given Closure and store the result. + * + * Reads plain through the stack and writes through the tagged path on a miss. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * @return TCacheValue + */ + public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed + { + if ($ttl === null) { + return $this->rememberForever($key, $callback); + } + + $key = enum_value($key); + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { + return $callback(); + } + + $value = $this->store->get($key); + + if (! is_null($value)) { + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value))); + + return NullSentinel::unwrap($value); + } + + $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key)); + + $value = $callback(); + + $this->put($key, $value, $seconds); + + return NullSentinel::unwrap($value); + } + + /** + * Get an item from the cache, or execute the given Closure and store the result forever. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * @return TCacheValue + */ + public function rememberForever(UnitEnum|string $key, Closure $callback): mixed + { + $key = enum_value($key); + $value = $this->store->get($key); + + if (! is_null($value)) { + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value))); + + return NullSentinel::unwrap($value); + } + + $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key)); + + $value = $callback(); + + $this->forever($key, $value); + + return NullSentinel::unwrap($value); + } + + /** + * Get the tag set instance (covariant return type). + */ + public function getTags(): StackTagSet + { + return $this->tags; + } + + /** + * Store multiple items in the cache indefinitely. + */ + protected function putManyForever(array $values): bool + { + $result = true; + + foreach ($values as $key => $value) { + if (! $this->forever((string) $key, $value)) { + $result = false; + } + } + + return $result; + } +} diff --git a/src/cache/src/TagSet.php b/src/cache/src/TagSet.php index 10930d83a..83b803069 100644 --- a/src/cache/src/TagSet.php +++ b/src/cache/src/TagSet.php @@ -6,7 +6,7 @@ use Hypervel\Contracts\Cache\Store; -class TagSet +abstract class TagSet { /** * The cache store implementation. @@ -30,62 +30,12 @@ public function __construct(Store $store, array $names = []) /** * Reset all tags in the set. */ - public function reset() - { - array_walk($this->names, [$this, 'resetTag']); - } - - /** - * Reset the tag and return the new tag identifier. - */ - public function resetTag(string $name): string - { - $this->store->forever($this->tagKey($name), $id = str_replace('.', '', uniqid('', true))); - - return $id; - } + abstract public function reset(): void; /** * Flush all the tags in the set. */ - public function flush(): void - { - array_walk($this->names, [$this, 'flushTag']); - } - - /** - * Flush the tag from the cache. - */ - public function flushTag(string $name): string - { - $this->store->forget($key = $this->tagKey($name)); - - return $key; - } - - /** - * Get a unique namespace that changes when any of the tags are flushed. - */ - public function getNamespace(): string - { - return implode('|', $this->tagIds()); - } - - /** - * Get the unique tag identifier for a given tag. - */ - public function tagId(string $name): string - { - return $this->store->get($this->tagKey($name)) ?: $this->resetTag($name); - } - - /** - * Get the tag identifier key for a given tag. - */ - public function tagKey(string $name): string - { - return 'tag:' . $name . ':key'; - } + abstract public function flush(): void; /** * Get all of the tag names in the set. @@ -94,14 +44,4 @@ public function getNames(): array { return $this->names; } - - /** - * Get an array of tag identifiers for all of the tags in the set. - * - * @return array - */ - public function tagIds(): array - { - return array_map([$this, 'tagId'], $this->names); - } } diff --git a/src/cache/src/TaggableStore.php b/src/cache/src/TaggableStore.php index 42bad96af..49847f24c 100644 --- a/src/cache/src/TaggableStore.php +++ b/src/cache/src/TaggableStore.php @@ -4,16 +4,33 @@ namespace Hypervel\Cache; +use Hypervel\Cache\Exceptions\NotSupportedException; use Hypervel\Contracts\Cache\Store; abstract class TaggableStore implements Store { /** * Begin executing a new tags operation. + * + * @throws NotSupportedException when conditional tag support is unavailable */ public function tags(mixed $names): TaggedCache { - return new TaggedCache($this, new TagSet($this, is_array($names) ? $names : func_get_args())); + return new NamespacedTaggedCache($this, new VersionedTagSet($this, is_array($names) ? $names : func_get_args())); + } + + /** + * Determine if this store currently supports tags. + * + * Stores whose tag support depends on configuration or composition + * override this; for everything else extending TaggableStore, tag + * support is unconditional. A store that can return false here must + * also throw a NotSupportedException from tags() because Repository + * relies on the store to enforce its own conditional support. + */ + public function supportsTags(): bool + { + return true; } /** diff --git a/src/cache/src/TaggedCache.php b/src/cache/src/TaggedCache.php index 818704e7d..b816be92e 100644 --- a/src/cache/src/TaggedCache.php +++ b/src/cache/src/TaggedCache.php @@ -14,7 +14,7 @@ use function Hypervel\Support\enum_value; -class TaggedCache extends Repository +abstract class TaggedCache extends Repository { /** * The tag set instance. @@ -82,11 +82,13 @@ public function flush(): bool } /** - * Get a fully qualified key for a tagged item. + * Remove all items from the cache. + * + * A tagged cache's PSR clear() scope is the tag set, not the whole store. */ - public function taggedItemKey(string $key): string + public function clear(): bool { - return hash('xxh128', $this->tags->getNamespace()) . ':' . $key; + return $this->flush(); } /** @@ -97,11 +99,6 @@ public function getTags(): TagSet return $this->tags; } - protected function itemKey(string $key): string - { - return $this->taggedItemKey($key); - } - /** * Fire an event for this cache instance. */ diff --git a/src/cache/src/VersionedTagSet.php b/src/cache/src/VersionedTagSet.php new file mode 100644 index 000000000..fafa2cb1b --- /dev/null +++ b/src/cache/src/VersionedTagSet.php @@ -0,0 +1,67 @@ +names, [$this, 'resetTag']); + } + + /** + * Reset the tag and return the new tag identifier. + */ + public function resetTag(string $name): string + { + $this->store->forever($this->tagKey($name), $id = str_replace('.', '', uniqid('', true))); + + return $id; + } + + /** + * Flush all the tags in the set. + */ + public function flush(): void + { + array_walk($this->names, [$this, 'flushTag']); + } + + /** + * Flush the tag from the cache. + */ + public function flushTag(string $name): string + { + $this->store->forget($key = $this->tagKey($name)); + + return $key; + } + + /** + * Get the unique tag identifier for a given tag. + */ + public function tagId(string $name): string + { + return $this->store->get($this->tagKey($name)) ?: $this->resetTag($name); + } + + /** + * Get the tag identifier key for a given tag. + */ + public function tagKey(string $name): string + { + return 'tag:' . $name . ':key'; + } +} diff --git a/src/contracts/src/Cache/CanFlushLocks.php b/src/contracts/src/Cache/CanFlushLocks.php index a8af3a82b..c19319664 100644 --- a/src/contracts/src/Cache/CanFlushLocks.php +++ b/src/contracts/src/Cache/CanFlushLocks.php @@ -6,6 +6,14 @@ interface CanFlushLocks { + /** + * Determine if the store can currently flush locks. + * + * Composite stores may implement this interface while delegating to a + * layer that cannot flush locks; this probe reports the real capability. + */ + public function supportsFlushingLocks(): bool; + /** * Flush all locks managed by the store. */ diff --git a/src/foundation/config/auth.php b/src/foundation/config/auth.php index 19023080e..d28d585d1 100644 --- a/src/foundation/config/auth.php +++ b/src/foundation/config/auth.php @@ -102,15 +102,17 @@ | authed requests at high concurrency. See the auth caching | documentation for the full explanation. | - | Caveat: only the outer store is validated. A stack with an - | unsupported inner tier (e.g. [array, redis]) won't be caught. + | Caveat: without tags, only the outer store is validated. A stack + | with an unsupported inner tier (e.g. [array, redis]) won't be caught. + | When cache tags are enabled, the stack's tag composition is also + | validated. | | Cache tags (optional): | Set 'tags' to an array of tag names (e.g. ['auth_users']) | to add those cache tags to every cached user. This is useful | for bulk cache invalidation using Cache::store(...)->tags([...])->flush(). - | Requires a TaggableStore configured in TagMode::Any - | (e.g. a Redis store with tag_mode=any). + | Requires a store with any-mode tag support (e.g. a Redis + | store with tag_mode=any, or a stack over any-mode taggable layers). | */ 'cache' => [ diff --git a/src/jwt/config/jwt.php b/src/jwt/config/jwt.php index 5d72abbc4..ff7238af4 100644 --- a/src/jwt/config/jwt.php +++ b/src/jwt/config/jwt.php @@ -325,6 +325,9 @@ |-------------------------------------------------------------------------- | | Specify the provider that is used to store tokens in the blacklist. + | The default tagged-cache storage requires a taggable default cache + | store; with node-local stack tiers, blacklist visibility is bounded + | by the upper tier's TTL. | */ diff --git a/src/jwt/src/JWTServiceProvider.php b/src/jwt/src/JWTServiceProvider.php index e54355eaf..2fd6f5bcf 100644 --- a/src/jwt/src/JWTServiceProvider.php +++ b/src/jwt/src/JWTServiceProvider.php @@ -134,7 +134,8 @@ protected function cacheStoreForJwtBlacklist(Container $app, bool $blacklistEnab if ($blacklistEnabled && ! $repository->supportsTags()) { throw new RuntimeException( - 'The JWT blacklist requires a taggable cache store. Use a taggable store or set a custom jwt.providers.storage.' + 'The JWT blacklist requires a taggable cache store (all-mode or any-mode). ' + . 'Use a taggable store or set a custom jwt.providers.storage.' ); } diff --git a/src/jwt/src/Storage/TaggedCache.php b/src/jwt/src/Storage/TaggedCache.php index 24892fd5d..43dc1921f 100644 --- a/src/jwt/src/Storage/TaggedCache.php +++ b/src/jwt/src/Storage/TaggedCache.php @@ -4,19 +4,43 @@ namespace Hypervel\JWT\Storage; +use Hypervel\Cache\TaggableStore; use Hypervel\Contracts\Cache\Repository as CacheContract; use Hypervel\JWT\Contracts\StorageContract; class TaggedCache implements StorageContract { + /** + * Key prefix applied in direct-key storage. + * + * In all mode the tag namespace isolates blacklist keys from the rest + * of the cache; in any mode keys are plain, so the prefix provides + * that isolation instead. + */ + private const DIRECT_KEY_PREFIX = 'jwt_blacklist:'; + protected string $tag = 'jwt_blacklist'; + /** + * Whether the store uses direct plain-key reads. + * + * Any-mode tags are write/index/flush only: writes go through tags() + * to record the invalidation index, while reads and per-key deletes + * use the plain key. + */ + protected bool $directKeyMode; + /** * Constructor. */ public function __construct( protected CacheContract $cache ) { + $store = $cache->getStore(); + + $this->directKeyMode = $store instanceof TaggableStore + && $store->supportsTags() + && $store->getTagMode()->supportsDirectGet(); } /** @@ -25,7 +49,7 @@ public function __construct( public function add(string $key, mixed $value, int $minutes): void { /* @phpstan-ignore-next-line */ - $this->cache->tags([$this->tag])->put($key, $value, $minutes * 60); + $this->cache->tags([$this->tag])->put($this->storageKey($key), $value, $minutes * 60); } /** @@ -34,7 +58,7 @@ public function add(string $key, mixed $value, int $minutes): void public function forever(string $key, mixed $value): void { /* @phpstan-ignore-next-line */ - $this->cache->tags([$this->tag])->forever($key, $value); + $this->cache->tags([$this->tag])->forever($this->storageKey($key), $value); } /** @@ -42,6 +66,10 @@ public function forever(string $key, mixed $value): void */ public function get(string $key): mixed { + if ($this->directKeyMode) { + return $this->cache->get($this->storageKey($key)); + } + /* @phpstan-ignore-next-line */ return $this->cache->tags([$this->tag])->get($key); } @@ -51,6 +79,10 @@ public function get(string $key): mixed */ public function destroy(string $key): bool { + if ($this->directKeyMode) { + return $this->cache->forget($this->storageKey($key)); + } + /* @phpstan-ignore-next-line */ return $this->cache->tags([$this->tag])->forget($key); } @@ -63,4 +95,12 @@ public function flush(): void /* @phpstan-ignore-next-line */ $this->cache->tags([$this->tag])->flush(); } + + /** + * Get the storage key for a logical blacklist key. + */ + protected function storageKey(string $key): string + { + return $this->directKeyMode ? self::DIRECT_KEY_PREFIX . $key : $key; + } } diff --git a/src/testbench/hypervel/config/auth.php b/src/testbench/hypervel/config/auth.php index c52b8ae94..a736b2026 100644 --- a/src/testbench/hypervel/config/auth.php +++ b/src/testbench/hypervel/config/auth.php @@ -36,15 +36,17 @@ | authed requests at high concurrency. See the auth caching | documentation for the full explanation. | - | Caveat: only the outer store is validated. A stack with an - | unsupported inner tier (e.g. [array, redis]) won't be caught. + | Caveat: without tags, only the outer store is validated. A stack + | with an unsupported inner tier (e.g. [array, redis]) won't be caught. + | When cache tags are enabled, the stack's tag composition is also + | validated. | | Cache tags (optional): | Set 'tags' to an array of tag names (e.g. ['auth_users']) | to add those cache tags to every cached user. This is useful | for bulk cache invalidation using Cache::store(...)->tags([...])->flush(). - | Requires a TaggableStore configured in TagMode::Any - | (e.g. a Redis store with tag_mode=any). + | Requires a store with any-mode tag support (e.g. a Redis + | store with tag_mode=any, or a stack over any-mode taggable layers). | */ 'cache' => [ diff --git a/tests/Auth/AuthEloquentUserProviderCacheTest.php b/tests/Auth/AuthEloquentUserProviderCacheTest.php index a5e16d4f4..fa9cc7fcf 100644 --- a/tests/Auth/AuthEloquentUserProviderCacheTest.php +++ b/tests/Auth/AuthEloquentUserProviderCacheTest.php @@ -425,6 +425,16 @@ public function testEnableCacheAcceptsTagsWithAnyModeRedisStore() $this->assertTrue($provider->isCacheEnabled()); } + public function testEnableCacheAcceptsTagsWithAnyModeStackStore() + { + $this->stubCache(StackStore::class, tagMode: TagMode::Any); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null, tags: ['auth_users']); + + $this->assertTrue($provider->isCacheEnabled()); + } + public function testEnableCacheRejectsTagsWithAllModeStore() { $this->stubCache(RedisStore::class, tagMode: TagMode::All); @@ -445,7 +455,7 @@ public function testEnableCacheRejectsTagsWithNonTaggableStore(string $storeClas $provider = $this->providerWithoutDbFetch(); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/require a TaggableStore/'); + $this->expectExceptionMessageMatches('/require a store that supports tags/'); $provider->enableCache(null, tags: ['auth_users']); } @@ -455,7 +465,24 @@ public static function nonTaggableWhitelistedStoreProvider(): iterable yield 'File' => [FileStore::class]; yield 'Database' => [DatabaseStore::class]; yield 'Swoole' => [SwooleStore::class]; - yield 'Stack' => [StackStore::class]; + } + + public function testEnableCacheRejectsTagsWithInvalidStackWithoutReadingMode() + { + $store = m::mock(StackStore::class); + $store->shouldReceive('supportsTags')->once()->andReturnFalse(); + $store->shouldNotReceive('getTagMode'); + + $repo = m::mock(CacheRepository::class); + $repo->shouldReceive('getStore')->andReturn($store); + $this->cacheManager->shouldReceive('store')->with(null)->andReturn($repo); + + $provider = $this->providerWithoutDbFetch(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/require a store that supports tags/'); + + $provider->enableCache(null, tags: ['auth_users']); } public function testRetrieveByIdMissUsesTaggedRepoForPutWhenTagsConfigured() @@ -672,6 +699,7 @@ public function testRecallingEnableCacheWithoutTagsClearsPreviousTagState() // call uses a plain Redis store (no tags). Set up both upfront so // the cache manager returns them in sequence from store(null). $store1 = m::mock(RedisStore::class); + $store1->shouldReceive('supportsTags')->andReturnTrue(); $store1->shouldReceive('getTagMode')->andReturn(TagMode::Any); $repo1 = m::mock(CacheRepository::class); $repo1->shouldReceive('getStore')->andReturn($store1); @@ -754,6 +782,7 @@ protected function stubCache(string $storeClass, ?string $name = null, ?TagMode $store = m::mock($storeClass); if ($tagMode !== null) { + $store->shouldReceive('supportsTags')->andReturnTrue(); $store->shouldReceive('getTagMode')->andReturn($tagMode); } diff --git a/tests/Cache/CacheDatabaseStoreTest.php b/tests/Cache/CacheDatabaseStoreTest.php index feea41668..6cb02b85e 100644 --- a/tests/Cache/CacheDatabaseStoreTest.php +++ b/tests/Cache/CacheDatabaseStoreTest.php @@ -382,6 +382,17 @@ public function testLocksMayBeFlushedFromCache() $this->assertTrue($result); } + public function testSupportsFlushingLocksRequiresSeparateLockTable() + { + [$store] = $this->getStore(); + + $this->assertTrue($store->supportsFlushingLocks()); + + $store = new DatabaseStore(m::mock(ConnectionResolverInterface::class), 'default', 'cache', '', 'cache'); + + $this->assertFalse($store->supportsFlushingLocks()); + } + public function testPruneExpiredRemovesExpiredEntries() { Carbon::setTestNow($now = Carbon::now()); diff --git a/tests/Cache/CacheEventsTest.php b/tests/Cache/CacheEventsTest.php index 6674a0eff..bcc8329c9 100644 --- a/tests/Cache/CacheEventsTest.php +++ b/tests/Cache/CacheEventsTest.php @@ -312,6 +312,7 @@ public function testFlushLocksFailureDoesDispatchEvent() // Create a store that fails to flush locks $failingStore = m::mock(ArrayStore::class); + $failingStore->shouldReceive('supportsFlushingLocks')->andReturn(true); $failingStore->shouldReceive('flushLocks')->andReturn(false); $repository = new Repository($failingStore, ['store' => 'array']); diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index 23737b488..fb3aa53c2 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -422,6 +422,17 @@ public function testHasSeparateLockStoreReturnsFalseWhenLockDirectoryIsNull() $this->assertFalse($store->hasSeparateLockStore()); } + public function testSupportsFlushingLocksRequiresSeparateLockDirectory() + { + $store = new FileStore(new Filesystem, __DIR__); + + $this->assertFalse($store->supportsFlushingLocks()); + + $store->setLockDirectory('/locks'); + + $this->assertTrue($store->supportsFlushingLocks()); + } + public function testFlushLocksThrowsExceptionWhenLockDirectoryIsSame() { $store = new FileStore(new Filesystem, __DIR__); diff --git a/tests/Cache/CacheRedisStoreTest.php b/tests/Cache/CacheRedisStoreTest.php index 26c96fd0d..6cab7305f 100755 --- a/tests/Cache/CacheRedisStoreTest.php +++ b/tests/Cache/CacheRedisStoreTest.php @@ -171,6 +171,17 @@ public function testFlushesCachedLocks() $this->assertTrue($result); } + public function testSupportsFlushingLocksRequiresSeparateLockConnection() + { + $store = $this->createStore($this->mockConnection()); + + $this->assertFalse($store->supportsFlushingLocks()); + + $store->setLockConnection('locks'); + + $this->assertTrue($store->supportsFlushingLocks()); + } + public function testGetAndSetPrefix() { $store = $this->createStore($this->mockConnection()); diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index 08dd8ea92..a940a598f 100644 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -16,12 +16,14 @@ use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\Events\WritingKey; use Hypervel\Cache\Events\WritingManyKeys; +use Hypervel\Cache\Exceptions\NotSupportedException; use Hypervel\Cache\FileStore; use Hypervel\Cache\Lock; use Hypervel\Cache\NullSentinel; use Hypervel\Cache\NullStore; use Hypervel\Cache\RedisStore; use Hypervel\Cache\Repository; +use Hypervel\Cache\StackStore; use Hypervel\Cache\TaggableStore; use Hypervel\Cache\TaggedCache; use Hypervel\Contracts\Cache\LockProvider; @@ -729,10 +731,32 @@ public function testItThrowsExceptionWhenStoreDoesNotSupportTags() $this->expectException(BadMethodCallException::class); $store = new FileStore(new Filesystem, '/usr'); - $this->assertFalse(method_exists($store, 'tags'), 'Store should not support tagging.'); + $this->assertFalse((new Repository($store))->supportsTags()); (new Repository($store))->tags('foo'); } + public function testItThrowsExceptionWhenTaggableStoreReportsUnsupportedTags() + { + $this->expectException(NotSupportedException::class); + $this->expectExceptionMessage('Detailed tag support failure.'); + + $store = m::mock(TaggableStore::class); + $store->shouldReceive('supportsTags')->never(); + $store->shouldReceive('tags')->once()->with(['foo'])->andThrow( + new NotSupportedException('Detailed tag support failure.') + ); + + (new Repository($store))->tags('foo'); + } + + public function testTaggableStoreCompositionExceptionIsPreserved() + { + $this->expectException(NotSupportedException::class); + $this->expectExceptionMessage('must be a taggable store in any mode'); + + (new Repository(new StackStore([new ArrayStore])))->tags('foo'); + } + public function testTagMethodReturnsTaggedCache() { $store = (new Repository(new ArrayStore))->tags('foo'); @@ -777,6 +801,7 @@ public function testDefaultCacheLifeTimeIsSetOnTaggableStore() public function testFlushLocksDelegatesToStore() { $flushable = m::mock(RedisStore::class); + $flushable->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue(); $flushable->shouldReceive('flushLocks')->once()->andReturn(true); $repo = new Repository($flushable); @@ -787,11 +812,21 @@ public function testFlushLocksDelegatesToStore() public function testTaggableRepositoriesSupportTags() { $taggable = m::mock(TaggableStore::class); + $taggable->shouldReceive('supportsTags')->once()->andReturnTrue(); $taggableRepo = new Repository($taggable); $this->assertTrue($taggableRepo->supportsTags()); } + public function testTaggableRepositoriesCanReportUnsupportedTags() + { + $taggable = m::mock(TaggableStore::class); + $taggable->shouldReceive('supportsTags')->once()->andReturnFalse(); + $taggableRepo = new Repository($taggable); + + $this->assertFalse($taggableRepo->supportsTags()); + } + public function testNonTaggableRepositoryDoesNotSupportTags() { $nonTaggable = m::mock(FileStore::class); @@ -803,11 +838,21 @@ public function testNonTaggableRepositoryDoesNotSupportTags() public function testFlushableLockRepositorySupportsFlushingLocks() { $flushable = m::mock(RedisStore::class); + $flushable->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue(); $flushableRepo = new Repository($flushable); $this->assertTrue($flushableRepo->supportsFlushingLocks()); } + public function testFlushableLockRepositoryCanReportUnsupportedFlushingLocks() + { + $flushable = m::mock(RedisStore::class); + $flushable->shouldReceive('supportsFlushingLocks')->once()->andReturnFalse(); + $flushableRepo = new Repository($flushable); + + $this->assertFalse($flushableRepo->supportsFlushingLocks()); + } + public function testNonFlushableLockRepositoryDoesNotSupportFlushingLocks() { $nonFlushable = m::mock(NullStore::class); @@ -834,6 +879,15 @@ public function testTouchWithNullTTLRemembersItemForever() $this->assertTrue($repo->touch('key', null)); } + public function testTouchWithNullTtlPreservesCachedNullSentinel() + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->with('key')->andReturn(NullSentinel::VALUE); + $repo->getStore()->shouldReceive('forever')->once()->with('key', NullSentinel::VALUE)->andReturn(true); + + $this->assertTrue($repo->touch('key', null)); + } + public function testTouchWithSecondsTtlCorrectlyProxiesToStore() { $repo = $this->getRepository(); @@ -842,6 +896,24 @@ public function testTouchWithSecondsTtlCorrectlyProxiesToStore() $this->assertTrue($repo->touch('key', 60)); } + public function testTouchWithSecondsTtlTreatsCachedNullSentinelAsHit() + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->with('key')->andReturn(NullSentinel::VALUE); + $repo->getStore()->shouldReceive('touch')->once()->with('key', 60)->andReturn(true); + + $this->assertTrue($repo->touch('key', 60)); + } + + public function testTouchWithEnumKeyProxiesResolvedKeyToStore() + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->with('foo')->andReturn('bar'); + $repo->getStore()->shouldReceive('touch')->once()->with('foo', 60)->andReturn(true); + + $this->assertTrue($repo->touch(TestCacheKey::Foo, 60)); + } + public function testTouchWithDatetimeTtlCorrectlyProxiesToStore() { Carbon::setTestNow($now = Carbon::now()); diff --git a/tests/Cache/CacheStackStoreLocksTest.php b/tests/Cache/CacheStackStoreLocksTest.php new file mode 100644 index 000000000..e46e5176c --- /dev/null +++ b/tests/Cache/CacheStackStoreLocksTest.php @@ -0,0 +1,108 @@ +shouldReceive('lock')->once()->with('name', 30, 'owner')->andReturn($lock); + + $stack = new StackStore([$this->plainStore(), $bottom]); + + $this->assertSame($lock, $stack->lock('name', 30, 'owner')); + } + + public function testRestoreLockDelegatesToBottomLayer(): void + { + $bottom = m::mock(Store::class, LockProvider::class); + $lock = m::mock(Lock::class); + + $bottom->shouldReceive('restoreLock')->once()->with('name', 'owner')->andReturn($lock); + + $stack = new StackStore([$this->plainStore(), $bottom]); + + $this->assertSame($lock, $stack->restoreLock('name', 'owner')); + } + + public function testLockThrowsWhenBottomLayerDoesNotSupportLocks(): void + { + $stack = new StackStore([$this->plainStore()]); + + $this->expectException(NotSupportedException::class); + $this->expectExceptionMessage('does not support locks'); + + $stack->lock('name'); + } + + public function testSupportsFlushingLocksReflectsBottomLayerProbe(): void + { + $bottom = m::mock(Store::class, CanFlushLocks::class); + $bottom->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue(); + + $this->assertTrue((new StackStore([$this->plainStore(), $bottom]))->supportsFlushingLocks()); + } + + public function testSupportsFlushingLocksReturnsFalseWhenBottomLayerProbeIsFalse(): void + { + $bottom = m::mock(Store::class, CanFlushLocks::class); + $bottom->shouldReceive('supportsFlushingLocks')->once()->andReturnFalse(); + + $this->assertFalse((new StackStore([$this->plainStore(), $bottom]))->supportsFlushingLocks()); + } + + public function testSupportsFlushingLocksReturnsFalseWhenBottomLayerDoesNotImplementContract(): void + { + $this->assertFalse((new StackStore([$this->plainStore()]))->supportsFlushingLocks()); + } + + public function testFlushLocksDelegatesToBottomLayer(): void + { + $bottom = m::mock(Store::class, CanFlushLocks::class); + $bottom->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue(); + $bottom->shouldReceive('flushLocks')->once()->andReturnTrue(); + + $this->assertTrue((new StackStore([$this->plainStore(), $bottom]))->flushLocks()); + } + + public function testFlushLocksThrowsWhenBottomLayerCannotFlushLocks(): void + { + $bottom = m::mock(Store::class, CanFlushLocks::class); + $bottom->shouldReceive('supportsFlushingLocks')->once()->andReturnFalse(); + $bottom->shouldNotReceive('flushLocks'); + + $stack = new StackStore([$this->plainStore(), $bottom]); + + $this->expectException(NotSupportedException::class); + $this->expectExceptionMessage('does not support flushing locks'); + + $stack->flushLocks(); + } + + public function testHasSeparateLockStoreDelegatesToBottomLayer(): void + { + $bottom = m::mock(Store::class, CanFlushLocks::class); + $bottom->shouldReceive('hasSeparateLockStore')->once()->andReturnTrue(); + + $this->assertTrue((new StackStore([$this->plainStore(), $bottom]))->hasSeparateLockStore()); + } + + private function plainStore(): Store|m\MockInterface + { + return m::mock(Store::class); + } +} diff --git a/tests/Cache/CacheStackStoreTagsTest.php b/tests/Cache/CacheStackStoreTagsTest.php new file mode 100644 index 000000000..50ecc19bc --- /dev/null +++ b/tests/Cache/CacheStackStoreTagsTest.php @@ -0,0 +1,274 @@ +nonTaggableStore(), $this->anyModeTaggableStore()], + [$this->anyModeTaggableStore()], + [$this->nonTaggableStore(), $this->anyModeTaggableStore(), $this->anyModeTaggableStore()], + ] as $layers) { + $stack = new StackStore($layers); + + $this->assertTrue($stack->supportsTags()); + $this->assertSame(TagMode::Any, $stack->getTagMode()); + $this->assertInstanceOf(StackTaggedCache::class, $stack->tags(['t'])); + } + } + + public function testInvalidCompositionsDoNotSupportTags(): void + { + foreach ([ + [$this->nonTaggableStore()], + [$this->anyModeTaggableStore(), $this->nonTaggableStore()], + [$this->nonTaggableStore(), $this->allModeTaggableStore()], + [$this->allModeTaggableStore(), $this->anyModeTaggableStore()], + ] as $layers) { + $stack = new StackStore($layers); + + $this->assertFalse($stack->supportsTags()); + + try { + $stack->tags(['t']); + $this->fail('Expected NotSupportedException was not thrown.'); + } catch (NotSupportedException) { + $this->assertTrue(true); + } + } + } + + public function testInvalidNestedStackDoesNotCallGetTagMode(): void + { + $taggable = m::mock(TaggableStore::class); + $taggable->shouldReceive('supportsTags')->once()->andReturnFalse(); + $taggable->shouldNotReceive('getTagMode'); + + $stack = new StackStore([$taggable]); + + $this->assertFalse($stack->supportsTags()); + } + + public function testValidationRunsOnce(): void + { + $taggable = m::mock(TaggableStore::class); + $taggable->shouldReceive('supportsTags')->once()->andReturnTrue(); + $taggable->shouldReceive('getTagMode')->once()->andReturn(TagMode::Any); + + $stack = new StackStore([$taggable]); + + $this->assertTrue($stack->supportsTags()); + $this->assertTrue($stack->supportsTags()); + $this->assertSame(TagMode::Any, $stack->getTagMode()); + } + + public function testTaggedPutWritesPlainUpperLayersAndTaggedLowerLayersWithTtlClamp(): void + { + $plain = $this->nonTaggableStore(); + $taggable = $this->anyModeTaggableStore(); + $taggedCache = m::mock(TaggedCache::class); + + $plain->shouldReceive('put') + ->once() + ->with('key', m::on(fn (array $record): bool => $record['value'] === 'value' && isset($record['expiration'])), 3) + ->andReturnTrue(); + $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); + $taggedCache->shouldReceive('put') + ->once() + ->with('key', m::on(fn (array $record): bool => $record['value'] === 'value' && isset($record['expiration'])), 60) + ->andReturnTrue(); + + $stack = new StackStore([ + new StackStoreProxy($plain, 3), + new StackStoreProxy($taggable), + ]); + + $this->assertTrue($stack->tags(['tag'])->put('key', 'value', 60)); + } + + public function testTaggedForeverHonorsLayerTtlClamp(): void + { + $plain = $this->nonTaggableStore(); + $taggable = $this->anyModeTaggableStore(); + $taggedCache = m::mock(TaggedCache::class); + + $plain->shouldReceive('put')->once()->with('key', ['value' => 'value'], 3)->andReturnTrue(); + $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); + $taggedCache->shouldReceive('forever')->once()->with('key', ['value' => 'value'])->andReturnTrue(); + + $stack = new StackStore([ + new StackStoreProxy($plain, 3), + new StackStoreProxy($taggable), + ]); + + $this->assertTrue($stack->tags(['tag'])->forever('key', 'value')); + } + + public function testTaggedPutClampsTtlOnTaggableLayer(): void + { + $taggable = $this->anyModeTaggableStore(); + $taggedCache = m::mock(TaggedCache::class); + + $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); + $taggedCache->shouldReceive('put') + ->once() + ->with('key', m::on(fn (array $record): bool => $record['value'] === 'value' && isset($record['expiration'])), 3) + ->andReturnTrue(); + + $stack = new StackStore([ + new StackStoreProxy($taggable, 3), + ]); + + $this->assertTrue($stack->tags(['tag'])->put('key', 'value', 300)); + } + + public function testTaggedForeverClampsToPutOnTaggableLayerWithTtlOverride(): void + { + $taggable = $this->anyModeTaggableStore(); + $taggedCache = m::mock(TaggedCache::class); + + $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); + $taggedCache->shouldReceive('put')->once()->with('key', ['value' => 'value'], 3)->andReturnTrue(); + $taggedCache->shouldNotReceive('forever'); + + $stack = new StackStore([ + new StackStoreProxy($taggable, 3), + ]); + + $this->assertTrue($stack->tags(['tag'])->forever('key', 'value')); + } + + public function testTaggedWriteRollsBackWrittenLayersOnFailure(): void + { + $plain = $this->nonTaggableStore(); + $taggable = $this->anyModeTaggableStore(); + $taggedCache = m::mock(TaggedCache::class); + + $plain->shouldReceive('put')->once()->andReturnTrue(); + $plain->shouldReceive('forget')->once()->with('key')->andReturnTrue(); + $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); + $taggedCache->shouldReceive('put')->once()->andReturnFalse(); + + $stack = new StackStore([ + new StackStoreProxy($plain), + new StackStoreProxy($taggable), + ]); + + $this->assertFalse($stack->tags(['tag'])->put('key', 'value', 60)); + } + + public function testTaggedIncrementWritesThroughTaggedPath(): void + { + $taggable = $this->anyModeTaggableStore(); + $taggedCache = m::mock(TaggedCache::class); + + $taggable->shouldReceive('get')->once()->with('counter')->andReturn(['value' => 1]); + $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); + $taggedCache->shouldReceive('forever')->once()->with('counter', ['value' => 3])->andReturnTrue(); + + $stack = new StackStore([$taggable]); + + $this->assertSame(3, $stack->tags(['tag'])->increment('counter', 2)); + } + + public function testTaggedRememberReadsPlainAndWritesTaggedOnMiss(): void + { + $taggable = $this->anyModeTaggableStore(); + $taggedCache = m::mock(TaggedCache::class); + + $taggable->shouldReceive('get')->once()->with('key')->andReturnNull(); + $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); + $taggedCache->shouldReceive('put')->once()->andReturnTrue(); + + $stack = new StackStore([$taggable]); + + $this->assertSame('computed', $stack->tags(['tag'])->remember('key', 60, fn () => 'computed')); + } + + public function testTaggedRememberHitReadsPlainWithoutTaggedWrite(): void + { + $taggable = $this->anyModeTaggableStore(); + + $taggable->shouldReceive('get')->once()->with('key')->andReturn(['value' => 'cached']); + $taggable->shouldNotReceive('tags'); + + $stack = new StackStore([$taggable]); + + $this->assertSame('cached', $stack->tags(['tag'])->remember('key', 60, fn () => 'computed')); + } + + public function testTaggedReadAndDeleteOperationsThrow(): void + { + $cache = (new StackStore([$this->anyModeTaggableStore()]))->tags(['tag']); + + foreach ([ + fn () => $cache->get('key'), + fn () => $cache->getMultiple(['key']), + fn () => $cache->delete('key'), + fn () => $cache->deleteMultiple(['key']), + fn () => $cache->touch('key', 60), + ] as $operation) { + try { + $operation(); + $this->fail('Expected BadMethodCallException was not thrown.'); + } catch (BadMethodCallException) { + $this->assertTrue(true); + } + } + } + + public function testClearFlushesTaggableLayersOnly(): void + { + $taggable = $this->anyModeTaggableStore(); + $tagSet = m::mock(TagSet::class); + $taggedCache = m::mock(TaggedCache::class); + + $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); + $taggedCache->shouldReceive('getTags')->once()->andReturn($tagSet); + $tagSet->shouldReceive('flush')->once(); + + $stack = new StackStore([$this->nonTaggableStore(), $taggable]); + + $this->assertTrue($stack->tags(['tag'])->clear()); + } + + private function anyModeTaggableStore(): TaggableStore|m\MockInterface + { + $store = m::mock(TaggableStore::class); + $store->shouldReceive('supportsTags')->byDefault()->andReturnTrue(); + $store->shouldReceive('getTagMode')->byDefault()->andReturn(TagMode::Any); + + return $store; + } + + private function allModeTaggableStore(): TaggableStore|m\MockInterface + { + $store = m::mock(TaggableStore::class); + $store->shouldReceive('supportsTags')->byDefault()->andReturnTrue(); + $store->shouldReceive('getTagMode')->byDefault()->andReturn(TagMode::All); + + return $store; + } + + private function nonTaggableStore(): Store|m\MockInterface + { + return m::mock(Store::class); + } +} diff --git a/tests/Cache/CacheStackStoreTest.php b/tests/Cache/CacheStackStoreTest.php index 8424b0684..1e70e82fc 100644 --- a/tests/Cache/CacheStackStoreTest.php +++ b/tests/Cache/CacheStackStoreTest.php @@ -13,6 +13,7 @@ use Hypervel\Cache\StackStoreProxy; use Hypervel\Cache\SwooleStore; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; use Mockery\MockInterface; @@ -50,6 +51,14 @@ public function testRetrieveItemFromStoreStacked() $this->assertSame($value, $this->store->get($key)); } + public function testConstructorRequiresAtLeastOneStore(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A cache stack requires at least one store layer.'); + + new StackStore([]); + } + public function testPutWithCorrectTTL() { $this->createStores(); diff --git a/tests/Cache/CacheTaggedCacheTest.php b/tests/Cache/CacheTaggedCacheTest.php index 5547452fb..138194eac 100644 --- a/tests/Cache/CacheTaggedCacheTest.php +++ b/tests/Cache/CacheTaggedCacheTest.php @@ -9,6 +9,7 @@ use DateTimeImmutable; use Hypervel\Cache\ArrayStore; use Hypervel\Cache\Repository; +use Hypervel\Cache\TaggedCache; use Hypervel\Tests\TestCase; use TypeError; @@ -88,6 +89,21 @@ public function testCacheSavedWithMultipleTagsCanBeFlushed() $this->assertSame('bar', $store->tags($tags2)->get('foo')); } + public function testClearFlushesOnlyTaggedNamespace(): void + { + $store = new ArrayStore; + + $store->put('foo', 'plain', 10); + $store->tags(['bop'])->put('foo', 'tagged', 10); + + $tagged = $store->tags(['bop']); + + $this->assertInstanceOf(TaggedCache::class, $tagged); + $this->assertTrue($tagged->clear()); + $this->assertSame('plain', $store->get('foo')); + $this->assertNull($store->tags(['bop'])->get('foo')); + } + public function testTagFlushRemovesSentinelAndReRunsCallbackOnRememberNullable() { $store = new ArrayStore(serializesValues: true); diff --git a/tests/Cache/FunnelUnsupportedStoresTest.php b/tests/Cache/FunnelUnsupportedStoresTest.php index dfcd2772b..1feaf4b39 100644 --- a/tests/Cache/FunnelUnsupportedStoresTest.php +++ b/tests/Cache/FunnelUnsupportedStoresTest.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Cache; use Hypervel\Cache\SessionStore; -use Hypervel\Cache\StackStore; use Hypervel\Cache\SwooleStore; use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Tests\TestCase; @@ -17,11 +16,6 @@ public function testSwooleStoreDoesNotImplementLockProvider() $this->assertFalse(is_subclass_of(SwooleStore::class, LockProvider::class)); } - public function testStackStoreDoesNotImplementLockProvider() - { - $this->assertFalse(is_subclass_of(StackStore::class, LockProvider::class)); - } - public function testSessionStoreDoesNotImplementLockProvider() { $this->assertFalse(is_subclass_of(SessionStore::class, LockProvider::class)); diff --git a/tests/Cache/Redis/AllTagSetTest.php b/tests/Cache/Redis/AllTagSetTest.php index 81a2f0bc9..985d6ceb4 100644 --- a/tests/Cache/Redis/AllTagSetTest.php +++ b/tests/Cache/Redis/AllTagSetTest.php @@ -12,10 +12,56 @@ * Note: Operation-specific tests (addEntry, entries, flushStaleEntries) have been * moved to dedicated test classes in tests/Cache/Redis/Operations/AllTag/. * - * This file tests the TagSet-specific API methods: tagId, tagKey, flushTag, resetTag. + * This file tests the TagSet-specific API methods: reset, flush, tagId, tagKey, flushTag, resetTag. */ class AllTagSetTest extends RedisCacheTestCase { + /** + * @test + */ + public function testResetWalksAllTags(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $tagSet = new AllTagSet($store, ['users', 'posts']); + + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries') + ->andReturn(1); + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:posts:entries') + ->andReturn(1); + + $tagSet->reset(); + + $this->assertTrue(true); + } + + /** + * @test + */ + public function testFlushWalksAllTags(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $tagSet = new AllTagSet($store, ['users', 'posts']); + + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries') + ->andReturn(1); + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:posts:entries') + ->andReturn(1); + + $tagSet->flush(); + + $this->assertTrue(true); + } + /** * @test */ diff --git a/tests/Cache/Redis/AllTaggedCacheTest.php b/tests/Cache/Redis/AllTaggedCacheTest.php index 8c256dc58..8ff2b08b2 100644 --- a/tests/Cache/Redis/AllTaggedCacheTest.php +++ b/tests/Cache/Redis/AllTaggedCacheTest.php @@ -338,6 +338,40 @@ public function testFlush(): void $this->assertTrue($result); } + /** + * @test + */ + public function testClearFlushesTaggedItems(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:people:entries', null, '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 0; + + return ['key1' => 0, 'key2' => 0]; + }); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:people:entries', 0, '*', 1000) + ->andReturnNull(); + $connection->shouldReceive('del') + ->once() + ->with('prefix:key1', 'prefix:key2') + ->andReturn(2); + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:people:entries') + ->andReturn(1); + + $store = $this->createStore($connection); + $result = $store->tags(['people'])->clear(); + + $this->assertTrue($result); + } + /** * @test */ @@ -381,6 +415,169 @@ public function testPutZeroTtlDeletesKey(): void $this->assertTrue($result); } + /** + * @test + */ + public function testPutManyZeroTtlDeletesNamespacedKeys(): void + { + $connection = $this->mockConnection(); + + $namespace = hash('xxh128', '_all:tag:users:entries') . ':'; + + $connection->shouldReceive('del') + ->once() + ->with("prefix:{$namespace}name") + ->andReturn(1); + $connection->shouldReceive('del') + ->once() + ->with("prefix:{$namespace}age") + ->andReturn(1); + + $store = $this->createStore($connection); + $result = $store->tags(['users'])->putMany([ + 'name' => 'John', + 'age' => 30, + ], 0); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testTouchUpdatesKeyAndTagScores(): void + { + $connection = $this->mockConnection(); + + $key = hash('xxh128', '_all:tag:users:entries') . ':name'; + + $connection->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize('John')); + $connection->shouldReceive('evalWithShaCache') + ->once() + ->withArgs(function (string $script, array $keys, array $args) use ($key): bool { + $this->assertSame(["prefix:{$key}", 'prefix:_all:tag:users:entries'], $keys); + $this->assertSame(60, $args[0]); + $this->assertSame($key, $args[2]); + + return true; + }) + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->tags(['users'])->touch('name', 60); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testTouchUpdatesCachedNullKeyAndTagScores(): void + { + $connection = $this->mockConnection(); + + $key = hash('xxh128', '_all:tag:users:entries') . ':name'; + + $connection->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize(NullSentinel::VALUE)); + $connection->shouldReceive('evalWithShaCache') + ->once() + ->withArgs(function (string $script, array $keys, array $args) use ($key): bool { + $this->assertSame(["prefix:{$key}", 'prefix:_all:tag:users:entries'], $keys); + $this->assertSame(60, $args[0]); + $this->assertSame($key, $args[2]); + + return true; + }) + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->tags(['users'])->touch('name', 60); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testTouchWithNullTtlStoresItemForeverWithTags(): void + { + $connection = $this->mockConnection(); + + $key = hash('xxh128', '_all:tag:users:entries') . ':name'; + + $connection->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize('John')); + $connection->shouldReceive('pipeline')->once()->andReturn($connection); + $connection->shouldReceive('zadd')->once()->with('prefix:_all:tag:users:entries', -1, $key)->andReturn($connection); + $connection->shouldReceive('set')->once()->with("prefix:{$key}", serialize('John'))->andReturn($connection); + $connection->shouldReceive('exec')->once()->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['users'])->touch('name', null); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testTouchWithNullTtlPreservesCachedNullSentinel(): void + { + $connection = $this->mockConnection(); + + $key = hash('xxh128', '_all:tag:users:entries') . ':name'; + + $connection->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize(NullSentinel::VALUE)); + $connection->shouldReceive('pipeline')->once()->andReturn($connection); + $connection->shouldReceive('zadd')->once()->with('prefix:_all:tag:users:entries', -1, $key)->andReturn($connection); + $connection->shouldReceive('set') + ->once() + ->with( + "prefix:{$key}", + m::on(fn (string $serialized): bool => unserialize($serialized) === NullSentinel::VALUE) + ) + ->andReturn($connection); + $connection->shouldReceive('exec')->once()->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['users'])->touch('name', null); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testTouchReturnsFalseForMissingKey(): void + { + $connection = $this->mockConnection(); + + $key = hash('xxh128', '_all:tag:users:entries') . ':name'; + + $connection->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturnNull(); + $connection->shouldNotReceive('evalWithShaCache'); + $connection->shouldNotReceive('pipeline'); + + $store = $this->createStore($connection); + $result = $store->tags(['users'])->touch('name', 60); + + $this->assertFalse($result); + } + /** * @test */ @@ -478,6 +675,43 @@ public function testRememberCallsCallbackAndStoresValueOnMiss(): void $this->assertSame(1, $callCount); } + public function testRememberNormalizesEnumKeyForRedisAndEvents(): void + { + $connection = $this->mockConnection(); + + $key = hash('xxh128', '_all:tag:users:entries') . ':profile'; + $expectedScore = now()->timestamp + 60; + + $connection->shouldReceive('get')->once()->with("prefix:{$key}")->andReturnNull(); + $connection->shouldReceive('pipeline')->once()->andReturn($connection); + $connection->shouldReceive('zadd')->once()->with('prefix:_all:tag:users:entries', $expectedScore, $key)->andReturn($connection); + $connection->shouldReceive('setex')->once()->with("prefix:{$key}", 60, serialize('computed_value'))->andReturn($connection); + $connection->shouldReceive('exec')->once()->andReturn([1, true]); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function (object $event) use (&$captured): void { + $captured[] = $event; + }); + + $tagged = $this->createStore($connection)->tags(['users']); + $tagged->setEventDispatcher($events); + + $result = $tagged->remember(AllTaggedCacheTestKey::Profile, 60, fn () => 'computed_value'); + + $this->assertSame('computed_value', $result); + + $cacheMissed = array_values(array_filter($captured, fn (object $event) => $event instanceof CacheMissed))[0] ?? null; + $keyWritten = array_values(array_filter($captured, fn (object $event) => $event instanceof KeyWritten))[0] ?? null; + + $this->assertNotNull($cacheMissed); + $this->assertSame('profile', $cacheMissed->key); + $this->assertNotNull($keyWritten); + $this->assertSame('profile', $keyWritten->key); + } + /** * @test */ @@ -675,6 +909,38 @@ public function testRememberForeverReturnsExistingValueOnCacheHit(): void $this->assertSame('cached_settings', $result); } + public function testRememberForeverNormalizesEnumKeyForRedisAndEvents(): void + { + $connection = $this->mockConnection(); + + $key = hash('xxh128', '_all:tag:config:entries') . ':settings'; + + $connection->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize('cached_settings')); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function (object $event) use (&$captured): void { + $captured[] = $event; + }); + + $tagged = $this->createStore($connection)->tags(['config']); + $tagged->setEventDispatcher($events); + + $result = $tagged->rememberForever(AllTaggedCacheTestKey::Settings, fn () => 'new_settings'); + + $this->assertSame('cached_settings', $result); + + $cacheHit = array_values(array_filter($captured, fn (object $event) => $event instanceof CacheHit))[0] ?? null; + + $this->assertNotNull($cacheHit); + $this->assertSame('settings', $cacheHit->key); + } + /** * @test */ @@ -872,3 +1138,9 @@ public function testFlexibleNullableReturnsNullOnFreshSentinelHit(): void $this->assertFalse($invoked); } } + +enum AllTaggedCacheTestKey: string +{ + case Profile = 'profile'; + case Settings = 'settings'; +} diff --git a/tests/Cache/Redis/AnyTagSetTest.php b/tests/Cache/Redis/AnyTagSetTest.php index 5fee4aff3..813661979 100644 --- a/tests/Cache/Redis/AnyTagSetTest.php +++ b/tests/Cache/Redis/AnyTagSetTest.php @@ -53,28 +53,6 @@ public function testGetNamesReturnsEmptyArrayWhenNoTags(): void $this->assertSame([], $tagSet->getNames()); } - /** - * @test - */ - public function testTagIdReturnsTagNameDirectly(): void - { - $tagSet = new AnyTagSet($this->store, ['users']); - - // Unlike AllTagSet, any mode uses tag name directly (no UUID) - $this->assertSame('users', $tagSet->tagId('users')); - $this->assertSame('posts', $tagSet->tagId('posts')); - } - - /** - * @test - */ - public function testTagIdsReturnsAllTagNames(): void - { - $tagSet = new AnyTagSet($this->store, ['users', 'posts', 'comments']); - - $this->assertSame(['users', 'posts', 'comments'], $tagSet->tagIds()); - } - /** * @test */ @@ -238,59 +216,6 @@ public function testFlushTagDeletesSingleTag(): void $this->assertSame('prefix:_any:tag:users:entries', $result); } - /** - * @test - */ - public function testGetNamespaceReturnsEmptyString(): void - { - $tagSet = new AnyTagSet($this->store, ['users']); - - // Union mode doesn't namespace keys by tags - $this->assertSame('', $tagSet->getNamespace()); - } - - /** - * @test - */ - public function testResetTagFlushesTagAndReturnsName(): void - { - $tagSet = new AnyTagSet($this->store, ['users']); - - // GetTaggedKeys for the flush operation - $this->connection->shouldReceive('hlen') - ->once() - ->with('prefix:_any:tag:users:entries') - ->andReturn(1); - $this->connection->shouldReceive('hkeys') - ->once() - ->with('prefix:_any:tag:users:entries') - ->andReturn(['key1']); - - // Pipeline for flush operations - $this->connection->shouldReceive('pipeline')->andReturn($this->pipeline); - $this->pipeline->shouldReceive('del')->andReturnSelf(); - $this->pipeline->shouldReceive('unlink')->andReturnSelf(); - $this->pipeline->shouldReceive('zrem')->andReturnSelf(); - $this->pipeline->shouldReceive('exec')->andReturn([]); - - $result = $tagSet->resetTag('users'); - - // Returns the tag name (not a UUID like AllTagSet) - $this->assertSame('users', $result); - } - - /** - * @test - */ - public function testTagKeyReturnsSameAsTagHashKey(): void - { - $tagSet = new AnyTagSet($this->store, ['users']); - - $result = $tagSet->tagKey('users'); - - $this->assertSame('prefix:_any:tag:users:entries', $result); - } - /** * @test */ diff --git a/tests/Cache/Redis/AnyTaggedCacheTest.php b/tests/Cache/Redis/AnyTaggedCacheTest.php index 420aa3d73..1c865ff40 100644 --- a/tests/Cache/Redis/AnyTaggedCacheTest.php +++ b/tests/Cache/Redis/AnyTaggedCacheTest.php @@ -7,6 +7,7 @@ use BadMethodCallException; use Generator; use Hypervel\Cache\Events\CacheHit; +use Hypervel\Cache\Events\CacheMissed; use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\NullSentinel; use Hypervel\Cache\Redis\AnyTaggedCache; @@ -68,6 +69,21 @@ public function testManyThrowsBadMethodCallException(): void $cache->many(['key1', 'key2']); } + /** + * @test + */ + public function testGetMultipleThrowsBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot get items via tags in any mode'); + + $cache->getMultiple(['key1', 'key2']); + } + /** * @test */ @@ -113,6 +129,90 @@ public function testForgetThrowsBadMethodCallException(): void $cache->forget('key'); } + /** + * @test + */ + public function testTouchThrowsBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot touch items via tags in any mode'); + + $cache->touch('key', 60); + } + + /** + * @test + */ + public function testDeleteThrowsBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot forget items via tags in any mode'); + + $cache->delete('key'); + } + + /** + * @test + */ + public function testDeleteMultipleThrowsBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot forget items via tags in any mode'); + + $cache->deleteMultiple(['key1', 'key2']); + } + + /** + * @test + */ + public function testArrayAccessReadOperationsThrowBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + try { + $cache['key']; + $this->fail('ArrayAccess get should throw.'); + } catch (BadMethodCallException $e) { + $this->assertStringContainsString('Cannot get items via tags in any mode', $e->getMessage()); + } + + try { + isset($cache['key']); + $this->fail('ArrayAccess exists should throw.'); + } catch (BadMethodCallException $e) { + $this->assertStringContainsString('Cannot check existence via tags in any mode', $e->getMessage()); + } + } + + /** + * @test + */ + public function testArrayAccessUnsetThrowsBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot forget items via tags in any mode'); + + unset($cache['key']); + } + /** * @test */ @@ -152,15 +252,19 @@ public function testPutWithNullTtlCallsForever(): void /** * @test */ - public function testPutWithZeroTtlReturnsFalse(): void + public function testPutWithZeroTtlDeletesPlainKey(): void { $connection = $this->mockConnection(); + $connection->shouldReceive('evalWithShaCache') + ->once() + ->andReturn(1); + $store = $this->createStore($connection); $cache = $store->setTagMode('any')->tags(['users', 'posts']); $result = $cache->put('mykey', 'myvalue', 0); - $this->assertFalse($result); + $this->assertTrue($result); } /** @@ -234,15 +338,61 @@ public function testPutManyWithNullTtlCallsForeverForEach(): void /** * @test */ - public function testPutManyWithZeroTtlReturnsFalse(): void + public function testPutManyWithZeroTtlDeletesPlainKeys(): void { $connection = $this->mockConnection(); + $connection->shouldReceive('evalWithShaCache') + ->once() + ->andReturn(1); + $store = $this->createStore($connection); $cache = $store->setTagMode('any')->tags(['users']); $result = $cache->putMany(['key1' => 'value1'], 0); - $this->assertFalse($result); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testSetMultipleWritesThroughTaggedPath(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('pipeline')->andReturn($connection); + $connection->shouldReceive('smembers')->andReturn($connection); + $connection->shouldReceive('exec')->andReturn([[], []]); + $connection->shouldReceive('setex')->andReturn($connection); + $connection->shouldReceive('del')->andReturn($connection); + $connection->shouldReceive('sadd')->andReturn($connection); + $connection->shouldReceive('expire')->andReturn($connection); + $connection->shouldReceive('hSet')->andReturn($connection); + $connection->shouldReceive('hexpire')->andReturn($connection); + $connection->shouldReceive('zadd')->andReturn($connection); + + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users']); + + $this->assertTrue($cache->setMultiple(['key1' => 'value1', 'key2' => 'value2'], 60)); + } + + /** + * @test + */ + public function testArrayAccessSetWritesThroughTaggedPath(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('evalWithShaCache') + ->once() + ->andReturn(true); + + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users']); + + $cache['mykey'] = 'myvalue'; + + $this->assertTrue(true); } /** @@ -417,6 +567,30 @@ public function testFlushDeletesAllTaggedItems(): void $this->assertTrue($result); } + /** + * @test + */ + public function testClearFlushesTaggedItems(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('hlen') + ->andReturn(2); + $connection->shouldReceive('hkeys') + ->once() + ->andReturn(['key1', 'key2']); + $connection->shouldReceive('pipeline')->andReturn($connection); + $connection->shouldReceive('del')->andReturn($connection); + $connection->shouldReceive('unlink')->andReturn($connection); + $connection->shouldReceive('zrem')->andReturn($connection); + $connection->shouldReceive('exec')->andReturn([2, 1]); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->clear(); + + $this->assertTrue($result); + } + /** * @test */ @@ -466,6 +640,49 @@ public function testRememberCallsCallbackAndStoresValueWhenMiss(): void $this->assertSame(1, $callCount); } + public function testRememberNormalizesEnumKeyForRedisAndEvents(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:profile') + ->andReturnNull(); + + $connection->shouldReceive('evalWithShaCache') + ->once() + ->withArgs(function (string $script, array $keys, array $args): bool { + $this->assertSame('prefix:profile', $keys[0]); + $this->assertSame('profile', $args[5]); + + return true; + }) + ->andReturn(true); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function (object $event) use (&$captured): void { + $captured[] = $event; + }); + + $tagged = $this->createStore($connection)->setTagMode('any')->tags(['users']); + $tagged->setEventDispatcher($events); + + $result = $tagged->remember(AnyTaggedCacheTestKey::Profile, 60, fn () => 'computed_value'); + + $this->assertSame('computed_value', $result); + + $cacheMissed = array_values(array_filter($captured, fn (object $event) => $event instanceof CacheMissed))[0] ?? null; + $keyWritten = array_values(array_filter($captured, fn (object $event) => $event instanceof KeyWritten))[0] ?? null; + + $this->assertNotNull($cacheMissed); + $this->assertSame('profile', $cacheMissed->key); + $this->assertNotNull($keyWritten); + $this->assertSame('profile', $keyWritten->key); + } + public function testRememberNullableStoresAndReturnsNonNullValue(): void { $connection = $this->mockConnection(); @@ -623,6 +840,36 @@ public function testRememberForeverRetrievesExistingValueFromStore(): void $this->assertSame('cached_value', $result); } + public function testRememberForeverNormalizesEnumKeyForRedisAndEvents(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:settings') + ->andReturn(serialize('cached_settings')); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function (object $event) use (&$captured): void { + $captured[] = $event; + }); + + $tagged = $this->createStore($connection)->setTagMode('any')->tags(['users']); + $tagged->setEventDispatcher($events); + + $result = $tagged->rememberForever(AnyTaggedCacheTestKey::Settings, fn () => 'new_settings'); + + $this->assertSame('cached_settings', $result); + + $cacheHit = array_values(array_filter($captured, fn (object $event) => $event instanceof CacheHit))[0] ?? null; + + $this->assertNotNull($cacheHit); + $this->assertSame('settings', $cacheHit->key); + } + /** * @test */ @@ -881,3 +1128,9 @@ public function testFlexibleNullableThrowsBadMethodCallException(): void $cache->flexibleNullable('mykey', [60, 120], fn () => 'v'); } } + +enum AnyTaggedCacheTestKey: string +{ + case Profile = 'profile'; + case Settings = 'settings'; +} diff --git a/tests/Cache/Redis/Console/DoctorCommandTest.php b/tests/Cache/Redis/Console/DoctorCommandTest.php index 93b11cd6f..e9a0b0d86 100644 --- a/tests/Cache/Redis/Console/DoctorCommandTest.php +++ b/tests/Cache/Redis/Console/DoctorCommandTest.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Cache\CacheManager; +use Hypervel\Cache\Redis\Console\Doctor\Checks\HashFieldExpirationCheck; use Hypervel\Cache\Redis\Console\Doctor\DoctorContext; use Hypervel\Cache\Redis\Console\DoctorCommand; use Hypervel\Cache\Redis\Support\StoreContext; @@ -19,6 +20,7 @@ use Hypervel\Redis\RedisConnection; use Hypervel\Testbench\TestCase; use Mockery as m; +use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\NullOutput; @@ -28,6 +30,116 @@ */ class DoctorCommandTest extends TestCase { + public function testHashFieldExpirationCheckSkipsAllMode(): void + { + $connection = m::mock(RedisConnection::class); + $connection->shouldNotReceive('hsetex'); + $connection->shouldNotReceive('hexpire'); + $connection->shouldNotReceive('del'); + + $check = new HashFieldExpirationCheck($connection, 'all'); + $result = $check->run(); + + $this->assertTrue($result->passed()); + $this->assertSame([ + [ + 'passed' => true, + 'description' => 'Hash-field expiration check skipped (not required for all mode)', + ], + ], $result->assertions); + $this->assertNull($check->getFixInstructions()); + } + + public function testHashFieldExpirationCheckProbesHsetexAndHexpireInAnyMode(): void + { + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('hsetex') + ->once() + ->with(m::pattern('/^erc:doctor:hash-field-expiration-test:/'), ['field' => '1'], ['EX' => 60]) + ->andReturn(true); + $connection->shouldReceive('hexpire') + ->once() + ->with(m::pattern('/^erc:doctor:hash-field-expiration-test:/'), 60, ['field']) + ->andReturn([1]); + $connection->shouldReceive('del') + ->once() + ->with(m::pattern('/^erc:doctor:hash-field-expiration-test:/')) + ->andReturn(1); + + $check = new HashFieldExpirationCheck($connection, 'any'); + $result = $check->run(); + + $this->assertTrue($result->passed()); + $this->assertSame([ + [ + 'passed' => true, + 'description' => 'HSETEX and HEXPIRE commands are available', + ], + ], $result->assertions); + $this->assertNull($check->getFixInstructions()); + } + + public function testHashFieldExpirationCheckReportsFixInstructionsWhenProbeFails(): void + { + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('hsetex') + ->once() + ->with(m::pattern('/^erc:doctor:hash-field-expiration-test:/'), ['field' => '1'], ['EX' => 60]) + ->andThrow(new RuntimeException('unsupported command')); + $connection->shouldReceive('hexpire')->never(); + $connection->shouldReceive('del') + ->once() + ->with(m::pattern('/^erc:doctor:hash-field-expiration-test:/')) + ->andReturn(0); + + $check = new HashFieldExpirationCheck($connection, 'any'); + $result = $check->run(); + + $this->assertFalse($result->passed()); + $this->assertSame([ + [ + 'passed' => false, + 'description' => 'HSETEX and HEXPIRE commands are available', + ], + ], $result->assertions); + $this->assertSame( + 'Any tagging mode requires Redis 8.0+ or Valkey 9.0+ for hash-field expiration commands such as HSETEX and HEXPIRE. Upgrade your Redis/Valkey server, or switch to all tagging mode.', + $check->getFixInstructions() + ); + } + + public function testHashFieldExpirationCheckReportsFailureWhenProbeReturnsFalse(): void + { + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('hsetex') + ->once() + ->with(m::pattern('/^erc:doctor:hash-field-expiration-test:/'), ['field' => '1'], ['EX' => 60]) + ->andReturn(false); + $connection->shouldReceive('hexpire') + ->once() + ->with(m::pattern('/^erc:doctor:hash-field-expiration-test:/'), 60, ['field']) + ->andReturn(false); + $connection->shouldReceive('del') + ->once() + ->with(m::pattern('/^erc:doctor:hash-field-expiration-test:/')) + ->andReturn(0); + + $check = new HashFieldExpirationCheck($connection, 'any'); + $result = $check->run(); + + $this->assertFalse($result->passed()); + $this->assertSame([ + [ + 'passed' => false, + 'description' => 'HSETEX and HEXPIRE commands are available', + ], + ], $result->assertions); + $this->assertSame( + 'Any tagging mode requires Redis 8.0+ or Valkey 9.0+ for hash-field expiration commands such as HSETEX and HEXPIRE. Upgrade your Redis/Valkey server, or switch to all tagging mode.', + $check->getFixInstructions() + ); + } + public function testDoctorFailsForNonRedisStore(): void { $nonRedisStore = m::mock(Store::class); diff --git a/tests/Cache/Redis/RedisStoreTest.php b/tests/Cache/Redis/RedisStoreTest.php index ca719dd55..68e17bbbe 100644 --- a/tests/Cache/Redis/RedisStoreTest.php +++ b/tests/Cache/Redis/RedisStoreTest.php @@ -188,6 +188,70 @@ public function testSetTagModeFallsBackToAllForInvalidMode(): void $this->assertSame(TagMode::All, $redis->getTagMode()); } + /** + * @test + */ + public function testTouchUsesPlainExpireInAllMode(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('expire') + ->once() + ->with('prefix:key', 60) + ->andReturn(1); + + $redis = $this->createStore($connection); + + $this->assertTrue($redis->touch('key', 60)); + } + + /** + * @test + */ + public function testTouchUsesAnyTagMetadataOperationInAnyMode(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('evalWithShaCache') + ->once() + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + + $this->assertTrue($redis->touch('key', 60)); + } + + /** + * @test + */ + public function testForgetUsesPlainDeleteInAllMode(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('del') + ->once() + ->with('prefix:key') + ->andReturn(1); + + $redis = $this->createStore($connection); + + $this->assertTrue($redis->forget('key')); + } + + /** + * @test + */ + public function testForgetUsesAnyTagMetadataOperationInAnyMode(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('evalWithShaCache') + ->once() + ->andReturn(1); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + + $this->assertTrue($redis->forget('key')); + } + /** * @test */ diff --git a/tests/Integration/Cache/Redis/HashLifecycleIntegrationTest.php b/tests/Integration/Cache/Redis/HashLifecycleIntegrationTest.php index 2116f27ca..751e8a5ee 100644 --- a/tests/Integration/Cache/Redis/HashLifecycleIntegrationTest.php +++ b/tests/Integration/Cache/Redis/HashLifecycleIntegrationTest.php @@ -101,7 +101,7 @@ public function testKeepsHashAliveWhileAnyFieldRemainsUnexpired(): void // ORPHANED FIELDS BEHAVIOR (LAZY CLEANUP MODE) // ========================================================================= - public function testCreatesOrphanedFieldsWhenCacheKeyDeletedButFieldRemains(): void + public function testCreatesOrphanedFieldsWhenCacheKeyDeletedOutsideCacheForget(): void { // Create forever item (no field expiration) Cache::tags(['orphan-test'])->forever('lifecycle:orphan', 'value'); @@ -111,15 +111,15 @@ public function testCreatesOrphanedFieldsWhenCacheKeyDeletedButFieldRemains(): v $this->assertRedisKeyExists($tagHash); $this->assertEquals(1, $this->redis()->hlen($tagHash)); - // Manually delete the cache key (simulates flush of another tag) - Cache::forget('lifecycle:orphan'); + // Delete the cache key outside the metadata-aware forget path. + $prefix = $this->getCachePrefix(); + $this->redis()->del($prefix . 'lifecycle:orphan'); // Hash field still exists even though cache key is gone $this->assertRedisKeyExists($tagHash); $this->assertEquals(1, $this->redis()->hlen($tagHash)); // The field is now "orphaned" - points to non-existent cache key - $prefix = $this->getCachePrefix(); $this->assertFalse($this->redis()->exists($prefix . 'lifecycle:orphan') > 0); // This is what prune command is designed to clean up @@ -135,8 +135,8 @@ public function testOrphanedFieldsFromLazyModeFlushExpireNaturallyIfTheyHaveTtl( $this->assertRedisKeyExists($tagHash); $this->assertEquals(1, $this->redis()->hlen($tagHash)); - // Simulate flush by deleting cache key but leaving field - Cache::forget('lifecycle:temp'); + // Delete the cache key outside the metadata-aware forget path. + $this->redis()->del($this->getCachePrefix() . 'lifecycle:temp'); // Orphaned field still exists $this->assertRedisKeyExists($tagHash); diff --git a/tests/Integration/Cache/Redis/PruneIntegrationTest.php b/tests/Integration/Cache/Redis/PruneIntegrationTest.php index 480406eb3..6c48195ee 100644 --- a/tests/Integration/Cache/Redis/PruneIntegrationTest.php +++ b/tests/Integration/Cache/Redis/PruneIntegrationTest.php @@ -15,6 +15,7 @@ * - Prune command removes orphaned entries * - Prune preserves valid entries * - Prune deletes empty tag structures + * - Plain any-mode forget removes tag membership immediately */ class PruneIntegrationTest extends RedisCacheIntegrationTestCase { @@ -54,26 +55,24 @@ public function testAnyModeFlushLeavesOrphanedFieldsInOtherTags(): void ); } - public function testAnyModeForgetLeavesOrphanedFields(): void + public function testAnyModeForgetRemovesTagMembership(): void { $this->setTagMode(TagMode::Any); Cache::tags(['posts', 'user:1'])->put('post:1', 'data', 60); - // Forget the item directly + // Forget the item directly. Cache::forget('post:1'); - // Cache key should be gone + // Cache key and tag membership should be gone. $this->assertNull(Cache::get('post:1')); - - // But tag hash fields remain (orphaned) - $this->assertTrue( + $this->assertFalse( $this->anyModeTagHasEntry('posts', 'post:1'), - 'posts hash should have orphaned field' + 'posts hash should not retain membership for forgotten key' ); - $this->assertTrue( + $this->assertFalse( $this->anyModeTagHasEntry('user:1', 'post:1'), - 'user:1 hash should have orphaned field' + 'user:1 hash should not retain membership for forgotten key' ); } @@ -118,8 +117,9 @@ public function testAnyModePruneDeletesEmptyTagHashes(): void // Verify hash exists $this->assertRedisKeyExists($this->anyModeTagKey('user:1')); - // Forget item (leaves orphaned field) - Cache::forget('post:1'); + // Delete the cache key outside the metadata-aware forget path to + // simulate an orphaned field left by another invalidation path. + $this->redis()->del($this->getCachePrefix() . 'post:1'); // Orphan exists $this->assertTrue($this->anyModeTagHasEntry('user:1', 'post:1')); diff --git a/tests/Integration/Cache/Redis/StackTaggedCacheIntegrationTest.php b/tests/Integration/Cache/Redis/StackTaggedCacheIntegrationTest.php new file mode 100644 index 000000000..52c9ec3de --- /dev/null +++ b/tests/Integration/Cache/Redis/StackTaggedCacheIntegrationTest.php @@ -0,0 +1,137 @@ +tempDir = ParallelTesting::tempDir('StackTaggedCacheIntegrationTest'); + + $filesystem = new Filesystem; + $filesystem->deleteDirectory($this->tempDir); + $filesystem->makeDirectory($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + if ($this->tempDir !== null) { + (new Filesystem)->deleteDirectory($this->tempDir); + } + + parent::tearDown(); + } + + public function testTaggedWriteIsReadablePlainAndIndexedInRedis(): void + { + $this->setTagMode(TagMode::Any); + + $stack = $this->stackCache(); + + $this->assertTrue($stack->tags(['stack-tag'])->put('stack-key', 'value', 30)); + + $this->assertSame('value', $stack->get('stack-key')); + $this->assertTrue($this->anyModeTagHasEntry('stack-tag', 'stack-key')); + $this->assertContains('stack-tag', $this->getAnyModeReverseIndex('stack-key')); + } + + public function testTagFlushLeavesOnlyBoundedL1Staleness(): void + { + $this->setTagMode(TagMode::Any); + + $stack = $this->stackCache(l1Ttl: 1); + + $stack->tags(['stack-tag'])->put('stack-key', 'value', 30); + $this->assertSame('value', $stack->get('stack-key')); + + $stack->tags(['stack-tag'])->flush(); + + $this->assertNull($this->cache()->get('stack-key')); + $this->assertSame('value', $stack->get('stack-key')); + + sleep(2); + + $this->assertNull($stack->get('stack-key')); + } + + public function testNonTaggableLayerBelowRedisWouldResurrectFlushedValues(): void + { + $this->setTagMode(TagMode::Any); + + $stack = new StackStore([ + new StackStoreProxy($this->store()), + new StackStoreProxy($this->fileStore()), + ]); + + $this->expectException(NotSupportedException::class); + + $stack->tags(['stack-tag']); + } + + public function testReadBackfillsL1FromTaggedL2Record(): void + { + $this->setTagMode(TagMode::Any); + + $file = $this->fileStore(); + $stack = $this->stackCache(file: $file); + + $expiration = time() + 30; + + $this->cache()->tags(['stack-tag'])->put('stack-key', [ + 'value' => 'from-redis', + 'expiration' => $expiration, + ], 30); + + $this->assertSame('from-redis', $stack->get('stack-key')); + $this->assertSame([ + 'value' => 'from-redis', + 'expiration' => $expiration, + ], $file->get('stack-key')); + } + + public function testPlainForgetPreventsTagFlushFromDeletingReusedStackKey(): void + { + $this->setTagMode(TagMode::Any); + + $stack = $this->stackCache(); + + $stack->tags(['stack-tag'])->put('stack-key', 'tagged', 30); + + $this->assertTrue($stack->forget('stack-key')); + + $stack->put('stack-key', 'plain', 30); + $stack->tags(['stack-tag'])->flush(); + + $this->assertSame('plain', $stack->get('stack-key')); + } + + private function stackCache(?FileStore $file = null, int $l1Ttl = 1): CacheRepository + { + return new CacheRepository(new StackStore([ + new StackStoreProxy($file ?? $this->fileStore(), $l1Ttl), + new StackStoreProxy($this->store()), + ])); + } + + private function fileStore(): FileStore + { + assert($this->tempDir !== null); + + return new FileStore(new Filesystem, $this->tempDir); + } +} diff --git a/tests/Integration/Cache/Redis/TagConsistencyIntegrationTest.php b/tests/Integration/Cache/Redis/TagConsistencyIntegrationTest.php index b4773f91f..2fdc9342c 100644 --- a/tests/Integration/Cache/Redis/TagConsistencyIntegrationTest.php +++ b/tests/Integration/Cache/Redis/TagConsistencyIntegrationTest.php @@ -221,7 +221,7 @@ public function testAllModeFlushLeavesOrphansInSharedTags(): void // FORGET CLEANUP - ANY MODE WITH REVERSE INDEX // ========================================================================= - public function testAnyModeForgetLeavesOrphanedTagEntries(): void + public function testAnyModePlainForgetCleansTagMembership(): void { $this->setTagMode(TagMode::Any); @@ -239,16 +239,45 @@ public function testAnyModeForgetLeavesOrphanedTagEntries(): void // Verify item is gone from cache $this->assertNull(Cache::get('forget-me')); - // Orphaned entries remain in tag hashes (cleaned up by prune command) - $this->assertTrue( - $this->anyModeTagHasEntry('tag-x', 'forget-me'), - 'Orphaned entry should remain in tag hash until prune' - ); - $this->assertTrue($this->anyModeTagHasEntry('tag-y', 'forget-me')); - $this->assertTrue($this->anyModeTagHasEntry('tag-z', 'forget-me')); + // Tag membership is removed immediately, so a future tag flush cannot + // delete an unrelated value written later at the same plain key. + $this->assertFalse($this->anyModeTagHasEntry('tag-x', 'forget-me')); + $this->assertFalse($this->anyModeTagHasEntry('tag-y', 'forget-me')); + $this->assertFalse($this->anyModeTagHasEntry('tag-z', 'forget-me')); + + // Reverse index is removed with the key. + $this->assertSame([], $this->getAnyModeReverseIndex('forget-me')); + } + + public function testAnyModePlainForgetPreventsTagFlushFromDeletingReusedKey(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['tag-x'])->put('reused-key', 'tagged', 60); + + $this->assertTrue(Cache::forget('reused-key')); + + Cache::put('reused-key', 'plain', 60); + Cache::tags(['tag-x'])->flush(); + + $this->assertSame('plain', Cache::get('reused-key')); + } + + public function testAnyModePlainForgetOfUntaggedKeyDeletesValue(): void + { + $this->setTagMode(TagMode::Any); + + Cache::put('plain-key', 'plain', 60); + + $this->assertTrue(Cache::forget('plain-key')); + $this->assertNull(Cache::get('plain-key')); + } + + public function testAnyModePlainForgetOfMissingKeyReturnsFalse(): void + { + $this->setTagMode(TagMode::Any); - // Reverse index also remains (orphaned) - $this->assertNotEmpty($this->getAnyModeReverseIndex('forget-me')); + $this->assertFalse(Cache::forget('missing-key')); } public function testAllModeForgetLeavesOrphanedTagEntries(): void diff --git a/tests/Integration/Cache/Redis/TtlHandlingIntegrationTest.php b/tests/Integration/Cache/Redis/TtlHandlingIntegrationTest.php index 711aa16c4..b16858e4d 100644 --- a/tests/Integration/Cache/Redis/TtlHandlingIntegrationTest.php +++ b/tests/Integration/Cache/Redis/TtlHandlingIntegrationTest.php @@ -6,6 +6,7 @@ use Carbon\Carbon; use DateInterval; +use Hypervel\Cache\NullSentinel; use Hypervel\Cache\TagMode; use Hypervel\Support\Facades\Cache; @@ -288,6 +289,148 @@ public function testAnyModeUpdatesTtlOnOverwrite(): void $this->assertGreaterThan(20, $ttl); } + public function testAllModeTaggedTouchExtendsKeyAndTagScore(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['touch_ttl'])->put('touch_key', 'value', 30); + + $namespacedKey = Cache::tags(['touch_ttl'])->taggedItemKey('touch_key'); + + $this->assertTrue(Cache::tags(['touch_ttl'])->touch('touch_key', 120)); + + $score = (int) $this->redis()->zScore($this->allModeTagKey('touch_ttl'), $namespacedKey); + $this->assertGreaterThan(time() + 100, $score); + $this->assertLessThanOrEqual(time() + 121, $score); + + Cache::tags(['touch_ttl'])->flushStale(); + $this->assertSame('value', Cache::tags(['touch_ttl'])->get('touch_key')); + + Cache::tags(['touch_ttl'])->flush(); + $this->assertNull(Cache::tags(['touch_ttl'])->get('touch_key')); + } + + public function testAllModeTaggedTouchShortensTagScore(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['touch_shorter'])->put('touch_key', 'value', 120); + + $namespacedKey = Cache::tags(['touch_shorter'])->taggedItemKey('touch_key'); + $scoreBefore = (int) $this->redis()->zScore($this->allModeTagKey('touch_shorter'), $namespacedKey); + + $this->assertTrue(Cache::tags(['touch_shorter'])->touch('touch_key', 30)); + + $scoreAfter = (int) $this->redis()->zScore($this->allModeTagKey('touch_shorter'), $namespacedKey); + $this->assertLessThan($scoreBefore, $scoreAfter); + $this->assertGreaterThan(time() + 20, $scoreAfter); + $this->assertLessThanOrEqual(time() + 31, $scoreAfter); + } + + public function testAllModeTaggedTouchMissingKeyReturnsFalseWithoutTagEntries(): void + { + $this->setTagMode(TagMode::All); + + $this->assertFalse(Cache::tags(['touch_missing'])->touch('missing_key', 60)); + $this->assertSame([], $this->getAllModeTagEntries('touch_missing')); + } + + public function testAllModeTaggedTouchPreservesCachedNullSentinel(): void + { + $this->setTagMode(TagMode::All); + + $invocations = 0; + $tagged = Cache::tags(['touch_nullable']); + + $this->assertNull($tagged->rememberNullable('nullable_key', 1, function () use (&$invocations): null { + ++$invocations; + + return null; + })); + + $namespacedKey = $tagged->taggedItemKey('nullable_key'); + + $this->assertTrue($tagged->touch('nullable_key', 120)); + + $score = (int) $this->redis()->zScore($this->allModeTagKey('touch_nullable'), $namespacedKey); + $this->assertGreaterThan(time() + 100, $score); + $this->assertLessThanOrEqual(time() + 121, $score); + + sleep(2); + + $tagged->flushStale(); + + $this->assertNull($tagged->rememberNullable('nullable_key', 60, function () use (&$invocations): string { + ++$invocations; + + return 'fresh'; + })); + $this->assertSame(1, $invocations); + + $this->assertTrue($tagged->touch('nullable_key', null)); + $this->assertSame(-1.0, $this->redis()->zScore($this->allModeTagKey('touch_nullable'), $namespacedKey)); + + $rawValue = $this->redis()->get($this->getCachePrefix() . $namespacedKey); + $this->assertIsString($rawValue); + $this->assertSame(NullSentinel::VALUE, unserialize($rawValue)); + + $this->assertNull($tagged->rememberNullable('nullable_key', 60, function () use (&$invocations): string { + ++$invocations; + + return 'fresh'; + })); + $this->assertSame(1, $invocations); + } + + public function testAnyModePlainTouchExtendsKeyAndTagMetadata(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['touch_ttl'])->put('touch_key', 'value', 30); + + $this->assertTrue(Cache::touch('touch_key', 120)); + + $keyTtl = $this->redis()->ttl($this->getCachePrefix() . 'touch_key'); + $this->assertGreaterThan(100, $keyTtl); + $this->assertLessThanOrEqual(120, $keyTtl); + + $reverseIndexTtl = $this->redis()->ttl($this->anyModeReverseIndexKey('touch_key')); + $this->assertGreaterThan(100, $reverseIndexTtl); + $this->assertLessThanOrEqual(120, $reverseIndexTtl); + + $fieldTtlResult = $this->redis()->httl($this->anyModeTagKey('touch_ttl'), ['touch_key']); + $fieldTtl = $fieldTtlResult[0] ?? $fieldTtlResult; + $this->assertGreaterThan(100, $fieldTtl); + $this->assertLessThanOrEqual(120, $fieldTtl); + + $registryScore = (int) $this->redis()->zScore($this->anyModeRegistryKey(), 'touch_ttl'); + $this->assertGreaterThan(time() + 100, $registryScore); + + Cache::tags(['touch_ttl'])->flush(); + $this->assertNull(Cache::get('touch_key')); + } + + public function testAnyModePlainTouchOfUntaggedKeyOnlyTouchesValue(): void + { + $this->setTagMode(TagMode::Any); + + Cache::put('plain_touch_key', 'value', 30); + + $this->assertTrue(Cache::touch('plain_touch_key', 120)); + + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'plain_touch_key'); + $this->assertGreaterThan(100, $ttl); + $this->assertSame([], $this->getAnyModeReverseIndex('plain_touch_key')); + } + + public function testAnyModePlainTouchMissingKeyReturnsFalseWithoutMetadata(): void + { + $this->setTagMode(TagMode::Any); + + $this->assertFalse(Cache::touch('missing_touch_key', 60)); + $this->assertSame([], $this->getAnyModeReverseIndex('missing_touch_key')); + } + // ========================================================================= // NON-TAGGED TTL - BOTH MODES // ========================================================================= diff --git a/tests/Integration/Cache/RedisStoreTest.php b/tests/Integration/Cache/RedisStoreTest.php index 1965083a0..8161cae5b 100644 --- a/tests/Integration/Cache/RedisStoreTest.php +++ b/tests/Integration/Cache/RedisStoreTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Integration\Cache; +use BadMethodCallException; use DateTime; use Hypervel\Cache\RedisStore; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; @@ -314,10 +315,23 @@ public function testHasSeparateLockStoreReturnsFalseWhenLockConnectionIsSame() $this->assertFalse($store->hasSeparateLockStore()); } - public function testFlushLocksThrowsExceptionWhenLockConnectionIsSame() + public function testRepositoryFlushLocksThrowsExceptionWhenLockConnectionIsSame() { - /** @var \Hypervel\Cache\RedisStore $store */ - $store = Cache::store('redis'); + $repository = Cache::store('redis'); + /** @var RedisStore $store */ + $store = $repository->getStore(); + $store->setConnection('default'); + $store->setLockConnection('default'); + + $this->expectException(BadMethodCallException::class); + + $repository->flushLocks(); + } + + public function testStoreFlushLocksThrowsExceptionWhenLockConnectionIsSame() + { + /** @var RedisStore $store */ + $store = Cache::store('redis')->getStore(); $store->setConnection('default'); $store->setLockConnection('default'); diff --git a/tests/JWT/JWTServiceProviderTest.php b/tests/JWT/JWTServiceProviderTest.php index 6b59ce22d..256948b9f 100644 --- a/tests/JWT/JWTServiceProviderTest.php +++ b/tests/JWT/JWTServiceProviderTest.php @@ -6,7 +6,12 @@ use Hypervel\Auth\AuthManager; use Hypervel\Cache\Repository as CacheRepository; +use Hypervel\Cache\StackStore; +use Hypervel\Cache\StackStoreProxy; +use Hypervel\Cache\TaggableStore; +use Hypervel\Cache\TagMode; use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Http\Request; use Hypervel\JWT\Blacklist; @@ -115,6 +120,54 @@ public function testTaggedCacheStorageUsesCacheStore(): void $repository = m::mock(CacheRepository::class); $repository->shouldReceive('supportsTags')->once()->andReturnTrue(); + $repository->shouldReceive('getStore')->once()->andReturn($this->taggableStore(TagMode::All)); + $cache = m::mock(); + $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); + + $this->app->instance('cache', $cache); + $this->app->forgetInstance(BlacklistContract::class); + + $blacklist = $this->app->make(BlacklistContract::class); + + $this->assertInstanceOf(Blacklist::class, $blacklist); + } + + public function testTaggedCacheStorageAcceptsAnyModeCacheStore(): void + { + $config = $this->app->make('config'); + $config->set('jwt.providers.storage', TaggedCache::class); + $config->set('jwt.blacklist_enabled', true); + $config->set('jwt.blacklist_grace_period', 0); + $config->set('jwt.blacklist_refresh_ttl', 20160); + + $repository = m::mock(CacheRepository::class); + $repository->shouldReceive('supportsTags')->once()->andReturnTrue(); + $repository->shouldReceive('getStore')->once()->andReturn($this->taggableStore(TagMode::Any)); + $cache = m::mock(); + $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); + + $this->app->instance('cache', $cache); + $this->app->forgetInstance(BlacklistContract::class); + + $blacklist = $this->app->make(BlacklistContract::class); + + $this->assertInstanceOf(Blacklist::class, $blacklist); + } + + public function testTaggedCacheStorageAcceptsValidStackCacheStore(): void + { + $config = $this->app->make('config'); + $config->set('jwt.providers.storage', TaggedCache::class); + $config->set('jwt.blacklist_enabled', true); + $config->set('jwt.blacklist_grace_period', 0); + $config->set('jwt.blacklist_refresh_ttl', 20160); + + $repository = m::mock(CacheRepository::class); + $repository->shouldReceive('supportsTags')->once()->andReturnTrue(); + $repository->shouldReceive('getStore')->once()->andReturn(new StackStore([ + new StackStoreProxy(m::mock(Store::class)), + new StackStoreProxy($this->taggableStore(TagMode::Any)), + ])); $cache = m::mock(); $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); @@ -136,6 +189,33 @@ public function testDisabledBlacklistAllowsNonTaggableCacheStore(): void $repository = m::mock(CacheRepository::class); $repository->shouldReceive('supportsTags')->never(); + $repository->shouldReceive('getStore')->once()->andReturn(m::mock(Store::class)); + $cache = m::mock(); + $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); + + $this->app->instance('cache', $cache); + $this->app->forgetInstance(BlacklistContract::class); + + $blacklist = $this->app->make(BlacklistContract::class); + + $this->assertInstanceOf(Blacklist::class, $blacklist); + } + + public function testDisabledBlacklistAllowsInvalidTaggableCacheStore(): void + { + $config = $this->app->make('config'); + $config->set('jwt.providers.storage', TaggedCache::class); + $config->set('jwt.blacklist_enabled', false); + $config->set('jwt.blacklist_grace_period', 0); + $config->set('jwt.blacklist_refresh_ttl', 20160); + + $store = m::mock(TaggableStore::class); + $store->shouldReceive('supportsTags')->once()->andReturnFalse(); + $store->shouldReceive('getTagMode')->never(); + + $repository = m::mock(CacheRepository::class); + $repository->shouldReceive('supportsTags')->never(); + $repository->shouldReceive('getStore')->once()->andReturn($store); $cache = m::mock(); $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); @@ -150,7 +230,10 @@ public function testDisabledBlacklistAllowsNonTaggableCacheStore(): void public function testEnabledTaggedCacheBlacklistRequiresTaggableCacheStore(): void { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('The JWT blacklist requires a taggable cache store.'); + $this->expectExceptionMessage( + 'The JWT blacklist requires a taggable cache store (all-mode or any-mode). ' + . 'Use a taggable store or set a custom jwt.providers.storage.' + ); $config = $this->app->make('config'); $config->set('jwt.providers.storage', TaggedCache::class); @@ -185,6 +268,16 @@ public function testCustomBlacklistStorageBypassesTaggedCacheRequirement(): void $this->assertInstanceOf(Blacklist::class, $blacklist); } + + protected function taggableStore(TagMode $mode): TaggableStore + { + /** @var TaggableStore $store */ + $store = m::mock(TaggableStore::class); + $store->shouldReceive('supportsTags')->zeroOrMoreTimes()->andReturnTrue(); + $store->shouldReceive('getTagMode')->once()->andReturn($mode); + + return $store; + } } class JwtServiceProviderCustomStorage implements StorageContract diff --git a/tests/JWT/Storage/TaggedCacheTest.php b/tests/JWT/Storage/TaggedCacheTest.php index be04b4d2e..ed398da5c 100644 --- a/tests/JWT/Storage/TaggedCacheTest.php +++ b/tests/JWT/Storage/TaggedCacheTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\JWT\Storage; +use Hypervel\Cache\TaggableStore; +use Hypervel\Cache\TagMode; use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\JWT\Storage\TaggedCache; use Hypervel\Tests\TestCase; @@ -19,49 +21,124 @@ class TaggedCacheTest extends TestCase protected TaggedCache $storage; - protected function setUp(): void + public function testAddTheItemToAllModeTaggedStorage(): void { - /** @var CacheRepository|MockInterface */ - $cache = m::mock(CacheRepository::class); - - $this->cache = $cache; - $this->storage = new TaggedCache($this->cache); - + $this->useStoreMode(TagMode::All); $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); + $this->cache->shouldReceive('put')->with('foo', 'bar', 10 * 60)->once(); + + $this->storage->add('foo', 'bar', 10); } - public function testAddTheItemToTaggedStorage() + public function testAddTheItemToAnyModeTaggedStorageWithDirectKeyPrefix(): void { - $this->cache->shouldReceive('put')->with('foo', 'bar', 10 * 60)->once(); + $this->useStoreMode(TagMode::Any); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); + $this->cache->shouldReceive('put')->with('jwt_blacklist:foo', 'bar', 10 * 60)->once(); $this->storage->add('foo', 'bar', 10); } - public function testAddTheItemToTaggedStorageForever() + public function testAddTheItemToAllModeTaggedStorageForever(): void { + $this->useStoreMode(TagMode::All); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); $this->cache->shouldReceive('forever')->with('foo', 'bar')->once(); $this->storage->forever('foo', 'bar'); } - public function testGetAnItemFromTaggedStorage() + public function testAddTheItemToAnyModeTaggedStorageForeverWithDirectKeyPrefix(): void + { + $this->useStoreMode(TagMode::Any); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); + $this->cache->shouldReceive('forever')->with('jwt_blacklist:foo', 'bar')->once(); + + $this->storage->forever('foo', 'bar'); + } + + public function testGetAnItemFromAllModeTaggedStorage(): void { + $this->useStoreMode(TagMode::All); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); $this->cache->shouldReceive('get')->with('foo')->once()->andReturn(['foo' => 'bar']); $this->assertSame(['foo' => 'bar'], $this->storage->get('foo')); } - public function testRemoveTheItemFromTaggedStorage() + public function testGetAnItemFromAnyModeStorageUsesPrefixedPlainKey(): void + { + $this->useStoreMode(TagMode::Any); + $this->cache->shouldReceive('tags')->never(); + $this->cache->shouldReceive('get')->with('jwt_blacklist:foo')->once()->andReturn(['foo' => 'bar']); + + $this->assertSame(['foo' => 'bar'], $this->storage->get('foo')); + } + + public function testRemoveTheItemFromAllModeTaggedStorage(): void { + $this->useStoreMode(TagMode::All); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); $this->cache->shouldReceive('forget')->with('foo')->once()->andReturn(true); $this->assertTrue($this->storage->destroy('foo')); } - public function testRemoveAllTaggedItemsFromStorage() + public function testRemoveTheItemFromAnyModeStorageUsesPrefixedPlainKey(): void { + $this->useStoreMode(TagMode::Any); + $this->cache->shouldReceive('tags')->never(); + $this->cache->shouldReceive('forget')->with('jwt_blacklist:foo')->once()->andReturn(true); + + $this->assertTrue($this->storage->destroy('foo')); + } + + public function testRemoveAllAllModeTaggedItemsFromStorage(): void + { + $this->useStoreMode(TagMode::All); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); + $this->cache->shouldReceive('flush')->withNoArgs()->once(); + + $this->storage->flush(); + } + + public function testRemoveAllAnyModeTaggedItemsFromStorageUsesUnprefixedTagName(): void + { + $this->useStoreMode(TagMode::Any); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); $this->cache->shouldReceive('flush')->withNoArgs()->once(); $this->storage->flush(); } + + public function testConstructorDoesNotReadTagModeWhenStoreDoesNotSupportTags(): void + { + /** @var CacheRepository|MockInterface */ + $cache = m::mock(CacheRepository::class); + /** @var MockInterface|TaggableStore */ + $store = m::mock(TaggableStore::class); + + $store->shouldReceive('supportsTags')->once()->andReturnFalse(); + $store->shouldReceive('getTagMode')->never(); + $cache->shouldReceive('getStore')->once()->andReturn($store); + + $storage = new TaggedCache($cache); + + $this->assertInstanceOf(TaggedCache::class, $storage); + } + + protected function useStoreMode(TagMode $mode): void + { + /** @var CacheRepository|MockInterface */ + $cache = m::mock(CacheRepository::class); + /** @var MockInterface|TaggableStore */ + $store = m::mock(TaggableStore::class); + + $store->shouldReceive('supportsTags')->once()->andReturnTrue(); + $store->shouldReceive('getTagMode')->once()->andReturn($mode); + $cache->shouldReceive('getStore')->once()->andReturn($store); + + $this->cache = $cache; + $this->storage = new TaggedCache($this->cache); + } }