Skip to content

Commit 071d04d

Browse files
tobiajoclaude
andcommitted
Cassandra design doc: make it self-consistent for the full fence
The doc carried leftovers of the persist-only split: an empty "Scope: persist-only" heading, the lost "The replay window" heading (its content sat under the Delete section), and a paragraph arguing for persist-only against the very mode this doc describes. - Restore "The replay window" heading; drop the empty Scope heading. - Replace the persist-only advocacy with a "Compatibility and rollout" section (KIP-style) describing the actual API break - the unified Stored read/write replacing the get/persist/delete triple - and why it is accepted; move persist-only scope to Rejected alternatives. - Restructure Consistency why-first: the read side reopens evolution-gaming#732 under weak levels, then the serial level, then locality-driven guidance. - Route the intro to the user-facing Persistence page; note the fenced-gated events-recovery floor read; align Stored pseudo-notation with the ADT constructors. - Fix stale wording: deleteCompareAndSet Scaladoc still said "get reads it back as None"; persistence.md said "journal" for the input topic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGxViqVaLYb1xQyveED6Hs
1 parent 49931ac commit 071d04d

3 files changed

Lines changed: 86 additions & 79 deletions

File tree

docs/cassandra-single-writer-design.md

Lines changed: 83 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,10 @@ sidebar_label: "Cassandra single-writer design: persist only"
55
---
66

77
Design 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

5146
Three 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

5750
A 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

6861
keeping the row (and its `offset`). A stale lower-offset writer is then rejected, not resurrected; a
6962
replayed 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

8172
A delete and a re-persist are fenced on an offset that, just after recovery, can legitimately trail
8273
the 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
148139
store.)
149140

150141
So 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
153144
live snapshot); `Snapshots` holds `X` as the buffer high-water even with no buffered value, so a re-derived
154145
snapshot below `X` is dropped exactly as for a live snapshot and the owner makes progress. The deleted-key
155146
recovery 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
161152
the journal rather than reading the snapshot. A delete clears the key's journal, so the fold yields `None`
162153
and the high-water `X` survives only on the snapshot tombstone — the buffer would again start with no floor.
163154
So 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
193187
snapshot, 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,

docs/persistence.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ sidebar_label: Persistence
99
kafka-flow keeps the state of each key in memory while it processes a partition. To survive a
1010
restart or a partition rebalance without replaying the whole input topic from the beginning, that
1111
state is **persisted** and recovered when the partition is next assigned. Persistence is optional —
12-
it is only needed when reprocessing the full journal on every recovery is too expensive — and two
12+
it is only needed when reprocessing the input topic on every recovery is too expensive — and two
1313
backends are provided:
1414

1515
- **Cassandra** (`kafka-flow-persistence-cassandra`) — stores per-key *journals* (the folded

persistence-cassandra/src/main/scala/com/evolutiongaming/kafka/flow/snapshot/CassandraSnapshots.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,8 @@ class CassandraSnapshots[F[_]: Async, T](
162162
session.execute(boundStatement).void
163163
}
164164

165-
/** Deletes via an offset-gated logical tombstone (see [[Statements.prepareDelete]]); `get` reads it back as `None`.
165+
/** Deletes via an offset-gated logical tombstone (see [[Statements.prepareDelete]]); `read` surfaces it as a
166+
* [[Stored.Tombstone]] carrying the offset.
166167
*
167168
* A not-applied result is a benign no-op when the row is absent (an at-least-once replay, or a key never persisted)
168169
* and is reported as success; a not-applied result with a higher stored offset means a newer writer owns the key,

0 commit comments

Comments
 (0)