Skip to content

[spike] single-key lease: generation in-hash instead of sidecar key#77

Merged
abnegate merged 1 commit into
feat/leasable-cachefrom
feat/leasable-cache-singlekey
Jun 23, 2026
Merged

[spike] single-key lease: generation in-hash instead of sidecar key#77
abnegate merged 1 commit into
feat/leasable-cachefrom
feat/leasable-cache-singlekey

Conversation

@abnegate

Copy link
Copy Markdown
Member

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

  • The separate _utopia_cache_gen: sidecar key. The generation moves into a reserved field (__utopia_gen__) inside the value's own hash.
  • The reserved-prefix collision handling (no sidecar key to collide with a value key).
  • The getSize() prefix-scan exclusion (gone — see trade-off).
  • Cross-shard exposure: saveWithLease and purge become 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, so DBSIZE slightly over-counts purged-but-not-yet-recached keys. Filtering them would need an O(N) scan + per-key HLEN, not worth it for a diagnostic counter.
  • purge() does a small HLEN/HEXISTS dance to preserve "returns true iff a value was actually removed".
  • One reserved hash field (__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

…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>
@abnegate abnegate marked this pull request as ready for review June 23, 2026 10:11
Copilot AI review requested due to automatic review settings June 23, 2026 10:11
@abnegate abnegate merged commit a8aabaa into feat/leasable-cache Jun 23, 2026
5 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@abnegate abnegate deleted the feat/leasable-cache-singlekey branch June 23, 2026 10:11
@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR moves Redis lease generations from sidecar keys into the cached hash itself. The main changes are:

  • Stores generation tokens in the reserved __utopia_gen__ hash field.
  • Rewrites leased save and purge as single-key Lua scripts.
  • Changes getGeneration() to read the in-hash field.
  • Returns raw Redis DBSIZE from getSize() and updates Redis lease tests for gen-only hashes.

Confidence Score: 3/5

The 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

T-Rex T-Rex Logs

What T-Rex did

  • I attempted to run a focused PHP repro script against the real Redis adapter, but execution is blocked because the php binary is missing and PHP tooling (php, composer) are unavailable in the runtime environment.
  • I tried to prepare the PHP runtime with Composer and start the Redis service with docker-compose, but the environment lacks both the php binary and the docker binary, preventing real Redis adapter requests or focused repro execution.
  • I compared the lease-single-key logs before and after to verify the state of assertions and sidecar/document hashes across purge and fresh save, confirming the __utopia_gen__ flag appears in the after state.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (1)

  1. src/Cache/Adapter/Redis.php, line 245-252 (link)

    P1 Hide internal field

    list() now returns the raw Redis hash fields, so leased keys expose __utopia_gen__ as if it were a user cache entry. After purge('doc:1'), the value field is gone but the generation field remains, and list('doc:1') returns a non-empty list for a key with no user values. Callers that iterate this list can try to load, touch, purge, or display internal metadata.

Reviews (1): Last reviewed commit: "spike(cache): store generation in-hash (..." | Re-trigger Greptile

Comment on lines +31 to 34
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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Reserve generation field

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.

Comment on lines +53 to +54
redis.call('DEL', KEYS[1])
redis.call('HSET', KEYS[1], field, next)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Absent purges persist

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants