Skip to content

VersionAware optimistic concurrency: replaceIf(key, value) CAS on a store-stamped TSID version (lib-data-store-state-redis 1.2.0) [release] - #107

Merged
pditommaso merged 14 commits into
masterfrom
state-store-raw-cas-update
Aug 3, 2026
Merged

VersionAware optimistic concurrency: replaceIf(key, value) CAS on a store-stamped TSID version (lib-data-store-state-redis 1.2.0) [release]#107
pditommaso merged 14 commits into
masterfrom
state-store-raw-cas-update

Conversation

@pditommaso

@pditommaso pditommaso commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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 by Class.getDeclaredMethods() reflection, which differs between JVM instances. Observed in the sched-dev rollout of seqeralabs/sched#913: two pods of the same image serialized TaskSpec'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 — AbstractStateStore and StateProvider keep their 1.0 contracts, byte-for-byte:

interface VersionAware<T> {          // the value contract
    long version();
    T withVersion(long version);
}

// consumer contract — new class, versioning enforced at compile time
abstract class VersionedStateStore<V extends VersionAware<V>> extends AbstractStateStore<V> {
    boolean replaceIf(String key, V value);
    boolean replaceIf(String key, V value, Duration ttl);
}

// implementation contract — standalone capability, deliberately unrelated to StateProvider
interface VersionProvider<K,V> {
    boolean replaceIf(K key, long expected, V value, Duration ttl);
}

Both providers implement StateProvider and VersionProvider; VersionedStateStore accepts a plain StateProvider — a store switches between plain and versioned by changing only its extends clause — and verifies the capability at construction, failing fast otherwise. A non-VersionAware value 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 pathput/putIfAbsent included, 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 blind put would force every write through a server-side read-modify script, where the TSID keeps put a plain SET).

Caller flow:

var state = store.get(id);            // version as stored
var next  = state.completed(result);  // transitions carry the version through
if (!store.replaceIf(id, next))       // lands only if the entry is unchanged since the read
    // re-read, re-decide

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 with GETRANGE (28 bytes, covering 19-digit TSIDs) and conditionally SETs the new value:

  • one round-trip per CAS, no read-back
  • no expected payload on the wire — only the new value ships
  • no payload parsing server-side — cost independent of the value size
  • no re-serialization anywhere — the byte-determinism assumption is gone, not worked around

The frame is transparent to the encoding strategy, symmetrically. VersionedStateStore adds it on write and strips it on read, injecting its version into the decoded value through withVersion — 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 with IllegalStateException), and their leading @v property 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 plain AbstractStateStore never frames nor strips, so a genuine leading @v property 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 replaceIf is removed — a breaking change for its one consumer (sched), which migrates in lockstep (see Follow-up). StateStore, AbstractStateStore and StateProvider are 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, putIfAbsentAndCount read-back); framing and fresh-stamp asserted on creation, replace, and legacy adoption; frame-authoritative reads (a spurious payload ver loses 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-VersionProvider providers rejected fail-fast; RequestIdAware mapping 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-redis and lib-pairing compile and pass unaffected.

Follow-up

seqeralabs/sched PR: bump to 1.2.0, have CommandStateStoreImpl extend VersionedStateStore, add a version component to CommandState (carried through its transition methods) implementing VersionAware<CommandState>, and rebase CommandStateStoreImpl.update() on the new replaceIf.

🤖 Generated with Claude Code

pditommaso and others added 2 commits August 3, 2026 09:41
…-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>
@pditommaso pditommaso changed the title Add raw-compare update() read-modify-write to AbstractStateStore (lib-data-store-state-redis 1.2.0) [release] Versioned optimistic concurrency: replaceIf(key, value) CAS on the value's own version (lib-data-store-state-redis 1.2.0) [release] Aug 3, 2026
pditommaso and others added 3 commits August 3, 2026 11:24
":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>
@pditommaso pditommaso changed the title Versioned optimistic concurrency: replaceIf(key, value) CAS on the value's own version (lib-data-store-state-redis 1.2.0) [release] VersionAware optimistic concurrency: replaceIf(key, value) CAS on a store-stamped TSID version (lib-data-store-state-redis 1.2.0) [release] Aug 3, 2026
pditommaso and others added 6 commits August 3, 2026 12:23
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>
pditommaso and others added 2 commits August 3, 2026 16:27
…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>
@pditommaso
pditommaso requested a review from swampie August 3, 2026 14:53
@pditommaso

Copy link
Copy Markdown
Contributor Author

Claude Code review summary

Verdict: 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 long), Lua atomicity and its local mirror (CAS on entry identity), frame transparency through every write path (put, putIfAbsent, putIfAbsentAndCount), witness captured before the re-stamp, legacy adoption at version 0. Module tests green; lib-cmd-queue-redis and lib-pairing unaffected.

Findings, all addressed on the branch:

  1. Foreign frame-lookalike guarantee overstated (low, docs) — "never matches and is left untouched" holds only while the digit run fits the 28-byte head peek; a ≥22-digit run (impossible for a store-written frame) degrades to version 0 like an unframed entry. Claim scoped in the changelog, VersionProvider javadoc and the Lua comment → a13b9fd
  2. Unbalanced {@code {"@v":N} doc tags (cosmetic) — the unmatched inner brace made the tag swallow the prose after it; 6 occurrences balanced → a13b9fd
  3. Nits — duplicate log on overflow reads collapsed to the single parseVersion warn; Pattern imported instead of the inline FQN; PR body sketch showed a VersionProvider overload that doesn't exist (body corrected) → a13b9fd
  4. From review discussion (swampie: can value be null?) — a raw put(key, null) on the local provider could NPE versionOf during the CAS; a null/empty stored value now refuses the match outright, mirroring the Lua empty-head refusal, with tests on both providers → 9e9cf39

🤖 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>
@pditommaso
pditommaso merged commit a574ace into master Aug 3, 2026
3 checks passed
@jonmarti

jonmarti commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Four things:

1. No write returns the stamped version, including a successful replaceIf.

read                       → version 100
replaceIf #1               → true    (entry now version 200, caller's object still 100)
replaceIf #2 chained       → false
replaceIf #2 after re-read → true

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
collide:

never-read value (0) vs a framed entry (200) → false, safe no-op
never-read value (0) vs a legacy entry  (0)  → true, blind overwrite

Both verified. So during the rolling window the same slip overwrites instead of no-op'ing — and false is indistinguishable from contention, so a retry loop spins rather than surfacing it. sched's flow always
re-reads, so this is latent rather than active.

→ The README comment is wrong either way: false covers three causes (another writer won, key missing, caller didn't re-read) and names only the first.

→ Then a choice: add V replaceIfAndGet(key, value) returning the stamped copy, or leave the API as-is and document the re-read rule for all writes.

2. AbstractStateStoreTest's helper flipped to extends VersionedStateStore, so its ~10 tests now cover the versioned path only. put/get survive indirectly via CommandServiceTest, but TTL expiry, custom
TTL, both putIfAbsent forms, putIfAbsentAndCount + counter script, and findByRequestId now have no direct test on the plain store — which is still published contract.

→ Restore the extends, add the versioned copies as VersionedStateStoreTest.

3. serialize/deserialize should be final. A subclass override silently drops the frame, and every CAS from then on is a blind write at version 0. Nothing in the repo overrides either, so it's non-breaking
here.

4. An @v head of 22+ digits gets overwritten. Swept 17–26 digits on both providers:

digits | bytes to terminator | Local | Redis
    21 |                  28 | false | false
    22 |                  29 |  true |  true

The terminator falls outside the 28-byte peek, ver falls through to unframed, and any version-0 witness clobbers it. Same input shape as the 20-digit case you protect and test.

→ In the Lua and in versionOf: if the pattern doesn't match but the head starts {"@v":%d, refuse rather than falling through.

@pditommaso

Copy link
Copy Markdown
Contributor Author

Addressed in #108 (releases 1.2.1):

  • 4 — the window-overrun fallthrough is now refused outright in the Lua and in versionOf; your 22-digit shape is covered on both providers and at store level.
  • 2AbstractStateStoreTest is back on the plain store; versioned features moved to a new VersionedStateStoreTest.
  • 3serialize/deserialize are final on VersionedStateStore.
  • 1 — README comment reworded to cover every cause of false, and the re-read rule is documented. No replaceIfAndGet for now: no caller needs it (sched re-reads), so the documented rule covers the hazard — worth adding when a real chained-CAS caller shows up.

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.

3 participants