VersionAware optimistic concurrency: replaceIf(key, value) CAS on a store-stamped TSID version (lib-data-store-state-redis 1.2.0) [release] - #107
Conversation
…-data-store-state-redis 1.2.0) [release] replaceIf() compares a re-serialization of the expected value against the stored bytes, which silently requires the encoding strategy to be byte-deterministic ACROSS PROCESSES. In a multi-replica deployment the same value can serialize differently on each JVM (reflection-dependent field order, hash-based collection ordering), so a CAS attempted by any replica other than the one that wrote the entry refuses forever - observed in sched-dev, where it caused duplicate command executions and settle loops (seqeralabs/sched PR #913 rollout). update(key, mutator, attempts) is the safe primitive: an atomic read-modify-write whose CAS compares the exact raw serialized form read from the store, never a re-serialization - correct by construction under any encoding. The mutator sees the freshly read value on each retry round; null return aborts, same-instance return skips the write; the TTL is reset on every successful write matching put(); RequestIdAware values refresh their request-id mapping. The regression test drives the store through a deliberately non-deterministic encoder - the single-process equivalent of the two-JVM divergence - and was verified to fail against the re-serializing implementation before switching it to the raw comparison. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
…lue's own version (lib-data-store-state-redis 1.2.0) [release] Rework of the previous raw-compare update() approach, after design review: no mutator callback in the store contract, no new store methods - one new interface and a re-signed replaceIf. A value type opts in by implementing Versioned (version() + withVersion(long), JPA @Version style). replaceIf(key, value[, ttl]) lands only when the stored version still equals the version the value carries - i.e. the entry was not written since the read the value derives from - and persists the value with the version incremented. The version is the caller-visible write witness; atomicity between the check and the write is guaranteed by the provider raw compare on the stored form exactly as read, never a re-serialization. This removes the byte-deterministic encoding assumption that broke the previous byte-equality replaceIf in multi-replica deployments (reflection-dependent property order - observed in sched-dev, seqeralabs/sched PR #913 rollout). Entries written before versioning report version 0 and are adopted by their first successful replace, so no data migration is needed. Unconditional put() does not move the version and must be reserved for entry creation. The raw stored-form CAS moves from the StateStore contract to StateProvider, as the internal atomicity primitive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
":N} frame The versioned replaceIf previously read the entry back (one extra round-trip) and anchored the swap on the raw bytes, shipping the full expected payload to the server on every attempt. Now the whole compare-and-swap is ONE atomic server-side call: the store frames every versioned write with a leading {"@v":N} JSON property, and the Lua script peeks the stored version with GETRANGE (24 bytes) - no payload parsing, no expected value on the wire, no read-back, cost independent of the value size. The frame is written by the store at a fixed position, never by the encoding strategy; on read it is ignored by decoders as an unknown property, so readers of any code version are unaffected. Versioned values must serialize to a JSON object (enforced fail-fast). Unframed entries count as version 0 and are adopted by their first successful replace, unchanged. New StateProvider primitive replaceIfVersion(key, expected, value[, ttl]) with Redis (Lua) and local (regex head-match, entry-instance CAS) implementations, covered by provider-level tests in both suites including TTL preserve/reset semantics against real Redis via testcontainers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
…ompare CAS No new method names: StateProvider.replaceIf(key, expected, value[, ttl]) now takes the expected version (long) instead of the expected stored form, and the byte-equality compare-and-swap is removed entirely - no byte-level CAS remains anywhere in the API. Provider tests converted accordingly, including the 16-thread barrier test proving exactly one concurrent versioned replace wins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
… of incrementing
The version becomes a unique time-sorted identifier (TSID) generated by the
store on every write path - put() and putIfAbsent() included - instead of a
counter incremented only by the conditional replace. Any write now invalidates
every outstanding compare-and-swap witness, which removes the two documented
caveats of the counter design: the put-is-creation-only invariant and the ABA
across a remove-and-recreate of an entry. Callers never assign versions; they
carry forward the version of the value they read.
The interface is renamed VersionAware, matching the existing RequestIdAware
naming. The Lua head window widens to 28 bytes to cover 19-digit TSIDs. The
CAS mechanics, the {"@v":N} frame, and all signatures are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
The {"@v":N} frame is now transparent to the encoding strategy in both
directions: deserialize0 - the symmetric inverse of serialize0 - strips
the frame before decoding, so the decoder receives exactly the payload
the encoder produced, and injects the frame's version into the decoded
value through withVersion. The frame is the single source of truth for
the version: value types no longer need to serialize their version
field, strict decoders are unaffected, and an unframed entry reads as
version 0 whatever the payload carries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ider capability AbstractStateStore returns to its 1.0 contract - plain serialization, no replaceIf. The versioning logic moves to the new VersionedStateStore, typed V extends VersionAware<V> so the compare-and-swap contract is enforced at compile time; framing/stamping on write and the symmetric strip-and-inject on read live in serialize/deserialize overrides, so all inherited write and read paths pick them up without duplication. Reads are also resilient to foreign data that merely resembles a frame: version digits that do not fit a long - which no store-written frame can carry - fall back to decoding the stored form as-is at version 0, and a plain store never frames nor strips, so a genuine leading @v property of a non-versioned value is always preserved. At the provider level the versioned replaceIf moves to the standalone VersionProvider capability interface - deliberately unrelated to StateProvider, which keeps its 1.0 contract - implemented by both providers; VersionedStateStore verifies the capability at construction time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three readers of the {"@v":N} frame disagreed on digits no
store-written frame produces: the local provider crashed with an
uncaught NumberFormatException on digits overflowing a long, and the
Redis script compared the digits as a raw string, so a leading-zero or
overflowing head never matched the version recovered on read - the
adopt-at-zero semantics the read path promises could not land.
The version semantics are now numeric - leading zeros are
insignificant, digits that do not fit a signed 64-bit long count as
version 0, the same as no frame - and enforced by a single shared
parser (VersionParser) on the read path and the local provider, and
mirrored string-wise by the Lua script (leading-zero strip plus
length/range check), since Lua numbers are doubles and cannot hold a
64-bit version. A frame too long to terminate within the inspected
head window falls to version 0 by design rather than by accident.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
replaceIf(key, value) becomes sugar for replaceIf(key, value, getDuration()), the same shape as put and putIfAbsent: every write path renews the entry time-to-live, and one ttl value flows to both the CAS and the request-id mapping, so the findByRequestId index can no longer expire out of step with its entry - the mismatch is unrepresentable rather than handled. The time-to-live-preserving (KEEPTTL) variant is deleted: no consumer wants it - sched's CommandStateStoreImpl documents "the entry TTL is refreshed on every write, matching put()" and passed an explicit ttl on every replace to get exactly that. VersionProvider shrinks to the single (key, expected, value, ttl) signature, the Lua script always sets PX (dropping the Redis 6 KEEPTTL requirement), and the local provider loses the remaining-ttl computation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Versions are TSIDs stamped by the store, so a store-written frame can only carry the canonical decimal form of a long - leading zeros and overflowing digits have no producer. The machinery normalizing them (the shared VersionParser, the Lua leading-zero strip and length/range check) defended data that cannot exist and is removed. Both providers now compare the captured frame digits literally against the canonical rendering of the expected version: store-written frames always match, foreign heads that merely resemble a frame never match and are left untouched - no version parsing remains anywhere in the CAS path. The local provider mirrors the Lua comparison, including its 28-char head window. The one guard left is on read: deserialize degrades to version 0 when the frame digits do not fit a long, warning with the offending value, and every non-fully-resolved read path - unframed entry, unparseable digits, empty payload - now logs a debug. Reads through super are routed via a private decode() bridge so the IDE resolves withVersion against this class's recursive type bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…races; log the overflow once
- changelog, VersionProvider and the Lua comment claimed foreign frame-lookalikes
"never match"; that holds only while the digit run fits the head peek - a longer
run (impossible for a store-written frame) counts as version 0 like an unframed
entry, so the claim is now scoped to the window
- groovydoc: {@code {"@v":N}} - the unbalanced inner brace made the tag swallow
the prose following it (6 occurrences)
- deserialize: drop the duplicate debug on overflow, parseVersion's warn carrying
the offending digits is the single log for the event
- LocalStateProvider: import Pattern instead of the inline FQN
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pty head Store-written values are never null or empty - every write funnels through VersionedStateStore.serialize(), and the Redis provider cannot even hold a null value - but the local provider's raw put(key, null) had nothing preventing a null-valued entry, and versionOf would have thrown on it during the CAS. The Lua script already refuses the match when GETRANGE yields an empty head, where a missing key and an empty value are indistinguishable; the local mirror now refuses null and empty the same way, never throwing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude Code review summaryVerdict: approve. No correctness issue on any store-written path. Verified: window math (max store-written frame is 6 + 19 digits + terminator = 26 bytes, inside the 28-byte peek — a TSID is a positive Findings, all addressed on the branch:
🤖 Generated with Claude Code |
- README: drop the stale "Requires Redis 6.0 or later" - the requirement came from SET KEEPTTL in the revoked 1.1.0 CAS; the versioned swap uses only GETRANGE and SET PX, and no KEEPTTL remains anywhere in the module - changelog: record that a null or empty stored value never matches the CAS and never throws - the empty head of a missing key in the Redis script, mirrored by the local provider Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Four things: 1. No write returns the stamped version, including a successful Compounding it: version 0 means two different things — a value you constructed and never read carries 0, and a pre-versioning entry has no frame so it also reads back as 0. The CAS just compares numbers, so those two Both verified. So during the rolling window the same slip overwrites instead of no-op'ing — and → The README comment is wrong either way: → Then a choice: add 2. → Restore the 3. 4. An The terminator falls outside the 28-byte peek, → In the Lua and in |
|
Addressed in #108 (releases 1.2.1):
|
Problem
replaceIf(key, expected, value)(1.1.0, #104) performed the compare-and-swap on a re-serialization of the expected value, silently requiring the encoding strategy to be byte-deterministic across processes. That assumption fails in practice: Jackson orders a class's extra (non-component) accessors byClass.getDeclaredMethods()reflection, which differs between JVM instances. Observed in thesched-devrollout of seqeralabs/sched#913: two pods of the same image serializedTaskSpec's four derived accessors in different orders, so a CAS attempted by any replica other than the one that wrote the entry refused persistently — causing duplicate command executions, poison-message retry loops, and a user pipeline terminated by the fallout. Invisible in single-JVM tests, since each process is internally consistent. It also shipped the full expected payload to the server on every attempt (~270 KB twice over for sched's batch states).Design
Values carry their own optimistic-concurrency version, and the whole capability lives in dedicated types —
AbstractStateStoreandStateProviderkeep their 1.0 contracts, byte-for-byte:Both providers implement
StateProviderandVersionProvider;VersionedStateStoreaccepts a plainStateProvider— a store switches between plain and versioned by changing only itsextendsclause — and verifies the capability at construction, failing fast otherwise. A non-VersionAwarevalue type is now a compile error, not a runtime check.The version is a unique time-sorted identifier (TSID), stamped by the store on every write path —
put/putIfAbsentincluded, never assigned by callers, who only carry forward the version of the value they read. Because any write moves the version, every outstanding witness is invalidated by every write: there is no put-is-creation-only invariant and no ABA across a remove-and-recreate or TTL-expiry-and-recreate — the caveats an incremental counter would carry (an increment chain restarts at small, highly collision-prone values; and computing "previous + 1" on a blindputwould force every write through a server-side read-modify script, where the TSID keepsputa plainSET).Caller flow:
Single atomic server-side call. The store frames every versioned write with a leading
{"@v":N}JSON property — written by the store at a fixed position, never by the encoder — and the swap is one Lua script that peeks the stored version withGETRANGE(28 bytes, covering 19-digit TSIDs) and conditionallySETs the new value:The frame is transparent to the encoding strategy, symmetrically.
VersionedStateStoreadds it on write and strips it on read, injecting its version into the decoded value throughwithVersion— the decoder always receives exactly the payload the encoder produced, so no unknown-property tolerance is required of any decoder, the value type does not need to serialize its version field, and the frame is the single source of truth for the version (whatever the payload itself may carry). Versioned values must serialize to a JSON object (enforced fail-fast withIllegalStateException), and their leading@vproperty is reserved for the store.Reads are resilient to foreign data that merely resembles a frame: version digits that do not fit a long — which no store-written frame can carry — fall back to decoding the stored form as-is at version
0, never a read failure; and a plainAbstractStateStorenever frames nor strips, so a genuine leading@vproperty of a non-versioned value is always preserved.Migration
None for storage. Entries stay plain strings; the frame appears as values are written by new code. An entry written before versioning carries no frame, counts as version
0, and is adopted transparently by its first successful replace; a rolling window against older code degrades to the pre-existing behavior, never worse, and converges once the fleet is uniform.API note: the 1.1.0 raw byte-compare
replaceIfis removed — a breaking change for its one consumer (sched), which migrates in lockstep (see Follow-up).StateStore,AbstractStateStoreandStateProviderare all back to their pre-1.1.0 contracts, so plain-store consumers (lib-cmd-queue-redis,lib-pairing) are untouched.Testing
TDD throughout. Store level: the regression drives a store through a deliberately non-deterministic JSON encoder (property order and spacing rotate on every
encode()— the single-process equivalent of the cross-JVM divergence observed in dev) and passes because nothing compares serialized bytes; frame transparency is pinned by an encoder that does not serialize the version at all and throws if the frame ever reaches its decoder (round-trip, witness validity,putIfAbsentAndCountread-back); framing and fresh-stamp asserted on creation, replace, and legacy adoption; frame-authoritative reads (a spurious payloadverloses to the absent frame); the empty-object frame{"@v":N}round-trips; foreign frame-lookalikes preserved on plain stores and overflow digits degrade gracefully; stale version refused with the concurrent write preserved; missing key refused; non-JSON-object encoders and non-VersionProviderproviders rejected fail-fast;RequestIdAwaremapping refreshed. Provider level, in both Local and Redis (testcontainers) suites: version match/mismatch, unframed-counts-as-zero adoption, null/empty stored values refused without throwing, TTL reset semantics, and a 16-thread barrier test proving exactly one concurrent replace wins per round — validating the actual Lua. 54 tests across the module, all green;lib-cmd-queue-redisandlib-pairingcompile and pass unaffected.Follow-up
seqeralabs/sched PR: bump to 1.2.0, have
CommandStateStoreImplextendVersionedStateStore, add aversioncomponent toCommandState(carried through its transition methods) implementingVersionAware<CommandState>, and rebaseCommandStateStoreImpl.update()on the newreplaceIf.🤖 Generated with Claude Code