Repository navigation
Flaky ScyllaDB storage tests: Org_Domain_Operations and User_SCIM_Fields fail on read-after-write #813
Description
Two ScyllaDB storage subtests failed once in four make test-scylladb runs on 2026-10-14, then passed on re-run with no code change. Neither is covered by CI (make test is SQLite-only), so this will only ever be seen locally or in a release check.
Failures
internal/storage/provider_test.go, TestStorageProvider/should_test_storage_provider_for_scylladb:
Org_Domain_Operations (provider_test.go:2382, :2385) — after DeleteOrgDomain(ctx, domain2):
provider_test.go:2382: Error: An error is expected but got nil.
provider_test.go:2385: Error: "[0x... 0x...]" should have 1 item(s), but has 2
GetOrgDomainByDomain(domain2) still resolved the deleted domain, and ListOrgDomainsByOrg still returned both rows.
User_SCIM_Fields (provider_test.go:2445) — after UpdateUser with IsActive=false:
provider_test.go:2445: Error: Should be false
Messages: IsActive=false must persist
What it is not
- Not a driver error. Both are assertion failures about a write not being visible on an immediate read. A missing or unbuilt secondary index surfaces as an
InvalidRequestfrom gocql, not as silently wrong rows, so this is a different class from waitForCassandraSecondaryIndex never waits: ALLOW FILTERING defeats its own probe #803. - Not the
omitemptywrite-drop class.schemas.User.IsActiveiscql:"is_active"with noomitempty(internal/storage/schemas/user.go:69), sobuildCQLColumnMapdoes includefalse. The bug that silently droppedUser.Passworddoes not apply here.
Likely cause
authorizer_org_domain_org_id is a secondary index, which ScyllaDB implements as a materialized view. ListOrgDomainsByOrg reads that view, and a base-table delete propagates to it asynchronously — returning more rows than the base table for a short window, which matches "has 2" after deleting one of two. That is MV write-propagation lag, not the index-build race in #803, and #803's fix would not close it.
User_SCIM_Fields is harder to pin: UpdateUser then GetUserByID is a primary-key path where MV lag should not apply. Worth confirming the consistency level the Cassandra provider uses for that write/read pair — if either is ONE against a multi-node setup the read can miss the write, though the test container is single-node, which argues against it.
Worth deciding
- Is the flake in the tests (they assert read-your-write against an eventually-consistent view and should read the base table, or retry) or in the provider (these reads should use a consistency level that guarantees read-your-write)? The first is a test fix; the second is a correctness bug that would affect real callers of
ListOrgDomainsByOrgright after a delete. ListOrgDomainsByOrgis used by the org-domain cascade and home-realm discovery. If MV lag is real here, a verified domain can appear claimed for a window after its org is deleted — #2365-2392's own comment notes that an unfreed domain is "permanently unclaimable", so the window matters.
Reproduction
Intermittent: 1 failure in 4 runs of make test-scylladb on the same machine. Both subtests passed in the other three runs, including on main and on the #810 and #812 branches. Not reproducible on demand yet — a loop around TestStorageProvider/should_test_storage_provider_for_scylladb/Org_Domain_Operations against a cold container is the way in.
Surfaced while verifying #812; that PR touches only audit-log query construction and does not reach either code path.
Activity
docs(storage): correct the LWT timestamp-domain rationale
fix(storage): stop mixing LWT and plain writes on cassandra partitions (
lakhansamani commentedon Oct 15, 2026
Root cause found for one of the two failures, measured
Org_Domain_Operations— fixed by #818AddOrgDomainis anINSERT … IF NOT EXISTS(a lightweight transaction, deliberately — the normalized domain is the partition key and first-writer-wins is the atomicity guarantee).DeleteOrgDomainwas a plainDELETE.An LWT takes its mutation timestamp from the Paxos ballot, i.e. the server clock. A plain
DELETEwrites a row tombstone carrying gocql's client-side timestamp (DefaultTimestampdefaults to true). A tombstone only shadows a row whose timestamp it exceeds — so when the server clock runs ahead of the client, the delete is discarded with no error and the row survives. That is precisely the reported symptom: the deleted domain was still found, and the list returned 2 instead of 1.Measured, 50 LWT-insert-then-delete pairs per cell, both engines in Docker with the engine clock ~5-6ms ahead of the host:
DELETEDELETE … IF EXISTSscylladb/scylla3.0.8 (whatmake test-scylladbruns)cassandra:4.1(4.1.12)92% on the engine the suite actually runs against. The intermittency in CI comes from the clock lead being ~0.6ms — when the gap between the two statements happens to exceed it, the delete lands.
Apache Cassandra escapes it only because its ballot timestamp is truncated to whole milliseconds, which floors it behind the later client timestamp. The two clock domains are confirmed on both engines — on Cassandra by precision (50/50 of its LWT writetimes land on an exact millisecond against 0/50 for plain writes), on Scylla by direction (45/50 land ahead of a host clock reading taken after
Exec()returned, by up to 1393µs, which a client-stamped timestamp cannot do). A margin, not a guarantee.The same shape existed on the
usertable —AddUser'sIF NOT EXISTSagainst a plainDeleteUser. #818 removes that guard (it was dead: it only constrained a freshly generated UUID partition key, and its applied flag was never read) and addsIF EXISTSto the twoorg_domaindeletes.User_SCIM_Fields— still open, and not thisIsActive=falsefailing to persist is a cell update, not a row delete. A plainUPDATEafter an LWT insert measured 0/50 lost on both engines, in the identical loop shape that drops 46/50 of deletes — so whatever breaksUser_SCIM_Fields, it is not reproducible as this. Worth saying plainly that I also cannot explain why updates escape the race when deletes don't; under the timestamp model above they should lose too. Keeping this issue open for that failure alone.Also ruled out for it, for whoever picks it up:
ListOrgDomainsByOrgusesALLOW FILTERINGon the base table andGetOrgDomainByDomainisWHERE id = ?.omitemptywrite-drop class.IsActivecarries noomitemptytag, so the field is always serialized.Unexplored leads: shared state between subtests, a host clock adjustment mid-run, and behaviour that only appears under full-suite load rather than on the isolated path.
Related, and worth its own issue
Session_Token_Operationsfailed on a separate run againstmainatprovider_test.go:1073— "a repeat delete must not claim the row". That is the mirror shape:AddSessionTokenis a plainINSERT(session_token.go:25) while the delete is an LWT (:68), and the LWT's applied flag is the exactly-once arbiter behind refresh-token rotation. #818 leaves that table on a documented allow-list rather than guessing, because converging it means lifting the insert to an LWT and adding a Paxos round-trip to every login — a performance decision, not a cleanup.test(storage): stop the user suite depending on an almost-empty table
lakhansamani commentedon Oct 15, 2026
Both failures accounted for. Two causes, neither of them flakiness in the usual sense.
Org_Domain_Operations— a real storage bug, fixed in #818AddOrgDomain's lightweight transaction was stamped from the Paxos ballot (server clock) whileDeleteOrgDomainwas a plain row delete carrying gocql's client timestamp. A tombstone only shadows a row whose timestamp it exceeds, so with the server clock ahead the delete was discarded with no error and the domain stayed claimed. Details and measurements in #818.Re-measured today on a fresh container with a ~4ms engine clock lead: 48/50 plain deletes silently dropped, 0/50 with the fix. Forcing the skew makes it 50/50. Note the rate tracks the container's instantaneous clock offset, which is why it looked intermittent — the same probe on a different container start showed 0/50.
User_SCIM_Fields— I could not reproduce it, and found why the suite looks flaky anyway0 failures in 100 targeted runs, before and after #818. I'm not able to call this one diagnosed.
What I did find is that the suite fails hard on repeated local runs, for reasons that have nothing to do with storage. Running the scylladb storage suite in a loop gave 15 consecutive full-suite failures — on
main, and equally on pre-#818:Two self-inflicted causes, fixed in #824:
ListUsersfirst-page assumption.User_Operationsasserted the just-created user appears inListUsers(Limit: 10, Offset: 0)unfiltered. That only holds while the table is nearly empty. At 463 accumulated rows it fails every time — and the failure reads exactly like a write that never landed, which is how it would get filed as a flaky storage test. Now filtered by the user's own email.+1234567891phone literal in the table. Every later run then failed earlier withuser with given phone number already exists— one failure mutating into a different, misleading one. Both phone numbers are unique per run now.Invisible in CI, which gets a fresh container per run. Deterministic locally.
Same container, keyspace holding 463+ users throughout:
User_SCIM_Fields/Org_Domain_Operations/Session_Token_Operations× 100Two more things came out of this
#824 — the
cassandradbprovider had never been tested. It was missing from the storage suite's backend list, so the provider that serves bothcassandradbandscylladbwas only ever exercised through Scylla. Pointing it atcassandra:4.1failed on the first run: three queries use CQLLIKE, which Apache Cassandra accepts only against a SASI index.GetWebhookByEventNameerrored out on every Apache Cassandra deployment, so webhook dispatch by event name never fired there. Same for two session-token delete methods.DeleteAllSessionTokensByUserIDwas also matching unanchored, so a delete for42took out tokens for142— live on ScyllaDB too.make test-cassandradbadded.Also in #824 — the
session_tokens/oauth_statestables had the mirror of theorg_domainbug: plain insert (client clock) againstDELETE … IF EXISTS(ballot). With the client clock ahead, the tombstone sits below the insert, the row survives, and Paxos still reportsapplied=true— so a single-use refresh token can be claimed twice. Measured 20/20 with the client clock 10ms ahead, 0/20 after. That is theSession_Token_Operationsfailure atprovider_test.go:1073("a repeat delete must not claim the row") reported onmainin this thread.Proposed disposition
Keep this open until #824 merges, then close it.
User_SCIM_Fieldsstays unreproduced — if it resurfaces after those land, it deserves a fresh issue rather than being tracked under a title that now describes something understood.test(storage): cover the \z half of the mongo anchor
docs(changelog): record #824 and retract the 818 margin claim
fix(storage): make the cassandradb provider correct on apache cassand…