@@ -5,15 +5,10 @@ sidebar_label: "Cassandra single-writer design: persist only"
55---
66
77Design notes for the compare-and-set snapshot mode of ` kafka-flow-persistence-cassandra `
8- (` CassandraSnapshots.withSchema(compareAndSet = true) ` ). What ships is small — one conditional
9- predicate on each ` persist ` — so these notes spend little time on mechanics. Their weight is on the
10- design choices: where the persist-only line is drawn and why, and the full solution (offset-gated
11- deletes) that was designed, TLA+-modelled and then deferred — because the reasons it was deferred are
12- the main thing a future author needs before reopening it.
13-
14- Operational guidance (enabling, consistency levels, TTL, rollout) is in
15- [ Persistence] ( persistence.md ) . The Kafka backend solves the same problem with a different mechanism —
16- see [ Kafka single-writer design] ( kafka-single-writer-design.md ) .
8+ (` CassandraSnapshots.withSchema(compareAndSet = true) ` ) — the mechanism and its subtleties. User-facing
9+ enablement, costs and rollout guidance are in
10+ [ Persistence] ( persistence.md#protecting-against-stale-snapshot-writes ) . The Kafka backend solves the
11+ same problem differently — see [ Kafka single-writer design] ( kafka-single-writer-design.md ) .
1712
1813## Problem
1914
@@ -50,8 +45,6 @@ and treats its absence (or a null) as "row absent".
5045
5146Three choices in the predicate carry most of the design:
5247
53- ## Scope: persist-only — and what the full fence would cost
54-
5548## Delete: offset-carrying tombstone
5649
5750A delete cannot be a plain ` DELETE ` . Removing the row removes the ` offset ` guard with it, so a lagging
@@ -67,16 +60,14 @@ UPDATE snapshots_v2 SET value = null, offset = :offset WHERE <key> IF offset <=
6760
6861keeping the row (and its ` offset ` ). A stale lower-offset writer is then rejected, not resurrected; a
6962replayed delete is a no-op (equal offset) or a conflict (a newer write exists), never a resurrection;
70- a read surfaces the null ` value ` as a ` Stored.Tombstone ` (no live value). The tombstone is reaped by the TTL, if configured. Keeping
71- the row also routes the delete through Paxos, avoiding the well-known hazard of mixing lightweight
72- transactions and regular mutations on the same row.
63+ a read surfaces the null ` value ` as a ` Stored.Tombstone ` (no live value). The tombstone is reaped by
64+ the TTL, if configured. Keeping the row also routes the delete through Paxos, avoiding the well-known
65+ hazard of mixing lightweight transactions and regular mutations on the same row.
7366
74- ** A breaking API.** Gating deletes means a delete must carry an offset: an offset-carrying logical
75- * tombstone* (` SET value = null ` , keeping the row and its ` offset ` ) instead of a row removal, so a
76- lower-offset writer is rejected rather than allowed to resurrect. But that offset has to reach the store
77- through ` SnapshotWriteDatabase.delete ` , forcing a source/binary-breaking ` delete(key, offset) ` signature
78- and offset plumbing through every delete path. Persist-only makes ** no public API change** and needs no
79- major-version bump.
67+ Gating deletes has an API cost — the offset must reach the store through the delete path, a breaking
68+ change for custom stores; see [ Compatibility and rollout] ( #compatibility-and-rollout ) .
69+
70+ ## The replay window
8071
8172A delete and a re-persist are fenced on an offset that, just after recovery, can legitimately trail
8273the key's own stored snapshot. The partition resumes from the committed offset ` C ` (the minimum offset
@@ -148,8 +139,8 @@ persisted, so a replay-window tick-delete is dispatched `persist = false` — bu
148139store.)
149140
150141So recovery must surface the tombstone's offset as the floor, not collapse it to "nothing there".
151- ` SnapshotDatabase.read ` returns ` Some( Stored(None, X) )` for a tombstone — value-less but carrying the
152- offset — distinct from ` None ` for a reaped or never-written key (and from ` Some( Stored(Some(v) , X) )` for a
142+ ` SnapshotDatabase.read ` returns ` Stored.Tombstone(X ) ` for a tombstone — value-less but carrying the
143+ offset — distinct from ` None ` for a reaped or never-written key (and from ` Stored.Live(v , X) ` for a
153144live snapshot); ` Snapshots ` holds ` X ` as the buffer high-water even with no buffered value, so a re-derived
154145snapshot below ` X ` is dropped exactly as for a live snapshot and the owner makes progress. The deleted-key
155146recovery is thus symmetric with the live one — same floor, only the value is absent — and the floor is
@@ -161,11 +152,12 @@ The same hazard reaches the **events-recovery** mode (`restoreEvents`), where st
161152the journal rather than reading the snapshot. A delete clears the key's journal, so the fold yields ` None `
162153and the high-water ` X ` survives only on the snapshot tombstone — the buffer would again start with no floor.
163154So events-recovery reads the snapshot store for its tombstone floor (` ReadState ` runs ` Snapshots.read ` for
164- that side-effect) before folding the journal; the recovered state still comes from the journal. A * live* key
165- needs no floor here — its journal is intact, so the fold reconstructs ` X ` itself. Note compare-and-set
166- protects the snapshot store, which events-recovery does not read for state, so pairing the two buys no
167- stale-writer safety; seeding the floor only removes the deleted-key livelock for setups that nonetheless
168- enable both.
155+ that side-effect) before folding the journal; the recovered state still comes from the journal. The read
156+ runs only for a fencing buffer — an unfenced one never reads back a tombstone, so the per-key round-trip
157+ is skipped. A * live* key needs no floor here — its journal is intact, so the fold reconstructs ` X `
158+ itself. Note compare-and-set protects the snapshot store, which events-recovery does not read for
159+ state, so pairing the two buys no stale-writer safety; seeding the floor only removes the deleted-key
160+ livelock for setups that nonetheless enable both.
169161
170162## Equal-offset writes and determinism
171163
@@ -183,45 +175,53 @@ drop a lower-offset replay as a no-op.
183175
184176## Consistency
185177
186- The lightweight transaction reaches consensus at the * serial* consistency level, which is distinct
187- from the read/write levels in ` ConsistencyOverrides ` and defaults to ` SERIAL ` (a cross-datacenter
188- quorum). ` ConsistencyOverrides.write ` governs only the transaction's commit phase, not its consensus.
189- For single-datacenter partition ownership (the common case) set the scassandra client's
190- ` query.serial-consistency = LOCAL_SERIAL ` to keep the Paxos rounds in-DC; otherwise every conditional
191- write and delete pays a cross-datacenter round-trip. Recovery reads at ` ConsistencyOverrides.read ` ;
192- a serial read is not required, because ` R + W > N ` makes a non-serial read see every committed
178+ The write-side fence only closes #732 if recovery reads can see what fenced writes committed, so
179+ compare-and-set requires read ** and** write consistency at a quorum (` R + W > N ` ). The conditional
180+ write reaches consensus on a serial quorum but * materialises* at ` ConsistencyOverrides.write ` ; with a
181+ weaker write level a quorum recovery read can miss the newest committed snapshot even with no in-flight
182+ Paxos — #732 reintroduced on the read side, which the write-side fence does not heal. And this is not
183+ defaulted: ` ConsistencyOverrides ` is empty unless you set it, so the snapshot table inherits the
184+ session's default level (often ` LOCAL_ONE ` ) — you must configure both to a quorum.
185+
186+ A * serial* read is not required: ` R + W > N ` already makes a non-serial read see every committed
193187snapshot, and a still-in-flight write — one whose ` persist ` has not completed — is safe to miss
194188(recovery re-folds from the committed offset).
195189
196- Compare-and-set does require read and write consistency at ` QUORUM ` or stronger (so ` R + W > N ` ).
197- For single-datacenter ownership, ` LOCAL_QUORUM ` at both levels satisfies ` R + W > N ` within the
198- local DC and pairs with ` LOCAL_SERIAL ` . What matters is access locality — a key's writers and its
199- recovery read all in one DC — not the replication footprint, which may still span DCs for DR. The
200- conditional write reaches consensus on a serial quorum but * materialises* at ` ConsistencyOverrides.write ` ;
201- with a weaker write level a (non-serial) ` QUORUM ` recovery read can miss the newest committed snapshot
202- even with no in-flight Paxos, reintroducing #732 on the read side — which the write-side fence does not
203- heal. This is not a default: ` ConsistencyOverrides ` is empty unless you set it, so the snapshot table
204- inherits the session's default level (often ` LOCAL_ONE ` ) — you must configure read and write to a
205- quorum. Keep ` R + W > N ` within one consistency domain and a key's Paxos in one DC: the ` LOCAL_* ` set
206- is for single-DC ownership, while ownership that can fail over between DCs (or write a key from two
207- DCs) needs the cross-DC ` SERIAL ` / ` QUORUM ` levels.
208-
209- ## TTL and rollout
210-
211- The ` offset ` guard lives in the snapshot row, so it expires with the row's TTL. After a row's TTL
212- lapses the guard is gone and a stale write can land a fresh ` INSERT ` ; this is harmless when the TTL far
213- exceeds the rebalance/zombie overlap window (the usual case — a zombie outliving the TTL is not
214- realistic), but the monotonicity guarantee only holds within the TTL.
215-
216- Without a TTL the offset-carrying tombstone is never reaped, so a deleted key leaves its row behind
217- indefinitely and the table grows by one row per deleted key. Configure a TTL (comfortably above the
218- overlap window) for workloads that delete keys.
219-
220- Enabling on a running system needs no migration (the condition reads the ` offset ` column every version
221- already writes). The one rolling-deploy caveat: a lightweight transaction uses a coordinator-generated
222- write timestamp while a regular write uses a client-side one, so during a mixed deploy an application
223- clock running ahead of the coordinators can let an old (plain-write) instance's snapshot shadow a newer
224- conditional one. Negligible with NTP-synced clocks, and gone once every instance writes conditionally.
190+ The Paxos consensus itself runs at the * serial* consistency level, which is separate from
191+ ` ConsistencyOverrides ` (whose write level governs only the commit phase) and defaults to ` SERIAL ` — a
192+ cross-datacenter quorum. For single-datacenter partition ownership (the common case) set the scassandra
193+ client's ` query.serial-consistency = LOCAL_SERIAL ` , or every conditional write and delete pays a
194+ cross-datacenter round-trip.
195+
196+ Pick the levels by ownership locality, not by the replication footprint (which may still span DCs for
197+ DR): what matters is that a key's writers and its recovery read share one consistency domain. Single-DC
198+ ownership pairs ` LOCAL_QUORUM ` reads/writes with ` LOCAL_SERIAL ` ; ownership that can fail over between
199+ DCs (or write a key from two DCs) needs the cross-DC ` QUORUM ` / ` SERIAL ` levels.
200+
201+ ## Compatibility and rollout
202+
203+ ** A breaking store API.** Gating deletes means a delete must carry an offset to the store, and recovery
204+ must read a tombstone's offset back (the replay-window floor above). The store interface therefore
205+ exchanges one ` Stored ` unit both ways — ` read ` distinguishes a live snapshot, an offset-carrying
206+ tombstone and an absent row; ` write ` takes a ` Stored.Live ` or a ` Stored.Tombstone ` — replacing the
207+ previous ` get ` / ` persist ` / ` delete ` triple, and the per-key buffer's delete gains the offset
208+ (` delete(persist, offset) ` ). Source- and binary-breaking for custom ` SnapshotDatabase ` implementations,
209+ hence a major-version bump. The alternative — fencing persists only — needs no API change but leaves
210+ every deleted key open to resurrection (see Rejected alternatives).
211+
212+ ** Rolling deploy.** Enabling on a running system needs no data migration (the condition reads the
213+ ` offset ` column every version already writes). The one caveat: a lightweight transaction uses a
214+ coordinator-generated write timestamp while a regular write uses a client-side one, so during a mixed
215+ deploy an application clock running ahead of the coordinators can let an old (plain-write) instance's
216+ snapshot shadow a newer conditional one. Negligible with NTP-synced clocks, and gone once every
217+ instance writes conditionally.
218+
219+ ** TTL.** The ` offset ` guard lives in the snapshot row, so it expires with the row's TTL: once a row's
220+ TTL lapses a stale write can land a fresh ` INSERT ` . Harmless when the TTL far exceeds the
221+ rebalance/zombie overlap window (the usual case — a zombie outliving the TTL is not realistic), but the
222+ monotonicity guarantee only holds within the TTL. Without a TTL the offset-carrying tombstone is never
223+ reaped — the table grows by one row per deleted key — so configure a TTL (comfortably above the overlap
224+ window) for workloads that delete keys.
225225
226226## Implementation
227227
@@ -231,18 +231,18 @@ Entry point: `CassandraSnapshots.withSchema(compareAndSet = true)` (or
231231- ** Unified write / read** — ` CassandraSnapshots.write ` routes a ` Stored ` : a present value to
232232 ` persistCompareAndSet ` , an absent value (a delete) to ` deleteCompareAndSet ` , both issuing the offset-gated
233233 ` UPDATE ` /` INSERT ` ; ` resolveConditional ` classifies the result (` applied ` / newer-stored-offset /
234- row-absent), shared by both. ` read ` returns the stored unit — ` Some( Stored(value, offset)) ` , with an
235- absent value for a tombstone — or ` None ` for no row.
234+ row-absent), shared by both. ` read ` returns the stored unit — a ` Stored.Live ` or, for a tombstone, a
235+ ` Stored.Tombstone ` — or ` None ` for no row.
236236- ** Monotonic buffer** — ` Snapshots ` holds one ` Stored ` cell (a live snapshot or an offset-carrying
237237 tombstone floor); ` append ` and ` delete ` both flow through one monotonic ` put ` that drops a lower-offset
238238 re-persist and lifts a lower-offset delete to the high-water. Recovery seeds the cell (and thus the floor)
239- from ` SnapshotDatabase.read ` : ` Some( Stored(None, X) )` is a tombstone at ` X ` , ` None ` is a reaped or
239+ from ` SnapshotDatabase.read ` : ` Stored.Tombstone(X ) ` is a tombstone at ` X ` , ` None ` is a reaped or
240240 never-written key.
241241- ** Replay filter** — ` SnapshotFold ` drops replayed events above the recovered offset; a timer-driven
242242 ` TickToState ` delete bypasses that filter, which is why the monotonic buffer is what carries the
243243 tick-delete case.
244244- ** Events-recovery floor** — ` ReadState ` runs ` Snapshots.read ` for its tombstone-floor side effect
245- before folding the journal.
245+ before folding the journal, gated on ` Snapshots.fenced ` (skipped for an unfenced buffer) .
246246- ** Offset accessor** — the offset-carrying wiring passes ` Some(_.offset) ` (live fence); other stores
247247 pass ` None ` (unfenced, last-write-wins).
248248
@@ -278,16 +278,22 @@ core changes, each forced by the previous one.**
278278
279279## Rejected alternatives
280280
281- - ** Offset-as-write-timestamp (LWW register)** : write each snapshot ` USING TIMESTAMP <offset> ` and
282- let Cassandra's own last-write-wins reconciliation keep the highest-offset cell — a plain quorum
283- write, much cheaper than Paxos, and a delete becomes a tombstone ordered by offset for free.
284- Rejected: equal-offset replacement breaks (at equal timestamps Cassandra breaks ties by value, not
285- write order), a rolling deploy inverts catastrophically (old instances' wall-clock timestamps
286- dominate every offset-as-timestamp value), and it discards the real write timestamps.
287- - ** Lease / ownership table** : a per-partition lease acquired with one LWT, then cheap plain writes.
288- The lease alone does not stop a paused leaseholder's in-flight plain writes (last-write-wins still
289- applies), so a per-write fencing token is still required — at which point the lease adds only
290- liveness and expiry concerns on top of the per-write CAS.
281+ - ** Persist-only scope (deletes unfenced)** : gate ` persist ` and keep ` delete ` a plain row removal — no
282+ store-API change, no major-version bump. Rejected as the end state: removing the row removes the
283+ ` offset ` guard with it, so a zombie's lower-offset ` INSERT ... IF NOT EXISTS ` resurrects any deleted
284+ key (see Delete above) — #732 stays open exactly where keys are deleted, and the plain delete mixes a
285+ regular mutation into a lightweight-transaction row. Viable only as an interim step for workloads
286+ that never delete keys.
287+ - ** Offset-as-write-timestamp (LWW register)** : write each snapshot ` USING TIMESTAMP <offset> ` and let
288+ Cassandra's last-write-wins reconciliation keep the highest-offset cell — a plain quorum write, much
289+ cheaper than a Paxos round, and a delete becomes a tombstone ordered by offset. Rejected as the
290+ default: equal-offset replacement breaks (at equal timestamps Cassandra breaks ties by value, not
291+ write order), a rolling deploy inverts catastrophically (old instances write wall-clock timestamps
292+ that dominate every offset-as-timestamp value), and it discards the real write timestamps.
293+ - ** Lease / ownership table** : a per-partition lease acquired with one LWT, then cheap writes. The
294+ lease alone does not stop a paused leaseholder's plain writes (last-write-wins still applies), so a
295+ per-write fencing token is still required — at which point the lease only adds liveness/expiry
296+ concerns on top of the per-write CAS.
291297- ** Composite ` (offset, generation) ` token** : gate on the consumer generation as well as the offset,
292298 closing the equal-offset gap and giving per-partition (not just per-key) ownership. Couples the
293299 self-contained Cassandra module to the live consumer generation; reasonable as a future strict mode,
0 commit comments