Skip to content

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

Open
lakhansamani wants to merge 1 commit into
mainfrom
fix/cassandra-lwt-timestamp-mixing
Open

lakhansamani wants to merge 1 commit into
mainfrom
fix/cassandra-lwt-timestamp-mixing

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Fixes the root cause behind #813. Not a release blocker — see Impact.

Mechanism

Cassandra resolves conflicts by last-write-wins on the mutation timestamp. The two kinds of write take that timestamp from different clocks:

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)

So a plain write issued after an LWT on the same partition can carry an earlier timestamp and be discarded silently. No error is returned; the mutation simply never applies. Mixing LWT and non-LWT writes on one partition is the documented Cassandra/Scylla anti-pattern, and this is why.

Critically, a lost write is not "eventually consistent" — it is gone. That is why no read-consistency change fixes this and the fix has to be on the write side.

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. Email/phone uniqueness comes from the separate GetUserByEmail/GetUserByPhoneNumber check-then-insert and is untouched. Removing the pointless half puts every write to the table in one timestamp domain at zero cost.

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 write within clock-skew distance — microseconds. In real use the gap is seconds to months, so the plain write'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 an UpdateUser on the same user in the same request. Only the tests, which insert and modify 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 is a sub-millisecond race that reproduced about once in six runs and cannot be asserted on reliably.

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, on the hottest write path in the system.

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 both subtests that were failing in #813 (Org_Domain_Operations, User_SCIM_Fields) plus User_Operations and Delete_User_Cascade. Multiple runs; results in the PR thread. Given a 1-in-6 base rate, clean runs are supporting evidence — the mechanism analysis and the static guard are the real argument.

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

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.

This branch has not been deployed

No deployments
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