Skip to content

fix(storage): stop mixing LWT and plain writes on cassandra partitions - #818

Merged
lakhansamani merged 2 commits into
mainfrom
fix/cassandra-lwt-timestamp-mixing
Oct 15, 2026
Merged

lakhansamani merged 2 commits into
mainfrom
fix/cassandra-lwt-timestamp-mixing

Conversation

@lakhansamani

@lakhansamani lakhansamani commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the Org_Domain_Operations failure reported in #813. Not a release blocker — see Impact.

Mechanism

Cassandra and Scylla resolve conflicts by last-write-wins on the mutation timestamp, and an LWT does not take that timestamp from the same clock a plain write does.

Write Timestamp source
INSERT … IF NOT EXISTS / DELETE … IF EXISTS (LWT) the Paxos ballot — server-side. Cassandra won't accept USING TIMESTAMP on an LWT at all.
plain INSERT/UPDATE/DELETE gocql's client-side timestamp (DefaultTimestamp: true by default — gocql@v1.6.0/cluster.go:269; this repo sets no explicit USING TIMESTAMP)

A row tombstone only shadows a row whose marker (liveness timestamp) it exceeds. An LWT insert's marker is ballot-derived, so when the server clock runs ahead of the client, a plain DELETE issued afterwards is discarded silently — no error, the row simply survives. A lost write here is not "eventually consistent", it is gone, which is why no read-consistency change helps and the fix has to be on the write side.

Measured, not reasoned

50 LWT-insert-then-delete pairs per cell, gocql v1.6.0:

Engine plain DELETE DELETE … IF EXISTS
scylladb/scylla 3.0.8 (what make test-scylladb runs) 46/50 dropped 0/50
cassandra:4.1 (4.1.12) 0/50 dropped 0/50

Two things to record alongside that, so the limits of what was measured are clear:

  • The two clock domains are confirmed on both engines. On Cassandra by precision: 50/50 LWT writetimes land on an exact millisecond (UUIDGen ballot ms × 1000) against 0/50 for plain writes. On Scylla by direction: 45/50 LWT writetimes land ahead of a host clock reading taken after Exec() returned, by up to 1393µs — a client-supplied timestamp is stamped before the round trip and cannot do that. Scylla's ballot simply has microsecond precision, so the precision tell doesn't discriminate there.
  • The hazard shows up on row DELETEs, and I cannot yet explain why plain UPDATEs escape it. A plain UPDATE after an LWT insert measured 0/50 lost on both engines, in the identical loop shape that drops 46/50 of deletes. The drop and the fix are solid; the reason deletes and cell overwrites diverge is an open question, not something this PR claims to have answered.

Apache Cassandra escapes the drop only because its ballot timestamp is truncated to whole milliseconds, which floors it behind the later client timestamp. A margin, not a guarantee.

What changed

user — AddUser dropped IF NOT EXISTS. The guard was never load-bearing and the code's own comment said so: it only ever constrained the partition key, a freshly generated UUID (user.ID = uuid.New().String()) that never collides, so it enforced no uniqueness, and Exec() never read its applied flag. Email/phone uniqueness comes from the separate GetUserByEmail/GetUserByPhoneNumber check-then-insert and is untouched. DeleteUser (user.go:151) is a plain row delete on that same partition, so this was the 46/50 shape. Removing the pointless half puts every write to the table in one timestamp domain and saves a Paxos round-trip on every signup.

org_domain — here the LWT is load-bearing: the normalized domain is the partition key and first-writer-wins is the atomicity guarantee, explicitly contrasted in-code with the scim_endpoint TOCTOU race. So the insert stays an LWT and the two deletes gain IF EXISTS to match. The applied flag is discarded; the point is shared Paxos ordering. A dropped delete here was the worse of the two outcomes — the domain stayed claimed permanently, since nothing re-writes it, and the test's own comment notes that an unfreed domain is "permanently unclaimable".

After this, org_domain is all-LWT and user is all-plain.

Impact — low, and I want to be precise rather than alarming

The race needs the LWT insert and the later plain delete within clock-lead distance of each other — measured at ~0.6ms on Scylla. In real use the gap between creating a user or domain and deleting it is seconds to months, so the tombstone's timestamp is far ahead and always wins.

I checked for a production path with that proximity and there isn't one: AddUser's four call sites (signup.go:228, admin_access.go:250, magic_link_login.go:89, scim/scim.go:358) are none of them followed by a delete of the same user in the same request, and AddOrgDomain/DeleteOrgDomain are separate admin actions. Only the tests, which insert and delete microseconds apart, are close enough to lose.

So this is a latent correctness defect worth removing, not a live production bug. It does not warrant a patch release on top of 2.4.2.

Testing

TestNoTableMixesLWTAndPlainWrites pins the invariant statically — by parsing the provider's own fmt.Sprintf write statements and grouping them by schemas.Collections.X — because the live failure depends on sub-millisecond clock lead between two processes and cannot be asserted on reliably in CI.

Mutation-verified: reverting either delete to a plain DELETE makes it fail with OrgDomain mixes LWT and plain writes on one partition.

It carries a deliberate allow-list of the tables that still mix, so the remaining debt is visible rather than silent:

  • authenticators — an LWT insert and an LWT update sit alongside two plain updates and a plain delete; which way to converge needs deciding, not guessing.
  • session_tokens / oauth_states — plain insert, LWT deletes. Those deletes must stay LWT: the applied flag is the exactly-once arbiter for token consumption (session_token.go:62-68). Converging means lifting the inserts to LWT, which adds a Paxos round-trip to every login — a performance call, not a cleanup.

Both deserve their own change. The test fails if a table leaves the allow-list without being removed from it, so the list can only shrink.

make test-scylladb against a real ScyllaDB container: green, including Org_Domain_Operations and User_SCIM_Fields plus User_Operations and Delete_User_Cascade.

go build ./..., go vet ./..., gofmt clean.

What this does not fix

#813 also reported User_SCIM_Fields failing with IsActive=false not persisting. That is a cell update, which measured 0/50 lost on both engines — it is not explained by this mechanism. #813 stays open for it.

Cassandra resolves conflicts by last-write-wins on the mutation
timestamp. A lightweight transaction takes its timestamp from the
Paxos ballot (server-side); a plain write carries gocql's
client-side timestamp (DefaultTimestamp defaults to true). Those are
different clocks, so a plain write issued AFTER an LWT on the same
partition can carry an earlier timestamp and be discarded silently —
no error, the mutation simply never applies.

Two tables mixed the two, and both surfaced as read-after-write
staleness in the ScyllaDB suite (#813), roughly one run in six:

- user: AddUser used INSERT ... IF NOT EXISTS while UpdateUser and
  DeleteUser are plain. The guard was never load-bearing — it only
  constrained the partition key, a freshly generated UUID that never
  collides, and its own comment said so. Dropped, putting every write
  to the table in one timestamp domain at no cost.
- org_domain: AddOrgDomain's LWT IS load-bearing (the normalized
  domain is the partition key and first-writer-wins is the atomicity
  guarantee, explicitly contrasted with the scim_endpoint TOCTOU
  race), so the two deletes gain IF EXISTS instead. The applied flag
  is discarded; the point is the shared Paxos ordering. A dropped
  delete here left a domain claimed PERMANENTLY, since nothing
  re-writes it.

Production exposure is negligible and this is not a release blocker:
the race needs the LWT insert and the later plain write within
clock-skew distance, and no service path creates then modifies the
same row in one request — AddUser's four call sites are never
followed by an UpdateUser on the same user. Only the tests, which
insert and modify microseconds apart, are close enough to lose.

TestNoTableMixesLWTAndPlainWrites pins the invariant statically,
because the live failure is a sub-millisecond race that cannot be
asserted on reliably. It carries an explicit allow-list of the
tables that still mix — authenticators, and session_tokens /
oauth_states, whose LWT deletes must stay LWT because the applied
flag is the exactly-once consumption arbiter. Those need their own
change; the allow-list keeps the debt visible rather than silent.

Refs #813
@lakhansamani

Copy link
Copy Markdown
Contributor Author

ScyllaDB run tally, and a correction

An earlier set of runs I did for this branch was invalid and should be disregarded: I checked out main for unrelated verification while they were still running in the background. One died with open internal/storage/db/cassandradb/lwt_timestamp_domain_test.go: no such file or directory (my branch switch deleted the file mid-run), and the two after it executed against main without this fix at all.

Re-run cleanly on 8346671c with no branch switching:

Run Result Org_Domain_Operations User_SCIM_Fields Session_Token_Operations
1 0 failures PASS PASS PASS
2 0 failures PASS PASS PASS
3 0 failures PASS PASS PASS
4 0 failures PASS PASS PASS

TestNoTableMixesLWTAndPlainWrites passed in every run.

How much this proves: the pre-fix rate was roughly 1 failure in 6 runs, so four clean runs is supporting evidence, not proof. The mechanism analysis and the static guard remain the actual argument for this change. I would rather say that than imply the runs settled it.

A real finding from the discarded runs, on main

One of the runs that executed against main failed in a third subtest, which had not been seen before — provider_test.go:1073:

Error:    Should be false
Messages: a repeat delete must not claim the row

That is the single-use claim behind refresh-token rotation: the first DeleteSessionTokenByUserIDAndKey reported claimed=true without removing the row, so the repeat delete found it and claimed it too. Two callers can both believe they consumed the same token.

It is the same defect class, in session_tokens — the table this PR's allow-list defers. AddSessionToken is a plain INSERT (session_token.go:25) while the delete is an LWT DELETE … IF EXISTS (session_token.go:68), and the first delete's tombstone losing the timestamp comparison explains the sequence exactly.

So the allow-list entry is no longer theoretical. The property it leaves exposed — exactly-once refresh-token consumption — is more security-relevant than either table fixed here, even though the production bar is the same (the insert-to-rotation gap is minutes or hours in real use, so it would take minutes of clock skew rather than microseconds).

Session_Token_Operations passing in all four runs above is not evidence this PR helped it: nothing here touches that table. It is the flake not firing.

Possible better fix for the remainder

Converting AddSessionToken to an LWT would put a Paxos round on the hottest write path in the system. There is a cheaper option worth evaluating before anyone does that:

cassandraClient.DefaultTimestamp = false

Server-side coordinator timestamps for every write — the same clock domain as Paxos ballots — which would close authenticators, session_tokens and oauth_states in one line with no added Paxos cost, and would likely supersede most of this PR. The trade-off is losing client-side retry idempotency, where a retried write currently reuses its original timestamp.

Not proposing it here; it needs its own testing. Flagging it so the per-table conversions in the allow-list are not treated as the only route.

The first version said an LWT insert and a later plain write occupy
different clock domains outright. Measurement narrows it: on Scylla the
LWT's *cell* timestamps are gocql's client value, but the *row marker*
is ballot-derived and sits ahead of it. A row tombstone only shadows a
row whose marker it exceeds, so the hazard is specifically LWT insert
vs plain row DELETE, not cell updates.

50 insert-then-delete pairs per engine:

- scylladb/scylla 3.0.8 -- plain DELETE 46/50 dropped, IF EXISTS 0/50
- cassandra 4.1.12      -- plain DELETE  0/50 dropped, IF EXISTS 0/50

Cassandra escapes it only because its ballot timestamp is truncated to
whole milliseconds, flooring the marker behind the later client
timestamp. A margin, not a guarantee.

Comments only; no behaviour change.

Refs #813
@lakhansamani

lakhansamani commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Correction to my own earlier comment, with measurements

In the tally above I reported that a 300-iteration insert-delete stress loop on main found zero failures, and that a single writetime() reading placed an LWT insert inside the host's clock window — which I read as "both writes share one clock, the mechanism in this PR does not occur". That reading was wrong, and so was the conclusion. Two mistakes:

  1. One sample, against a 3.6ms-wide host window. The server clock lead here is ~0.6-1.4ms. The window was far too coarse to resolve it, so "inside the host window" carried no information and I treated it as if it did.
  2. I never checked the direction against a reading taken after the write returned. A client-supplied timestamp is stamped before the round trip, so it can never exceed a host clock read after Exec() returns. Measured properly, 45/50 LWT writetimes on Scylla do exceed it, by up to 1393µs. That is decisive, and it points the opposite way from my conclusion: LWT writes are server-stamped on Scylla too. Scylla's ballot just has microsecond precision, so the "is it an exact millisecond" tell that works on Cassandra says nothing there.

Re-measured properly, 50 LWT-insert-then-plain-delete pairs per cell, gocql v1.6.0, both engines in Docker with the engine clock ~5-6ms ahead of the host:

Engine plain DELETE DELETE … IF EXISTS
scylladb/scylla 3.0.8 46/50 silently dropped 0/50
cassandra:4.1 (4.1.12) 0/50 0/50

The row survives, no error is returned. So the defect this PR describes is real, it fires on 92% of tight pairs on the engine our own test suite runs, and IF EXISTS closes it completely.

Two things worth recording, including one I have not explained:

  • Apache Cassandra escapes it by accident. Its ballot timestamp is truncated to whole milliseconds, which floors it behind the later client timestamp. The two clock domains exist there just as clearly — 50/50 of its LWT writetimes land on an exact millisecond against 0/50 for plain writes. It is a margin, not a guarantee.
  • I cannot account for plain UPDATEs escaping the same race. An UPDATE issued right after an LWT insert measured 0/50 lost on both engines, in the identical loop shape that drops 46/50 of deletes. Under the timestamp model above it should lose too. Something about how a row tombstone is reconciled against an LWT-written row differs from a cell overwrite, and I have not pinned down what. Flagging it rather than inventing a mechanism: the drop and the fix are measured, this part is not.

This also pins down what in #813 this does and does not fix: Org_Domain_Operations ("deleted domain still found", list returned 2 not 1) is exactly the 46/50 shape and is fixed. User_SCIM_Fields (IsActive=false not persisting) is a cell update, measured 0/50 — not explained by this. #813 stays open for that one.

The probes were throwaway and are not committed — the committed guard is the static one, since the live failure depends on sub-millisecond clock lead between two processes and cannot be asserted on reliably in CI.

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.

1 participant