Protect Cassandra snapshot writes from stale writers (full compare-and-set fence) - #834
Conversation
f5dda65 to
6e8480b
Compare
|
Liveness gap: a tombstoned key self-fences in the CAS replay window The replay-window fix needs the recovered offset Confirmed with a
|
|
The Cassandra part that initially looked trivial with lightweight transactions turned out to have many pitfalls. What makes it harder and a fix much more invasive is the lost offset when deleting. There are possible workarounds, but not without changing the APIs and likely also having an inner ADT representing snapshot/deleted with offset/null. Different paths for persist and delete also make it more difficult. I'm starting to consider less safety as an alternative and skip asserting offset on deletes. This is starting to spiral out of control with all the invasive changes. Maybe better to wait for https://cwiki.apache.org/confluence/display/KAFKA/KIP-939%3A+Support+Participation+in+2PC and in the meantime compare-and-set (CAS) only for persist, or simply hold back. |
d65d74f to
52aae03
Compare
…nt fixes Follow-up fixes for the Cassandra compare-and-set review: - Skip the events-recovery snapshot-store floor read for unfenced (last-write-wins) buffers: add `Snapshots.fenced` and gate the 3-arg `ReadState` on it, so only a fencing buffer pays the per-key read at recovery (#1). - Deserialize snapshots with the snapshot's topic in `CassandraSnapshots.decode`, symmetric with the persist path (#4). - Soften the spurious-first-write-conflict comment to say it fails and re-recovers, and is effectively unreachable in pure CAS mode (#2). - Fix the IT comment referencing a non-existent "CasFirstWrite model" and a stale `SnapshotDatabase.recover`/`Recovered.Deleted` reference (#6). - Add unit tests for equal-offset re-persist on a fenced cell (#7) and an at/above-floor events-recovery counterpart (#8). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YHaexsSAhmWVb2M9wQf6Bd
72760ae to
52c96ce
Compare
The PR history's core learning (evolution-gaming#834): the conditional write looked trivial, the pitfalls were all in not fencing the legitimate owner. Say that up front so the doc's weight makes sense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
The PR history's core learning (evolution-gaming#834): the conditional write looked trivial, the pitfalls were all in not fencing the legitimate owner. Say that up front so the doc's weight makes sense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
The PR history's core learning (evolution-gaming#834): the conditional write looked trivial, the pitfalls were all in not fencing the legitimate owner. Say that up front so the doc's weight makes sense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
The PR history's core learning (evolution-gaming#834): the conditional write looked trivial, the pitfalls were all in not fencing the legitimate owner. Say that up front so the doc's weight makes sense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
The PR history's core learning (evolution-gaming#834): the conditional write looked trivial, the pitfalls were all in not fencing the legitimate owner. Say that up front so the doc's weight makes sense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
Restructure docs/cassandra-single-writer-design.md to focus on why over what: brief mechanics, explicit design choices, and the learnings from the deferred full solution, mirroring the Kafka design doc's shape (Problem, mechanism, Testing, Rejected alternatives, Forward-looking): - Problem now explains why the Kafka fix does not transfer to Cassandra (no transaction to bind the offset commit into, no ownership authority), motivating a per-write, per-key fence. - The mechanism is kept brief and its three load-bearing choices are called out explicitly: per-key granularity, `<=` over `<`, and ordering by data rather than identity. - "Why the store is the whole change" makes the SnapshotFold replay dedup explicit as the property that lets persist-only ship without core changes. - A Compatibility section collects the no-API-change / no-migration / write-side-only-fence consequences. - The deferred full solution (offset-gated deletes, PR evolution-gaming#834) is retold as a forced chain - tombstone, breaking delete(key, offset) API, monotonic buffer, tombstone-floor recovery read, independent events-recovery seeding - with a livelock diagram, the TLA+ results (all defects were liveness, never safety; models in PR evolution-gaming#835), and the explicit deferral rationale (cost/benefit and KIP-939 timing). Also make the rolling-deploy clock-skew caveat in persistence.md concrete (coordinator vs client write timestamps), and update a test comment referencing a renamed doc section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jfVxUNrjxpcSFF2hgiar5
The PR history's core learning (evolution-gaming#834): the conditional write looked trivial, the pitfalls were all in not fencing the legitimate owner. Say that up front so the doc's weight makes sense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
The PR history's core learning (evolution-gaming#834): the conditional write looked trivial, the pitfalls were all in not fencing the legitimate owner. Say that up front so the doc's weight makes sense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
- §1.1 'What ships: the persist-only subset': the study audits the full (deferred, evolution-gaming#834) fence, but the shipped artifact is persist-only - persist offset-fenced, delete plain LWW; evolution-gaming#732 closed for persists, left open for deletes (the documented residual, modelled by cassandra_notomb + tested by SnapshotSpec). Records what actually ships, not only the deferred design. - claims R6: SnapshotFold's filter is the *primary* liveness mechanism in the shipped persist-only mode (no monotonic buffer), not belt-and-suspenders, and it is unit-tested (SnapshotFoldSpec) - graded code+test.
The PR history's core learning (evolution-gaming#834): the conditional write looked trivial, the pitfalls were all in not fencing the legitimate owner. Say that up front so the doc's weight makes sense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
- §1.1 'What ships: the persist-only subset': the study audits the full (deferred, evolution-gaming#834) fence, but the shipped artifact is persist-only - persist offset-fenced, delete plain LWW; evolution-gaming#732 closed for persists, left open for deletes (the documented residual, modelled by cassandra_notomb + tested by SnapshotSpec). Records what actually ships, not only the deferred design. - claims R6: SnapshotFold's filter is the *primary* liveness mechanism in the shipped persist-only mode (no monotonic buffer), not belt-and-suspenders, and it is unit-tested (SnapshotFoldSpec) - graded code+test.
- §1.1 'What ships: the persist-only subset': the study audits the full (deferred, evolution-gaming#834) fence, but the shipped artifact is persist-only - persist offset-fenced, delete plain LWW; evolution-gaming#732 closed for persists, left open for deletes (the documented residual, modelled by cassandra_notomb + tested by SnapshotSpec). Records what actually ships, not only the deferred design. - claims R6: SnapshotFold's filter is the *primary* liveness mechanism in the shipped persist-only mode (no monotonic buffer), not belt-and-suspenders, and it is unit-tested (SnapshotFoldSpec) - graded code+test.
- §1.1 'What ships: the persist-only subset': the study audits the full (deferred, evolution-gaming#834) fence, but the shipped artifact is persist-only - persist offset-fenced, delete plain LWW; evolution-gaming#732 closed for persists, left open for deletes (the documented residual, modelled by cassandra_notomb + tested by SnapshotSpec). Records what actually ships, not only the deferred design. - claims R6: SnapshotFold's filter is the *primary* liveness mechanism in the shipped persist-only mode (no monotonic buffer), not belt-and-suspenders, and it is unit-tested (SnapshotFoldSpec) - graded code+test.
Restructure docs/cassandra-single-writer-design.md to focus on why over what: brief mechanics, explicit design choices, and the learnings from the deferred full solution, mirroring the Kafka design doc's shape (Problem, mechanism, Testing, Rejected alternatives, Forward-looking): - Problem now explains why the Kafka fix does not transfer to Cassandra (no transaction to bind the offset commit into, no ownership authority), motivating a per-write, per-key fence. - The mechanism is kept brief and its three load-bearing choices are called out explicitly: per-key granularity, `<=` over `<`, and ordering by data rather than identity. - "Why the store is the whole change" makes the SnapshotFold replay dedup explicit as the property that lets persist-only ship without core changes. - A Compatibility section collects the no-API-change / no-migration / write-side-only-fence consequences. - The deferred full solution (offset-gated deletes, PR evolution-gaming#834) is retold as a forced chain - tombstone, breaking delete(key, offset) API, monotonic buffer, tombstone-floor recovery read, independent events-recovery seeding - with a livelock diagram, the TLA+ results (all defects were liveness, never safety; models in PR evolution-gaming#835), and the explicit deferral rationale (cost/benefit and KIP-939 timing). Also make the rolling-deploy clock-skew caveat in persistence.md concrete (coordinator vs client write timestamps), and update a test comment referencing a renamed doc section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jfVxUNrjxpcSFF2hgiar5
The PR history's core learning (evolution-gaming#834): the conditional write looked trivial, the pitfalls were all in not fencing the legitimate owner. Say that up front so the doc's weight makes sense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
- §1.1 'What ships: the persist-only subset': the study audits the full (deferred, evolution-gaming#834) fence, but the shipped artifact is persist-only - persist offset-fenced, delete plain LWW; evolution-gaming#732 closed for persists, left open for deletes (the documented residual, modelled by cassandra_notomb + tested by SnapshotSpec). Records what actually ships, not only the deferred design. - claims R6: SnapshotFold's filter is the *primary* liveness mechanism in the shipped persist-only mode (no monotonic buffer), not belt-and-suspenders, and it is unit-tested (SnapshotFoldSpec) - graded code+test.
- §1.1 'What ships: the persist-only subset': the study audits the full (deferred, evolution-gaming#834) fence, but the shipped artifact is persist-only - persist offset-fenced, delete plain LWW; evolution-gaming#732 closed for persists, left open for deletes (the documented residual, modelled by cassandra_notomb + tested by SnapshotSpec). Records what actually ships, not only the deferred design. - claims R6: SnapshotFold's filter is the *primary* liveness mechanism in the shipped persist-only mode (no monotonic buffer), not belt-and-suspenders, and it is unit-tested (SnapshotFoldSpec) - graded code+test.
Restructure docs/cassandra-single-writer-design.md to focus on why over what: brief mechanics, explicit design choices, and the learnings from the deferred full solution, mirroring the Kafka design doc's shape (Problem, mechanism, Testing, Rejected alternatives, Forward-looking): - Problem now explains why the Kafka fix does not transfer to Cassandra (no transaction to bind the offset commit into, no ownership authority), motivating a per-write, per-key fence. - The mechanism is kept brief and its three load-bearing choices are called out explicitly: per-key granularity, `<=` over `<`, and ordering by data rather than identity. - "Why the store is the whole change" makes the SnapshotFold replay dedup explicit as the property that lets persist-only ship without core changes. - A Compatibility section collects the no-API-change / no-migration / write-side-only-fence consequences. - The deferred full solution (offset-gated deletes, PR evolution-gaming#834) is retold as a forced chain - tombstone, breaking delete(key, offset) API, monotonic buffer, tombstone-floor recovery read, independent events-recovery seeding - with a livelock diagram, the TLA+ results (all defects were liveness, never safety; models in PR evolution-gaming#835), and the explicit deferral rationale (cost/benefit and KIP-939 timing). Also make the rolling-deploy clock-skew caveat in persistence.md concrete (coordinator vs client write timestamps), and update a test comment referencing a renamed doc section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jfVxUNrjxpcSFF2hgiar5
The PR history's core learning (evolution-gaming#834): the conditional write looked trivial, the pitfalls were all in not fencing the legitimate owner. Say that up front so the doc's weight makes sense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
- §1.1 'What ships: the persist-only subset': the study audits the full (deferred, evolution-gaming#834) fence, but the shipped artifact is persist-only - persist offset-fenced, delete plain LWW; evolution-gaming#732 closed for persists, left open for deletes (the documented residual, modelled by cassandra_notomb + tested by SnapshotSpec). Records what actually ships, not only the deferred design. - claims R6: SnapshotFold's filter is the *primary* liveness mechanism in the shipped persist-only mode (no monotonic buffer), not belt-and-suspenders, and it is unit-tested (SnapshotFoldSpec) - graded code+test.
- §1.1 'What ships: the persist-only subset': the study audits the full (deferred, evolution-gaming#834) fence, but the shipped artifact is persist-only - persist offset-fenced, delete plain LWW; evolution-gaming#732 closed for persists, left open for deletes (the documented residual, modelled by cassandra_notomb + tested by SnapshotSpec). Records what actually ships, not only the deferred design. - claims R6: SnapshotFold's filter is the *primary* liveness mechanism in the shipped persist-only mode (no monotonic buffer), not belt-and-suspenders, and it is unit-tested (SnapshotFoldSpec) - graded code+test.
- §1.1 'What ships: the persist-only subset': the study audits the full (deferred, evolution-gaming#834) fence, but the shipped artifact is persist-only - persist offset-fenced, delete plain LWW; evolution-gaming#732 closed for persists, left open for deletes (the documented residual, modelled by cassandra_notomb + tested by SnapshotSpec). Records what actually ships, not only the deferred design. - claims R6: SnapshotFold's filter is the *primary* liveness mechanism in the shipped persist-only mode (no monotonic buffer), not belt-and-suspenders, and it is unit-tested (SnapshotFoldSpec) - graded code+test.
Restructure docs/cassandra-single-writer-design.md to focus on why over what: brief mechanics, explicit design choices, and the learnings from the deferred full solution, mirroring the Kafka design doc's shape (Problem, mechanism, Testing, Rejected alternatives, Forward-looking): - Problem now explains why the Kafka fix does not transfer to Cassandra (no transaction to bind the offset commit into, no ownership authority), motivating a per-write, per-key fence. - The mechanism is kept brief and its three load-bearing choices are called out explicitly: per-key granularity, `<=` over `<`, and ordering by data rather than identity. - "Why the store is the whole change" makes the SnapshotFold replay dedup explicit as the property that lets persist-only ship without core changes. - A Compatibility section collects the no-API-change / no-migration / write-side-only-fence consequences. - The deferred full solution (offset-gated deletes, PR evolution-gaming#834) is retold as a forced chain - tombstone, breaking delete(key, offset) API, monotonic buffer, tombstone-floor recovery read, independent events-recovery seeding - with a livelock diagram, the TLA+ results (all defects were liveness, never safety; models in PR evolution-gaming#835), and the explicit deferral rationale (cost/benefit and KIP-939 timing). Also make the rolling-deploy clock-skew caveat in persistence.md concrete (coordinator vs client write timestamps), and update a test comment referencing a renamed doc section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jfVxUNrjxpcSFF2hgiar5
The PR history's core learning (evolution-gaming#834): the conditional write looked trivial, the pitfalls were all in not fencing the legitimate owner. Say that up front so the doc's weight makes sense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
- §1.1 'What ships: the persist-only subset': the study audits the full (deferred, evolution-gaming#834) fence, but the shipped artifact is persist-only - persist offset-fenced, delete plain LWW; evolution-gaming#732 closed for persists, left open for deletes (the documented residual, modelled by cassandra_notomb + tested by SnapshotSpec). Records what actually ships, not only the deferred design. - claims R6: SnapshotFold's filter is the *primary* liveness mechanism in the shipped persist-only mode (no monotonic buffer), not belt-and-suspenders, and it is unit-tested (SnapshotFoldSpec) - graded code+test.
…st-only Snapshot persistence for the transactional Kafka mode was last-write-wins, so a stale writer (a zombie that lost its partition but is still flushing) could overwrite a newer snapshot. Guard the persist path with a compare-and-set on the offset: a write lands only if it advances the stored offset, so a stale writer's flush is rejected rather than clobbering the owner's state. This is the persist-only slice; delete stays plain last-write-wins. A guard-expired row (present with a null offset, left by a partial write whose TTL expired) is repaired by a persist claiming it via IF offset = null, which breaks the INSERT-IF-NOT-EXISTS conflict-forever deadlock. The design doc (docs/cassandra-single-writer-design.md, titled ": persist only") is rewritten around the choices and their learnings: why the offset, not the consumer generation, is the fence token, and where the subtlety lives. FlowSpec is adapted to the PartitionAssignment API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
…d-set fence) Extend the persist-only compare-and-set to the full snapshot fence: both persist and delete are offset-fenced, closing the stale-writer overwrite for deletes as well. A fenced delete always writes a tombstone, even for a never-persisted key, so a later replay of an already-deleted record cannot revive it. Events recovery is guarded against the journal revive: the replay-window floor is seeded from the snapshot store and initPersisted is routed through the monotonic cell, so a recovery that replays events cannot regress the floor below an existing snapshot's offset. The guard-expired row is read as absent and repaired via IF offset = null. The Cassandra design doc is made self-consistent for the full fence (why the consumer generation cannot replace the offset in the token, and where the subtlety lives); persistence.md and the kafka design doc get the matching updates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
…key serialization Cover the full-fence behaviour: the compare-and-set test suite, the replay- fencing spec (a replayed delete after its tombstone was TTL-reaped stays a fence), the per-key serialization race (the flushCell lost-write), the read-state floor gate, and the TTL-edge timing. The Cassandra integration tests pin the image above the mode's version floor and harden the TTL-edge timing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
Stacked on (merge first): #838
Problem
The persist-only fence (#838) leaves
deletelast-write-wins, so a stale writer can still revive a just-deleted key. Events recovery has its own replay-window exposure: a journal fold could regress a key below an existing snapshot's offset and revive it.Fix
Extend the compare-and-set to the full fence: persist and delete are offset-fenced. A fenced delete always writes an offset-carrying tombstone — even for a never-persisted key — so a later replay of an already-deleted record cannot revive it. Events recovery seeds the replay-window floor from the snapshot store and routes
initPersistedthrough the monotonic cell, closing the journal revive. A guard-expired row is read as absent and repaired viaIF offset = null.The store API changes (breaking): the
persist/deletewrite pair and theget/recoverread pair collapse into oneStored[S]ADT — a live snapshot or an offset-carrying tombstone — so the read, the write and the per-key buffer reason about one monotonic cell.Tests and docs
The compare-and-set suite, the replay-fencing spec (a replayed delete after its tombstone was TTL-reaped stays a fence), the per-key serialization race (the flushCell lost-write), the read-state floor gate, and the TTL-edge timing; the Cassandra ITs pin the image above the mode's version floor. The Cassandra design doc is made self-consistent for the full fence — including why the consumer generation cannot replace the offset as the fence token; persistence.md and the kafka design doc get matching updates.