Skip to content

Flaky ScyllaDB storage tests: Org_Domain_Operations and User_SCIM_Fields fail on read-after-write #813

Open

Description

@lakhansamani

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 InvalidRequest from 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 omitempty write-drop class. schemas.User.IsActive is cql:"is_active" with no omitempty (internal/storage/schemas/user.go:69), so buildCQLColumnMap does include false. The bug that silently dropped User.Password does 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 ListOrgDomainsByOrg right after a delete.
  • ListOrgDomainsByOrg is 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

  1. lakhansamani commented on Oct 15, 2026

    @lakhansamani
    ContributorAuthor

    Root cause found for one of the two failures, measured

    Org_Domain_Operations — fixed by #818

    AddOrgDomain is an INSERT … IF NOT EXISTS (a lightweight transaction, deliberately — the normalized domain is the partition key and first-writer-wins is the atomicity guarantee). DeleteOrgDomain was a plain DELETE.

    An LWT takes its mutation timestamp from the Paxos ballot, i.e. the server clock. A plain DELETE writes a row tombstone carrying gocql's client-side timestamp (DefaultTimestamp defaults 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:

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

    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 user table — AddUser's IF NOT EXISTS against a plain DeleteUser. #818 removes that guard (it was dead: it only constrained a freshly generated UUID partition key, and its applied flag was never read) and adds IF EXISTS to the two org_domain deletes.

    User_SCIM_Fields — still open, and not this

    IsActive=false failing to persist is a cell update, not a row delete. 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 — so whatever breaks User_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:

    • fix(storage): make cassandra index readiness probe actually wait #810's index-readiness fix. The failing queries bypass secondary indexes entirely — ListOrgDomainsByOrg uses ALLOW FILTERING on the base table and GetOrgDomainByDomain is WHERE id = ?.
    • The omitempty write-drop class. IsActive carries no omitempty tag, 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_Operations failed on a separate run against main at provider_test.go:1073 — "a repeat delete must not claim the row". That is the mirror shape: AddSessionToken is a plain INSERT (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.

  2. lakhansamani commented on Oct 15, 2026

    @lakhansamani
    ContributorAuthor

    Both failures accounted for. Two causes, neither of them flakiness in the usual sense.

    Org_Domain_Operations — a real storage bug, fixed in #818

    AddOrgDomain's lightweight transaction was stamped from the Paxos ballot (server clock) while DeleteOrgDomain was 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 anyway

    0 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:

    --- FAIL: .../User_Operations
        created user should appear in ListUsers
    

    Two self-inflicted causes, fixed in #824:

    1. ListUsers first-page assumption. User_Operations asserted the just-created user appears in ListUsers(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.
    2. Cascading pollution. That failure aborts before the subtest's cleanup, leaving a fixed +1234567891 phone literal in the table. Every later run then failed earlier with user 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:

    before after
    full storage suite × 15 15/15 failed 0/15 failed
    User_SCIM_Fields / Org_Domain_Operations / Session_Token_Operations × 100 0 failed 0 failed

    Two more things came out of this

    #824 — the cassandradb provider had never been tested. It was missing from the storage suite's backend list, so the provider that serves both cassandradb and scylladb was only ever exercised through Scylla. Pointing it at cassandra:4.1 failed on the first run: three queries use CQL LIKE, which Apache Cassandra accepts only against a SASI index. GetWebhookByEventName errored out on every Apache Cassandra deployment, so webhook dispatch by event name never fired there. Same for two session-token delete methods. DeleteAllSessionTokensByUserID was also matching unanchored, so a delete for 42 took out tokens for 142 — live on ScyllaDB too. make test-cassandradb added.

    Also in #824 — the session_tokens / oauth_states tables had the mirror of the org_domain bug: plain insert (client clock) against DELETE … IF EXISTS (ballot). With the client clock ahead, the tombstone sits below the insert, the row survives, and Paxos still reports applied=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 the Session_Token_Operations failure at provider_test.go:1073 ("a repeat delete must not claim the row") reported on main in this thread.

    Proposed disposition

    Keep this open until #824 merges, then close it. User_SCIM_Fields stays unreproduced — if it resurfaces after those land, it deserves a fresh issue rather than being tracked under a title that now describes something understood.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions