[spike] single-key lease: generation in-hash instead of sidecar key#77
Conversation
…a sidecar key Alternative to the sidecar-key generation in #75, for comparison. The generation lives in a reserved field (`__utopia_gen__`) inside the value's own hash, so every lease op is single-key: - saveWithLease / purge are single-key Lua scripts → one shard → correct under Redis Cluster and multi-threaded backends (Dragonfly) with no cross-shard span. - No sidecar key → deletes the reserved-prefix collision handling and the getSize() prefix-scan exclusion entirely. Interface is unchanged (same getGeneration + saveWithLease). Trade-offs vs the sidecar approach: - getSize() can no longer cleanly exclude the generation: a purged key keeps a tiny gen-only hash until re-cached, so DBSIZE slightly over-counts purged-but-not-yet-recached keys (filtering would need an O(N) scan). - purge() does a small HLEN/HEXISTS dance to keep "true iff a value was removed". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryThis PR moves Redis lease generations from sidecar keys into the cached hash itself. The main changes are:
Confidence Score: 3/5The Redis lease conversion needs targeted fixes before merge because internal generation metadata can leak into user-facing hash operations and affect purge semantics. The changed surface is small and localized to the Redis adapter plus tests, but the in-hash generation design introduces caller-visible collisions and metadata exposure in normal adapter operations. src/Cache/Adapter/Redis.php
What T-Rex did
|
| local current = redis.call('HGET', KEYS[1], '__utopia_gen__') | ||
| if current == false then current = '0' end | ||
| if current ~= ARGV[3] then return 0 end | ||
| redis.call('HSET', KEYS[1], ARGV[1], ARGV[2]) |
There was a problem hiding this comment.
The reserved generation field is still accepted as a caller-provided cache hash. When a caller stores data with hash __utopia_gen__, this script writes the encoded cache value into the same field that getGeneration() later reads as the lease token. A later purge($key) then computes HLEN - HEXISTS(__utopia_gen__) as 0, deletes the cached value, writes a new generation, and returns false even though it removed user data. The Redis adapter should reject or escape this reserved field anywhere a caller can pass $hash.
| redis.call('DEL', KEYS[1]) | ||
| redis.call('HSET', KEYS[1], field, next) |
There was a problem hiding this comment.
Purging a key that was never cached now creates a permanent Redis hash containing only __utopia_gen__. The script returns false, but HSET still leaves the key in Redis, and getSize() now returns raw DBSIZE, so repeated invalidations of uncached IDs can monotonically inflate the reported cache size. The previous behavior excluded these internal generation markers from size.
Draft / spike for comparison — not for merge. Diffs against
feat/leasable-cache(#75) so this PR's diff is the sidecar → in-hash conversion.@loks0n re: "the interface is wider, can we delete something" — this doesn't shrink the interface (still
getGeneration+saveWithLease, which are the minimal acquire/commit pair). It deletes machinery:What it deletes
_utopia_cache_gen:sidecar key. The generation moves into a reserved field (__utopia_gen__) inside the value's own hash.getSize()prefix-scan exclusion (gone — see trade-off).saveWithLeaseandpurgebecome single-key Lua scripts (value + generation are the same key), so they run on one shard. That's correct under Redis Cluster and multi-threaded backends like Dragonfly without a multi-key/cross-shard script.What it costs
getSize()can no longer cleanly exclude the generation: a purged key keeps a tiny gen-only hash until it's re-cached, soDBSIZEslightly over-counts purged-but-not-yet-recached keys. Filtering them would need an O(N) scan + per-keyHLEN, not worth it for a diagnostic counter.purge()does a smallHLEN/HEXISTSdance to preserve "returns true iff a value was actually removed".__utopia_gen__) callers must not use (vs. one reserved key prefix before).Net
Same public surface, fewer moving parts, single-key/shard-safe — at the price of a slightly fuzzier
getSize(). Verified: Pint + PHPStan (level max) clean, lease tests + affected adapter tests green.🤖 Generated with Claude Code