Skip to content

fix(storage): make the cassandradb provider correct on apache cassandra and scylla - #824

Open
lakhansamani wants to merge 10 commits into
mainfrom
fix/cassandradb-provider-correctness
Open

lakhansamani wants to merge 10 commits into
mainfrom
fix/cassandradb-provider-correctness

Conversation

@lakhansamani

@lakhansamani lakhansamani commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Supersedes #821, #822 and #823 — one PR, no merge-order choreography. Four commits, each reviewable on its own.

Started from one question: had we ever run the storage suite against real Apache Cassandra? No. cassandradb was missing from the 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, at subtest 9, with a panic.


1. chore(proto): regenerate stubs after a protovalidate dep bump

buf dep update resolves a newer buf.build/bufbuild/protovalidate, which moved its documentation URLs:

- * [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/)
+ * [see our documentation](https://protovalidate.com/schemas/custom-rules/)

Seven doc comments in gen/ts/buf/validate/validate_pb.ts plus the proto/buf.lock digest. No API, field or wire change.

Latent on main: buf.yml triggers on proto/**, gen/** or Makefile, so the staleness gate stayed quiet until a PR touched one of those — commit 2 adds a test target to the Makefile — and then failed pointing at the wrong change. Included here so this PR's CI is green on its own merits.

2. fix(storage): make the cassandradb provider work on apache cassandra

Three queries use CQL LIKE. Apache Cassandra accepts LIKE only against a SASI index and rejects it against the plain secondary index this provider creates:

InvalidRequest: LIKE restriction is only supported on properly indexed columns.
event_name LIKE 'test_event%' is not valid.

ScyllaDB accepts the same statements, which is exactly why this survived.

Method Effect on Apache Cassandra
GetWebhookByEventName errors — webhook dispatch by event name never fired
DeleteAllSessionTokensByUserID errors — session revocation fails
DeleteSessionTokensByNamespace errors — same

All three already carried ALLOW FILTERING, i.e. a full table scan, so the index was never doing any work. Moving the comparison into Go changes nothing but where it runs — no new scans, no behaviour change on Scylla.

A second bug, live on both engines. DeleteAllSessionTokensByUserID matched "%" + userId + "%" — unanchored. A delete for 42 also took out tokens stored for 142 or provider:427. The SQL provider was anchored long ago, with a comment explaining precisely this; the Cassandra provider kept the defect because the assertion covering it sat inside testSQLCRUDCorrectnessFixes, which only runs for SQL backends. It is now Anchored Session Token Delete, running on every backend. Mutation-verified: restoring the unanchored match fails it on ScyllaDB.

Also: Webhook_Operations indexed fetchedByEventName[0] after only an assert.NotNil. An empty-but-non-nil slice panics there, and a panic in one backend's subtest aborts the run for every backend queued behind it — which is how the first Cassandra run ended, hiding everything after it. Now require.NotEmpty.

Coverage: cassandradb added to allDBTypes and getTestDBConfig on port 9142 — deliberately not 9042, so TEST_DBS=scylladb,cassandradb cannot bind both types to one engine and silently test Scylla twice. Plus make test-cassandradb, and cassandradb in test-all-db, test-docker-up, test-cleanup, wait-for-test-dbs.sh and the AGENTS.md table (7 DBs → 8).

3. test(storage): stop the user suite depending on an almost-empty table

Chasing #813's remaining failure, I looped the scylladb storage suite and hit 15 consecutive full-suite failures — on main and on pre-#818. Neither was a storage bug; both were the test's own fault, and both produce exactly the "flaky ScyllaDB storage tests" symptom #813 reports.

  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 failed every run with created user should appear in ListUsers — indistinguishable from a write that never landed. Now filtered by the user's own email, which keeps both assertions and makes them independent of how much data is already there.
  2. Cascading pollution. That failure aborts before the subtest's cleanup, leaving a fixed +1234567891 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, like the emails already were.

Invisible in CI, which gets a fresh container per run. Deterministic locally.

4. fix(storage): put every cassandra table in one timestamp domain

Closes out the allow-list #818 deliberately left behind.

A single-use session token could be claimed twice. DeleteSessionTokenByUserIDAndKey consumes a token with DELETE … IF EXISTS and treats the applied flag as the exactly-once arbiter behind refresh-token rotation — the comment in the code says so explicitly. That LWT is stamped from the Paxos ballot, i.e. the server clock. AddSessionToken's plain insert carried gocql's client timestamp. When the client clock runs ahead, the tombstone lands below the insert: the row survives, but Paxos saw a live row and returned applied=true. The next claim of the same token also returns applied=true.

Measured on scylladb/scylla, client clock emulated 10 ms ahead with WithTimestamp, 20 insert-then-two-LWT-deletes cycles:

insert double-claimed row survived both deletes
plain, client clock +10ms 20/20 20/20
plain, no skew 0/20 0/20
DefaultTimestamp(false) 0/20 0/20

oauth_states has the identical shape for single-use authorization codes. authenticators mixes an LWT insert and two conditional updates with a plain update and a plain delete.

The fix is DefaultTimestamp(false) on the plain writes to those three tables: gocql sends no timestamp and the coordinator assigns one — the same clock the LWT already uses.

  • Per query, never on the cluster. gocql round-robins coordinators, so cl.DefaultTimestamp = false would push the 20-odd tables that never touch an LWT off a single monotonic client clock onto several skewed node clocks. Strictly worse than today.
  • No Paxos round-trip. That is what makes this routine rather than the performance decision flagged on fix(storage): stop mixing LWT and plain writes on cassandra partitions #818: converging the other way means lifting AddSessionToken to IF NOT EXISTS, i.e. a Paxos round on every login.

Re-measured on both engines after review, LWT insert then delete on one partition, 50 pairs per cell:

delete cassandra 4.1.12 scylladb
plain, ambient clocks 0/50 0/50 *
plain, client stamp forced −10ms 50/50 48/50
… IF EXISTS (#818's fix), forced −10ms 0/50 0/50
server-stamped plain, forced −10ms 0/50 0/50

* an earlier ScyllaDB container, with a different instantaneous client-vs-coordinator clock relationship, dropped 48/50 on that same ambient row. Whether it fires without forcing depends entirely on which way the two clocks happen to sit — which is what made #813 look flaky.

This corrects something I said earlier in this PR. I had written that Apache Cassandra escapes the drop because its ballot timestamp is truncated to whole milliseconds. It does not: with the client stamp forced back 10 ms, Cassandra drops 50/50, and an ambient run with the engine clock 15 ms ahead still dropped 0/50, which the truncation story cannot explain either. The exposure is not engine-specific and I no longer offer a mechanism for the ambient behaviour — only the measurement.

The guard is reframed from "LWT vs plain" to the invariant that actually matters — server-stamped vs client-stamped — and recognises DefaultTimestamp(false) as server-domain. knownMixedTables is empty.

It also had a blind spot worth naming: the statement regex matched "…" but not raw strings, so it never saw AddSessionToken's insert — one of the two writes it was written to watch. Both quote forms now, and the five tables it covers are asserted by name so a regex drift fails loudly instead of silently losing coverage.

SessionToken insert            guard caught it: YES
OAuthState insert              guard caught it: YES
SessionToken plain delete      guard caught it: YES
Authenticators plain update    guard caught it: YES
Authenticators plain delete    guard caught it: YES

Verification, all on this branch

Check Result
Storage suite, cassandra:4.1 26/26 subtests (was: panic at subtest 9)
Storage suite, scylladb/scylla 26/26 subtests
Full storage suite × 12, populated keyspace 0/12 failed (was 15/15)
User_SCIM_Fields / Org_Domain_Operations / Session_Token_Operations × 100 0 failed
TEST_DBS=sqlite go test -p 1 ./... pass
make proto-check, make proto-check-clients, make proto-lint pass
go build ./..., go vet ./... clean

gofmt -l flags internal/http_handlers/oauth_sso_verify_test.go, which is pre-existing on main and not touched by this PR.

What this does not fix

Refs #813


Added after review

A security second-pass (security-engineer, as AGENTS.md asks for on auth-sensitive storage changes) found a merge blocker I had missed, plus several smaller things. Three more commits.

5. test(storage): probe CQL readiness, not the docker port proxy

wait-for-test-dbs.sh checks with nc -z, which on Docker Desktop connects to the published-port proxy rather than to the server — it succeeds the instant docker run -p returns. Measured: nc -z on a fresh cassandra:4.1 succeeded at t=1s while cqlsh inside the same container still failed at t=3s. The gate that script exists to provide was absent for exactly the two slowest-booting backends.

It stayed hidden because go test ./... spends minutes on other packages before reaching internal/storage, which is long enough for the database to boot. A narrower -run does not.

wait-for-cql.sh asks the server from inside the container, which a proxy cannot answer for. Cold runs with no container present:

make test-cassandradb   nc: ready after 0s, CQL after 50s, suite green
make test-scylladb      nc: ready after 0s, CQL after  4s, suite green

6. fix(storage): stamp the user-delete cascade for the table it hits

Found re-reviewing commit 4 — a hole in my own fix. Converting session_tokens, oauth_states and authenticators to the server clock covered their direct call sites and missed the one that is not a direct call site.

DeleteUser cascades over schemas.UserOwnedCollections with a single generic DELETE, and three of those seven tables are the ones just converted. That delete stayed client-stamped, so those tables were still mixed: a session token inserted under the server clock and deleted under the client clock is dropped silently when the client runs behind — the token outlives the hard delete of its own user.

The guard could not see it: the statement names its table through a loop variable, so a source scan cannot classify it. It now (a) requires any write whose table is a variable to consult serverDomainTables, and (b) cross-checks that map against the domains it detected, in both directions. Mutation-verified — dropping either table from the map, adding a client-domain table to it, or removing the cascade's domain check entirely, each fails it with its own diagnostic.

7. fix(storage): anchor the session-token delete on the four remaining backends

The blocker. Commit 2 promoted the anchored-delete assertion to every backend, but only four of eight were anchored. MongoDB, ArangoDB, DynamoDB and Couchbase still matched an unanchored substring, so the new test failed on them. I had verified on sqlite, scylladb and cassandradb and stopped — exactly the gap AGENTS.md's "also run a non-SQL backend" rule exists to close.

backend before
mongodb/session_token.go:66 bson.M{"$regex": userId} — unanchored and unescaped
arangodb/session_token.go:96 FILTER CONTAINS(d.user_id, @user_id)
dynamodb/session_token.go:91 strings.Contains(t.UserID, userId)
couchbase/session_token.go:103 WHERE CONTAINS(user_id, $1)

A delete for 42 took out tokens for 142 or provider:427. Harmless in practice — every user id is a UUIDv4 — but it is a live over-delete primitive, and the SQL provider removed it long ago.

  • mongodb — anchored regex plus regexp.QuoteMeta. The old form interpolated the id straight into $regex, so an id containing regex metacharacters was evaluated as a pattern rather than matched literally.
  • arangodb — d.user_id == @user_id OR RIGHT(d.user_id, LENGTH(@suffix)) == @suffix.
  • dynamodb — HasSuffix instead of Contains.
  • couchbase — CONTAINS stays as the server-side narrowing filter; the anchored predicate runs in Go. Doing the suffix test in N1QL means LIKE '%:'||$1, whose wildcards would need escaping to stay literal. Its comment claimed the substring match mirrored the SQL provider — untrue since SQL was anchored.

Also folded in from the review:

  • The test now covers the bare <userID> stored form as well as <loginMethod>:<userID>, so the equality branch of the predicate is exercised. It never was, on any backend.
  • AddOAuthState documents why its delete-then-insert is safe (fresh state.ID per call, so different partitions) and what would break it — a caller supplying a stable id turns it into a same-partition pair where two coordinator timestamps can drop the insert.
  • The Cassandra authorizer_webhook_event_name index has no consumer left after commit 2. Said so in place rather than leaving it looking load-bearing; dropping it is a schema migration and belongs to a change that owns one. (authorizer_email_template_event_name is not dead — email_template.go still selects on it with =.)
  • WithContext(ctx) on the two new full scans, so they are cancellable.

Backward compatibility

  • No schema or data migration anywhere in this PR. The one index change is a comment.
  • The anchored matches are strictly tighter, never looser. Every value written to session_tokens.user_id is <userID> or <loginMethod>:<userID> (memory_store SetUserSession, plus service_account:<id>), so the user id is always the final segment and == userId || HasSuffix(":"+userId) is exhaustive. Every caller passes a bare user.ID. The refresh_family_<id> reuse-detection entries live under the same session key and are still swept.
  • DefaultTimestamp(false) and already-stored rows. A row written by the old binary carries Tc = t_insert + skew, where skew is app-host clock minus cluster clock. A new server-stamped delete at Ts = t_delete is dropped only if t_delete − t_insert < skew. If skew ≤ 0 there is no new exposure at all; if skew > 0 the window is skew measured from each row's own insert, not "until the old rows expire". The revocation paths that matter fire seconds to days after login. Practical advice: sanity-check NTP on app hosts before upgrading — a host minutes off NTP was already losing LWT deletes on the old binary, which is the bug being fixed.
  • Rolling upgrade adds the mirror case (old client-stamped delete vs new server-stamped insert), bounded identically by |skew|. No new failure class, same magnitude as the one removed.
  • One premise correction worth recording: gocql's client timestamp is not monotonic. gocql@v1.6.0/frame.go:1533 is a bare time.Now().UnixNano()/1000 with no per-session counter, so client stamping never gave a same-process ordering guarantee either. The change swaps one unsynchronised clock for a set of loosely-synchronised ones — better, not a proof.

Verification after review

Check Result
make test-all-db — full storage suite, all 8 backends exit 0, 0 failures (couchbase, postgres, sqlite, mongodb, arangodb, scylladb, cassandradb, dynamodb)
Anchored_Session_Token_Delete on all 8 backends pass
Mutation test: restore the substring match on mongodb / dynamodb / arangodb fails on each
Mutation test: 9 variants against the timestamp-domain guard caught on all 9
Storage suite, cassandra:4.1 and scylladb 26/26 each
make test-cassandradb / make test-scylladb, cold green
go build ./..., go vet ./... clean

Known gaps, stated rather than papered over


Second review round

An independent principal-engineer pass over commits 5–7 (the ones written in response to the first review, which had had no outside eyes). Verdict was "no blockers", but it found four real things and three demonstrated false passes in the guard — all reproduced before and after. Two more commits.

MongoDB was the one backend whose predicate was not equivalent — and it diverged both ways

$regex is PCRE: . does not cross a newline, and $ also matches before a trailing newline. Verified live on mongo:4.4.15 against the SQL semantics:

stored user_id SQL / Dynamo / Couchbase / Arango MongoDB (before)
"T", "p:T", "a:b:T" deleted deleted
"xT", "p:xT" kept kept
"a\nb:T" deleted survives — missed revocation
"p:T\n" kept deleted — over-delete

The second row is the exact primitive this PR exists to remove, alive on one backend. regexp.QuoteMeta only ever made the needle literal — it never made . and $ agree with the other five. Now (?s)^(.*:)? + QuoteMeta(id) + \z.

Neither edge is reachable from application writes (login methods come from a fixed set, ids are UUIDv4) — but "unreachable today" is precisely the standard the substring match was removed under, so it gets the same treatment. The test now carries the newline shape; mutation-verified that reverting the pattern fails it on MongoDB and passes the other seven.

Arango, DynamoDB and Couchbase were confirmed genuinely equivalent, Arango live against arangodb:3.10.3 — including a stored value shorter than the suffix, and null/missing/numeric user_id, all with no AQL warnings.

The guard had three false passes

Each was demonstrated, then re-run after the fix:

mutation before after
invert if serverDomainTables[table] to if !… passed caught — negates the serverDomainTables lookup
client-stamped write placed one line above a server-stamped one passed caught — SessionToken mixes timestamp domains
replace the branch with a comment mentioning serverDomainTables passed caught — without consulting serverDomainTables

The inversion is the one that matters: one flipped ! client-stamps the server-domain tables and server-stamps the client-domain ones, and the guard said nothing. It was checking presence, not correctness.

Fixes: classification regions are now cut at the next write statement or the end of the enclosing function (so a neighbour's flag can't classify this statement), comment lines are dropped from regions, and the variable-table check requires DefaultTimestamp(false) in the same region and rejects a negated lookup. All 9 earlier mutations still caught — 12 of 12 now.

wait-for-cql.sh and a container leak

  • It counted sleep 2 iterations, not wall time, so TIMEOUT=180 really meant "90 attempts" and the true ceiling was open-ended — and the "CQL after 50s" figure was measured the same way. Now a date-based deadline.
  • No fail-fast: a missing or OOM-killed container burned the full timeout in silence, which at MAX_HEAP_SIZE=512M is plausible. Now docker inspect the state first and tail the container log on failure. Both paths verified: missing container → no such container, exited container → is 'exited', not running, each immediately.
  • Each attempt is bounded by cqlsh's own --connect-timeout/--request-timeout rather than timeout(1), which is not present on macOS — where this runs most.
  • Its knob is now TEST_CQL_WAIT_TIMEOUT. Overloading TEST_DB_WAIT_TIMEOUT (which means "wait for a port" and is routinely set low) would have made TEST_DB_WAIT_TIMEOUT=30 make test-cassandradb fail against this PR's own measured 50s cold boot.
  • The leak: as standalone lines in the test-docker-up prerequisite, a probe failure aborted make before $(MAKE) test-cleanup in test-all-db's recipe ever ran — leaking all eight containers and every port, the exact failure that target's comment says was fixed.

Fixing the leak then broke the other half, which I caught by hitting it: with the waits moved wholly into test-all-db, a standalone make test-docker-up && go test ./internal/storage/ started against a still-booting Cassandra, and cassandradb failed the anchored-delete run. So: an advisory copy in test-docker-up (prefixed -, so a slow boot can't abort make) and the authoritative copy inside test-all-db's && chain. Re-running it there is free — 0s once CQL answers.

Verified on a faithful model of the recipe:

path tests cleanup exit
probe fails skipped ran non-zero
tests fail (7) ran ran 7 — the tests' status, not the teardown's
all pass ran ran 0

Smaller corrections

  • The "three of seven" count was wrong — it is two. oauth_states is in serverDomainTables but is not user-owned, so the cascade never reaches it. Two server / five client. Corrected in all three places.
  • The cascade comment overstated its reach. All backends cascade on the bare user_id, while memory_store stores session tokens as <loginMethod>:<id> — so that select finds no namespaced session-token rows on any backend. The live beneficiary is authenticators, whose user_id is bare; stamping the session-token half is correct and defensive rather than currently active.
  • WithContext(ctx) on the cascade select and the final user-row delete, in the same function the previous commit edited.
  • The sibling DeleteSessionTokensByNamespace carried the same two defects one function below the one just fixed: raw $regex interpolation on MongoDB, and an unescaped N1QL LIKE on Couchbase where _ is a single-char wildcard and every recipe name contains one (basic_auth, magic_link_login, service_account). Both fixed. It has no live caller — its only consumers are in _deprecated_update_env.go, which the leading _ excludes from the build (go list confirms zero matching build files) — but it is the same family one function away.

Final verification

Check Result
make test-all-db exit 0, 0 failures, all 8 backends; no containers left behind
CQL probe, cold Cassandra 50s, Scylla 0–1s
Anchored_Session_Token_Delete, all 8 backends incl. the newline shape pass
Guard mutations 12 of 12 caught
Mutation: revert the Mongo pattern fails on MongoDB, passes the other 7
go build ./..., go vet ./... clean

Found but deliberately not fixed here

The service-layer DeleteUser does not revoke namespaced session tokens. admin_users.go:406 calls StorageProvider.DeleteUser (cascade on bare user_id) and purges FGA tuples, but never DeleteAllUserSessions — and the cascade cannot see <loginMethod>:<id> rows. So a deleted user's session-token rows survive on every backend. Pre-existing, uniform, and testDeleteUserCascade misses it because it stores tokens with a bare id. I verified the code path, not the live behaviour. It wants its own issue and its own test rather than being folded in here.

buf dep update picks up a newer buf.build/bufbuild/protovalidate, which
moved its documentation URLs from buf.build/docs/protovalidate/... to
protovalidate.com/..., changing seven doc comments in the generated TS.

No API, field or wire change -- comments and the buf.lock digest only.

Latent on main: buf.yml runs on changes to proto/, gen/ or the Makefile,
so the staleness gate stayed quiet until a PR touched one of those and
then failed for a reason unrelated to its own diff.
The provider backs two DB types, cassandradb and scylladb, but only the
ScyllaDB path was ever tested -- cassandradb was absent from the storage
suite's backend list. Wiring it in and pointing it at cassandra:4.1 fails
immediately: three queries use CQL LIKE, which Apache Cassandra accepts
only against a SASI index and rejects against the plain secondary index
this provider creates.

    LIKE restriction is only supported on properly indexed columns

ScyllaDB accepts the same statements, which is why it went unnoticed.

- GetWebhookByEventName errored out on every Apache Cassandra
  deployment, so webhook dispatch by event name never fired.
- DeleteAllSessionTokensByUserID and DeleteSessionTokensByNamespace
  errored the same way.

All three already carried ALLOW FILTERING -- a full table scan -- so
moving the comparison into Go changes nothing but where it runs.

DeleteAllSessionTokensByUserID also matched "%<userId>%" unanchored, so
a delete for "42" took out tokens for "142" or "provider:427". The SQL
provider was anchored long ago; this one kept the defect because the
assertion covering it lived inside a SQL-only subtest. It now runs on
every backend as Anchored Session Token Delete, and fails on ScyllaDB
when the unanchored match is restored.

Also stops Webhook_Operations indexing a possibly-empty result: the
panic aborted the whole run for every backend queued behind it.

Adds make test-cassandradb and includes cassandradb in test-all-db.
Full storage suite now green on cassandra:4.1 and on scylladb.
Two self-inflicted failures that look like storage bugs. Both are
invisible in CI, which gets a fresh container per run, and deterministic
on any repeated local run -- the "flaky ScyllaDB storage tests" shape.

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 failed 15 of 15 runs
with "created user should appear in ListUsers", i.e. exactly like a write
that never landed. Now filtered by the user's own email, which keeps both
assertions and makes them independent of how much data is already there.

The two phone numbers were fixed literals, so the failure above -- which
aborts before the cleanup at the bottom -- left +1234567891 in the table
and every later run failed earlier with "user with given phone number
already exists". One failure cascading into a different, misleading one.
Unique per run now, like the emails.

Verified against the same 463-row keyspace: was 15/15 failing, now 5/5
passing.

Refs #813
A single-use session token could be claimed twice.

DeleteSessionTokenByUserIDAndKey consumes a token with DELETE ... IF
EXISTS and treats the applied flag as the exactly-once arbiter behind
refresh-token rotation. That LWT is stamped from the Paxos ballot, i.e.
the server clock, while AddSessionToken's plain insert carried gocql's
client-side timestamp. With the client clock ahead, the tombstone lands
BELOW the insert: the row survives, but Paxos saw it and returned
applied=true -- so the next claim of the same token also returns
applied=true. Measured on scylladb/scylla with the client clock 10ms
ahead: 20 of 20 tokens claimed twice, 0 of 20 after the fix.
oauth_states has the identical shape for single-use authorization codes.

The fix is DefaultTimestamp(false) on the plain writes to the three
tables that mixed domains, which hands the timestamp to the coordinator
-- the same clock the LWT already uses. Per query, never on the cluster:
gocql round-robins coordinators, so a global switch would expose the
20-odd tables that never touch LWT to inter-node skew instead. It adds
no Paxos round-trip, so the hot login path is untouched; converging the
other way (lifting the inserts to LWT) would have cost one per login.

The guard is reframed from "LWT vs plain" to the invariant that actually
matters, server-stamped vs client-stamped, and recognises
DefaultTimestamp(false) as server-domain. Its allow-list is now empty.

It also had a blind spot worth naming: the statement regex matched only
"..." and not raw strings, so it never saw AddSessionToken's insert --
one of the two writes it was written to watch. Both quote forms now.
Mutation-verified: removing any one of the five server-stamps fails it.

Full storage suite green on scylladb and cassandra:4.1, 26 subtests each.

Refs #813
wait-for-test-dbs.sh checks with `nc -z`, which on Docker Desktop
connects to the published-port proxy rather than the server: it succeeds
the instant `docker run -p` returns. Measured -- nc -z on a fresh
cassandra:4.1 succeeded at t=1s while cqlsh inside the same container
still failed at t=3s. The gate the script exists to provide was absent
for exactly the two slowest-booting backends.

It stayed hidden because `go test ./...` spends minutes on other
packages before reaching internal/storage, which is long enough for the
database to finish booting. A narrower -run target does not.

wait-for-cql.sh asks the server from inside the container, which a port
proxy cannot answer for. Wired into test-scylladb, test-cassandradb and
test-docker-up.

Cold runs, no container present at start:

    make test-cassandradb  nc: ready after 0s, CQL after 50s, suite green
    make test-scylladb     nc: ready after 0s, CQL after  4s, suite green
Found re-reviewing the previous commit. Converting session_tokens,
oauth_states and authenticators to the server clock covered their direct
call sites and missed the one that is not a direct call site.

DeleteUser cascades over schemas.UserOwnedCollections with a single
generic DELETE, and three of those seven tables are the ones just
converted. That delete stayed client-stamped, so those tables were still
mixed: a session token inserted under the server clock, deleted under
the client clock, is dropped silently when the client runs behind -- the
token outlives the hard delete of its own user.

The cascade now consults serverDomainTables, the only place a
loop-driven write can learn the domain of a table it picks at run time.
Direct call sites keep saying DefaultTimestamp(false) inline, next to
the statement and its reason.

The guard could not see any of this: the statement names its table
through a loop variable, so a source scan cannot classify it. It now
(a) requires every write whose table is a variable to consult
serverDomainTables, and (b) cross-checks that map against the domains it
detected, in both directions. Mutation-verified -- dropping either table
from the map, adding a client-domain table to it, or removing the
cascade's domain check at all, each fails it with its own diagnostic.
…ackends

The previous commit promoted the anchored-delete assertion to every
backend but only four of eight were anchored. MongoDB, ArangoDB,
DynamoDB and Couchbase still matched an unanchored substring, so the
test failed on them -- caught by a security second-pass, not by me: I
verified on sqlite, scylladb and cassandradb and stopped, which is
exactly the gap AGENTS.md's "run a non-SQL backend" rule exists to
close.

A delete for "42" took out tokens for "142" or "provider:427". Harmless
today because every user id is a UUIDv4, but it is a live over-delete
primitive and the SQL provider removed it long ago.

- mongodb: anchored regex, and regexp.QuoteMeta -- the old form
  interpolated the id straight into $regex, so an id containing regex
  metacharacters was evaluated as a pattern.
- arangodb: d.user_id == @user_id OR RIGHT(...) == @suffix.
- dynamodb: HasSuffix instead of Contains.
- couchbase: CONTAINS stays as the server-side narrowing filter, the
  anchored predicate runs in Go. Doing it in N1QL would need LIKE
  wildcard escaping to stay literal. Its comment claimed the substring
  match mirrored the SQL provider, which stopped being true when SQL was
  anchored.

Also from that review:

- the test now covers the bare "<userID>" stored form as well as
  "<loginMethod>:<userID>", so the equality branch of the predicate is
  exercised -- it never was, on any backend.
- AddOAuthState documents why its delete-then-insert is safe (fresh id
  per call, so different partitions) and what breaks it.
- the cassandra webhook event_name index has no consumer left; say so
  rather than leave it looking load-bearing. Dropping it is a schema
  migration and belongs to a change that owns one.
- WithContext(ctx) on the two new full scans.

Re-measured the timestamp hazard on BOTH engines, which corrects the
record: with the client stamp forced 10ms back, Apache Cassandra drops
50/50 and ScyllaDB 48/50. It is not Scylla-specific -- it was merely
unobserved on Cassandra at ambient skew, and my earlier millisecond-
truncation explanation for that does not survive a +15ms ambient lead
showing 0/50. The guard's table now reports both engines and stops
explaining what it cannot.

All eight backends pass Anchored_Session_Token_Delete. Mutation-verified
on mongodb, dynamodb and arangodb: restoring the substring match fails
it on each.
…ng match

Neither conclusion changes; the reasoning behind both does, and a stale
rationale is how the next reader re-derives a wrong answer.

memory_store/db/cache.go argued the reserved cache namespace is safe
from DeleteAllSessionTokensByUserID because real user ids are UUIDs and
so are never a substring of the literal. The match is anchored on every
backend now, so the guarantee no longer rests on that -- it holds
because the literal is neither an exact user id nor a ":<userId>"
suffix.

http_handlers/token.go said DeleteAllUserSessions sweeps the
reuse-detection index via a "substring/glob match on the user id". It
sweeps because the delete matches the session key and ignores the
sub-key, which is true before and after.
…passes

All from an independent review of the previous three commits.

MongoDB was the one backend whose new predicate was NOT equivalent to
the others, and it diverged BOTH ways. $regex is PCRE: `.` does not
cross a newline, so a stored "a\nb:<id>" survived here while every other
backend deleted it; and `$` also matches before a trailing newline, so
"<prefix>:<id>\n" was deleted here and kept everywhere else -- the
over-delete primitive this work exists to remove, alive on one backend.
Now "(?s)^(.*:)?"+QuoteMeta(id)+`\z`. QuoteMeta only ever made the
NEEDLE literal; it never made `.` or `$` agree with the other five.

The static guard had three demonstrated false passes, each reproduced
before and after:

- Inverting `if serverDomainTables[table]` passed. That one flip
  client-stamps the server-domain tables AND server-stamps the
  client-domain ones -- the likeliest way a refactor breaks the line.
  The guard now rejects a negated lookup and requires
  DefaultTimestamp(false) in the same region, so presence is no longer
  mistaken for correctness.
- The 3-line classification window bled into the next statement, so a
  neighbour's DefaultTimestamp(false) classified a genuinely
  client-stamped write as server-domain.
- A comment merely MENTIONING serverDomainTables satisfied the
  variable-table check while the code below stamped nothing.

Regions are now cut at the next write statement or the end of the
enclosing function, and comment lines are dropped from them.

wait-for-cql.sh counted sleeps rather than wall time, so its timeout was
an undercount and the real ceiling open-ended; it also burned the full
180s in silence when the container was missing or had died, which an OOM
at MAX_HEAP_SIZE=512M looks exactly like. Now a date-based deadline, a
docker-inspect state check that fails fast and tails the container log,
and cqlsh's own connect/request timeouts to bound each attempt --
timeout(1) is absent on macOS, where this runs most. Its knob is
TEST_CQL_WAIT_TIMEOUT, not TEST_DB_WAIT_TIMEOUT, which means "wait for a
port" and is routinely set below a cold Cassandra boot.

The two CQL waits moved out of test-docker-up and into test-all-db's own
recipe. As standalone lines in a prerequisite target, a probe failure
aborted make before `$(MAKE) test-cleanup` ever ran, leaking all eight
containers and every port -- the exact failure that target's comment
says was fixed.

Also: the "three of seven" count was wrong (oauth_states is in
serverDomainTables but is not user-owned, so two of seven are reached);
the cascade comment overstated its reach, since session tokens are
stored namespaced and this bare `user_id = ?` select finds none of them
on any backend; WithContext on the cascade select and the final user
delete; and the sibling DeleteSessionTokensByNamespace carried the same
two defects on mongodb (raw $regex interpolation) and couchbase
(unescaped LIKE, where `_` is a wildcard and every recipe name has one).

The test gains the newline shape that exposes the mongo divergence.
…proof

Moving the CQL waits wholly into test-all-db's recipe fixed the leak and
broke the other half: test-docker-up is also a standalone target, so
`make test-docker-up && go test ./internal/storage/` then started
against a Cassandra that was still booting. Caught by hitting it --
cassandradb failed the anchored-delete run until the probe was invoked
by hand.

Advisory copy in test-docker-up so that target leaves the stack usable,
prefixed with `-` so a slow boot cannot abort make before test-all-db's
cleanup. Authoritative copy stays inside test-all-db's && chain, where a
failure is captured and still tears down. Re-running it there is free --
0s once CQL answers.

Verified on a faithful model of the recipe: probe failure skips the
tests, runs cleanup and exits non-zero; a test failure runs cleanup and
propagates the TESTS' status; a pass exits 0.

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