Skip to content

Add a testcontainers-backed live-Postgres harness; wire it into CI (#102) - #118

Merged
stephane-segning merged 1 commit into
mainfrom
claude/testcontainers-harness
Aug 6, 2026
Merged

Add a testcontainers-backed live-Postgres harness; wire it into CI (#102)#118
stephane-segning merged 1 commit into
mainfrom
claude/testcontainers-harness

Conversation

@stephane-segning

Copy link
Copy Markdown
Contributor

Summary

  • crates/sms-test-support/ — a new shared harness that starts (or reuses) one Postgres 16 container and hands back a fully migrated, per-test-binary connection URL.
  • All 14 *_live_postgres.rs/*_live.rs suites now call sms_test_support::database_url() instead of reading DATABASE_URL from the environment (a human running docker run postgres:16 + ./ci/apply-migrations.sh by hand first).
  • .github/workflows/ci.yml — new live job running cargo test --workspace -- --ignored.
  • justfilejust test-live / just test-live-clean.
  • ci/assert-no-raw-sqlx.sh — allowlists sms-test-support/src/lib.rs as a fourth, documented R1 exception.

Intent

Source of truth: #102 and ca653a1's own investigation of it. The existing CI workflow ran a bare cargo test --workspace with no --ignored, so all 14 live-Postgres suites in this workspace were silently skipped in CI and had never once run there. AGENTS.md documents the concrete cost of that blind spot: claim_live_postgres.rs's own policy-gap bug (Message.list/detail missing hasRole('system'), making the claim loop's query silently return zero rows) sat undetected for a full milestone because nothing had run that suite live since #29 merged. This PR closes that gap by making every live suite runnable without manual setup, and by actually wiring them into CI.

Scope

  • Container lifecycle: deliberately avoids caching a testcontainers::ContainerAsync in a process-level static — Rust never runs Drop for a static's contents on any process-exit path, so that pattern leaks a container per test binary with no bound (confirmed the hard way: an earlier attempt at this exact harness orphaned 56 containers and filled the machine's RAM). Instead, crates/sms-test-support/src/lib.rs manages exactly one container via the docker CLI, keyed by a fixed name (vsms-test-harness-postgres) and a distinctive label (dev.vsms.test-harness=true) that scopes every cleanup operation — self-healing across a crashed prior run, never touching anything not carrying that label.
  • Per-binary databases: all 14 binaries share the one container but each gets its own database inside it (vsms_test_<binary-name>), dropped and recreated from a shared, lazily-migrated template (vsms_template) via CREATE DATABASE ... TEMPLATE on every call — see "Verification" below for why this was necessary, not just nice-to-have.
  • #[ignore] kept: every live suite stays #[ignore]d, same as before. just test-live (and the new CI live job) run -- --ignored explicitly. Nothing changes for a plain cargo test/just test — this PR's entire point is making the live path actually reachable, not changing the default path.
  • Out of scope: no changes to any production code path. docs/runbooks/36-handset-gate.md's two physical acceptance tests are untouched.

Verification

  • Baseline containers on this machine before any run: 8 (all pre-existing, unrelated to this workspace — a developer's own tenant-provisioner-* stack).
  • just test-live-clean, then a cold just test-live: 8 → 9 containers (exactly one harness container), all 14 suites green, 63 tests passing, ~67s.
  • Immediately after, a warm just test-live (same compiled binaries, no rebuild): still 9 containers (no growth), all 14 suites green, 63 tests, ~51s.
  • just test-live-clean again at the end returns the machine to its pre-existing container count.
  • Also green: just check, just lint (fmt + clippy -D warnings), just test, just parity, ./ci/assert-no-raw-sqlx.sh.

Two real bugs found only by actually running this against a live database, not by reading the code — this repo's own house rule (AGENTS.md: "verify against live execution... check/build/a prior session's own diagnosis can all stay green while wrong"):

  1. The to_regclass/regclass decode trap. The original migration-check query (SELECT to_regclass('public.messages')) decoded fine into Option<String> in sqlx — except that to_regclass returns regclass, which sqlx cannot actually decode as a string, and a NULL result decodes without ever consulting the declared type. So the query worked exactly once, against a fresh database where the table doesn't exist yet, then failed for every later caller once 0001_init had actually run — presenting as flaky suite ordering rather than a decode error. One suite took 9704 seconds before the fix (::text cast); 0.70s after.

  2. Cross-binary interference via a shared database (the cross-binary half of claim_live_postgres tests are flaky under the full workspace live sweep (shared-database test isolation) #102). All 14 binaries originally pointed at one vsms database inside the container, which persists across runs by design. Most suites tolerate the resulting row accumulation — they scope queries by a freshly generated id — but app/sms-worker/tests/kill9_reclaim_live.rs spawns a real sms-worker --roles dispatch process, and that process's claim loop deliberately selects any eligible candidate row, matching production. Run after the other 13 suites had left claimable rows behind, it picked up their leftovers instead of its own seeded message and timed out in wait_for_state — while passing in isolation in 2.6s. ca653a1 (claim_live_postgres tests are flaky under the full workspace live sweep (shared-database test isolation) #102) already serializes each binary's tests against itself via a per-binary TEST_MUTEX; this is the same underlying problem one layer further out, across binaries rather than within one, which ca653a1 explicitly could not address. Fixed by giving every binary its own database (ensure_binary_database/binary_database_name), derived deterministically from the binary's own executable name so reruns of an unchanged binary reuse rather than accumulate; kill9_reclaim_live.rs needed no changes for this beyond what it already did (reading its URL once from database_url() and passing it explicitly to the subprocess it spawns via --database-url).

    Naming this deterministically hit a second real collision, again found only by inspecting psql -l live rather than by code review: an initial version stripped Cargo's -<hex-metadata-hash> suffix from the binary's file name (reasoning: survive a rebuild). That silently reunited crates/sms-auth/tests/live_postgres.rs and crates/sms-worker/tests/live_postgres.rs — two different packages' test files that happen to share a file name — onto one shared database, since Cargo's own target/debug/deps/ is flat and workspace-wide and the hash suffix is the only thing in the executable's path that tells them apart (confirmed: they really do get different hashes, e.g. live_postgres-8d453e5151bd11d1 vs live_postgres-e79e21cf68671d0b). Fixed by keeping the hash; the tradeoff is that a rebuild which changes it mints a differently-named database rather than reusing the old one (cleaned up like any other harness state by just test-live-clean), which only matters across rebuilds, not across the reruns-of-the-same-binaries scenario this task's own verification and CI's single cargo test --workspace -- --ignored invocation both are.

ca653a1's mutexes: kept unchanged, and still load-bearing. They serialize tests within one binary; giving each binary its own database doesn't remove that need, since tests within one binary still share that one binary's database and can still race each other's candidate-row queries or a cold pg_type cache the same way ca653a1 originally found. Not removed speculatively — proven still necessary by the fact that removing per-binary isolation (the bug this PR fixes) was itself only found by seeing a real failure, not by reasoning about it.

Unverified: this PR does not touch (and cannot verify) the two genuinely physical parts of docs/runbooks/36-handset-gate.md — a real Orange handset and a human-timed kill -9 against a real Orange account. Also unverified: behavior under VSMS_TEST_DATABASE_URL (the escape hatch for a developer-supplied Postgres instead of the managed container) — the per-binary-database logic applies to it identically in code, but wasn't separately exercised against a non-container Postgres in this round of verification.

Screenshots/Evidence

N/A — a backend test-infrastructure change, no UI surface. Full per-suite pass counts (identical across both the cold and warm runs):

dlr_ingestion_live_postgres 5, errors_live_postgres 3, policy_golden_list_live_postgres 6, send_message_live_postgres 9, sms-auth live_postgres 3, oidc_flow_live 1, provision_app_client_live_postgres 2, rbac_layer2_live_postgres 7, m1_acceptance_gate_live_postgres 1, claim_live_postgres 7, dispatch_live_postgres 5, jobs_live_postgres 9, sms-worker live_postgres 4, kill9_reclaim_live 1 — 63 tests, 14/14 suites green.

Risk Assessment

Low for production code (zero production code paths changed — this is test infrastructure only). Moderate for CI reliability, in the sense that this is the first time these 14 suites have ever run in CI at all, so this PR is also the first real test of whether they hold up outside a developer's own machine; the two full sweeps above (cold + warm) are the best available proxy for that, but a CI runner's Docker environment can still differ from this dev machine's in ways not caught here.

AI Usage Declaration

This PR was implemented by Claude Code (Claude Sonnet 5) under my direction, continuing work an earlier agent had substantially completed but left uncommitted, then diagnosing and fixing the one remaining known bug (cross-binary database sharing) plus a second collision bug found live while fixing the first, and verifying the whole thing end to end before committing.

  • I reviewed the container-lifecycle and per-binary-database design and understand why each piece exists (in particular: why not a static handle, why drop-and-recreate rather than create-if-absent, why the template-copy approach, why the advisory lock cannot be shared between the two lock-acquisition sites).
  • I confirmed this was run against real Docker and a real Postgres — cold and warm, twice — rather than trusting cargo check/cargo build alone, per this repo's own house rule that a compiling test proves nothing about runtime behavior.
  • I take responsibility for the accuracy of the container-count and test-pass-count figures above; they are copied directly from this session's actual command output, not estimated.

Reviewer Focus

  • Whether per-binary databases inside one container is the right tradeoff versus, say, per-binary containers (rejected here on memory-fragility grounds per AGENTS.md) or a single shared database with stronger per-test scoping (rejected because kill9_reclaim_live.rs's claim loop is deliberately global and can't be scoped that way without diverging from production behavior).
  • Whether the advisory-lock structure in ensure_binary_database/ensure_template_ready (one lock held for the whole ensure-template-then-drop-then-recreate sequence, with the template-readiness check deliberately not taking its own lock to avoid a same-process cross-connection deadlock) is correct under genuinely concurrent invocations — the module doc frames the concurrent-sweep race as a known, accepted limitation, and it'd be worth a second pair of eyes on whether that's still true here.
  • The live CI job itself hasn't run in real GitHub Actions yet as part of this PR (no way to trigger that from here before merge) — worth watching its first real run for anything this local Docker environment doesn't reproduce.

)

.github/workflows/ci.yml ran a bare `cargo test --workspace` with no
`--ignored`, so all 14 `*_live_postgres.rs`/`*_live.rs` suites in this
workspace were silently skipped in CI and had never once run there — the
exact blind spot that let `claim_live_postgres.rs`'s own policy-gap bug
(missing `hasRole('system')` on Message.list/detail) sit undetected for a
full milestone, per AGENTS.md's own account of #29. This adds
`crates/sms-test-support`, a shared harness that starts (or reuses) one
Postgres 16 container and hands back a fully migrated connection URL, and
wires a new `live` job into CI running `cargo test --workspace --
--ignored`. `just test-live` / `just test-live-clean` give the same thing
locally. Every one of the 14 suites now goes through
`sms_test_support::database_url()` instead of reading `DATABASE_URL` from
the environment.

Container lifecycle deliberately avoids the usual
`testcontainers::ContainerAsync` cached in a process-level `static`: Rust
never runs `Drop` for a `static`'s contents on any process-exit path, so
that pattern leaks a container per test binary with no bound — confirmed
the hard way when an earlier attempt at this same harness accumulated 56
orphaned containers and filled the machine's RAM. Instead this manages
exactly one container by shelling out to `docker` directly, keyed by a
fixed name (`vsms-test-harness-postgres`) and a distinctive label
(`dev.vsms.test-harness=true`) that scopes every cleanup operation this
crate ever performs — verified end to end: baseline 8 containers on this
machine before any run, 9 after two full `just test-live` sweeps (one
cold, one immediately after, warm), never more.

Two real bugs found only by actually running the harness against a live
database, not by reading the code:

- `apply_migrations_if_needed`'s original migration check ran `SELECT
  to_regclass('public.messages')` decoded into `Option<String>`.
  `to_regclass` returns `regclass`, which sqlx cannot decode as a string —
  except when the value is NULL, which decodes without ever consulting the
  declared type. So the query worked exactly once, against a fresh
  database where the table doesn't exist yet, and failed for every caller
  afterward once `0001_init` had actually run — surfacing as flaky
  ordering across suites rather than a decode error. One test binary took
  9704 seconds before the fix (`::text` cast) and 0.70s after.

- All 14 binaries originally shared one `vsms` database inside the
  container, and that database persists across runs by design (so reruns
  don't pay Postgres's startup/migration cost again). Most suites
  tolerate the resulting cross-run row accumulation — they scope queries
  by a freshly generated id — but `app/sms-worker/tests/
  kill9_reclaim_live.rs` spawns a real `sms-worker --roles dispatch`
  process whose claim loop deliberately selects *any* eligible candidate
  row, the same way production does. Run after the other 13 suites left
  claimable rows behind, it picked up their leftovers instead of its own
  seeded message and timed out — passing in isolation (2.6s) but failing
  under the full sweep. This is the cross-binary half of the #102 problem
  `ca653a1` could only ever fix *within* one binary (its per-binary
  `TEST_MUTEX`es are unchanged and still needed for that reason — tests
  within one binary still share that binary's own database).

  Fixed by giving every test binary its own database inside the one
  shared container (`crates/sms-test-support/src/lib.rs`,
  `ensure_binary_database`/`binary_database_name`), derived
  deterministically from the test binary's own executable name so reruns
  of an unchanged binary reuse rather than accumulate. Each database is
  dropped (`WITH (FORCE)`, Postgres 13+) and recreated from a shared,
  lazily-migrated template (`vsms_template`) via `CREATE DATABASE ...
  TEMPLATE` on every call — a filesystem-level copy, far cheaper than
  running both migration files 14 times — and the drop-then-recreate, not
  just create-if-absent, is what actually stops row accumulation across
  reruns. `kill9_reclaim_live.rs` needed no changes for this beyond
  already reading its URL from `database_url()` and passing it explicitly
  to the subprocess it spawns via `--database-url`.

  Naming this deterministically hit a second real collision, again found
  only by inspecting `psql -l` live: an initial version stripped Cargo's
  `-<hex-metadata-hash>` suffix from the binary's file name for
  cosmetic/rebuild-survival reasons, which silently reunited
  `crates/sms-auth/tests/live_postgres.rs` and `crates/sms-worker/tests/
  live_postgres.rs` — two different packages' test files that happen to
  share a name — onto one shared database, since Cargo's own
  `target/debug/deps/` is flat and workspace-wide and the hash suffix is
  the only thing in the executable's path that tells them apart. Fixed by
  keeping the hash; a rebuild that changes it just mints a differently-
  named database rather than a collision.

Verified: two full `just test-live` sweeps (cold via `test-live-clean`,
then warm) both fully green — all 14 suites, 63 tests. Also green: `just
check`, `just lint`, `just test`, `just parity`,
`./ci/assert-no-raw-sqlx.sh` (this crate is a fourth, allowlisted R1
exception alongside `sms-worker/src/lease.rs`, `sms-worker/src/notify.rs`,
and `sms-api/src/cache.rs`).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 5705944

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@stephane-segning
stephane-segning force-pushed the claude/testcontainers-harness branch from 6ad2332 to 5705944 Compare August 6, 2026 23:05
@stephane-segning
stephane-segning merged commit c4982e3 into main Aug 6, 2026
16 checks passed
stephane-segning added a commit that referenced this pull request Aug 7, 2026
CI reproduced a real, ~30%-flaky failure in this suite (3/10 runs), not
a fluke: two different tests' wiremock .expect(1) mocks intermittently
saw 2 requests instead of 1.

Root cause: dispatch::tick's claim loop (claim.rs::candidates())
selects any eligible accepted/queued/routed message system-wide with
an expired-or-absent lease -- correctly matching production, since the
loop has no way to know which test seeded which row. This binary's
database (sms-test-support's per-binary design, #118) is shared by
every test in the file and never reset between runs. TEST_MUTEX
already serializes execution, but does nothing about residual state: a
message a previous test left non-terminal is exactly as claimable as
the row the current test is about to seed, and claim_batch's budget
(up to tps_ceiling rows per tick) means a single tick() can claim both
in the same batch -- so a test's own tick() submits a foreign leftover
message to its own wiremock server on top of its own legitimate one.
That mismatch is what tripped the affected mocks' .expect(1), and it
is order-dependent (hence intermittent), not a data race.

Fixed by draining the claimable backlog to a terminal state
(cancelled, reachable directly from all three claimable states per
§2.10) before every test seeds its own message, through CrateStack
delegates only (R1) -- an isolated_db() helper that every test now
calls instead of db() directly, so isolation can't be forgotten at a
new call site. Under the same TEST_MUTEX every test already holds, so
by induction the candidate set at the start of any test contains only
rows that test itself goes on to seed.

Verified: 15 consecutive clean runs of
`cargo test -p sms-worker --test dispatch_live_postgres -- --ignored`
(0 failures across 120 individual test executions, well past the
10-run bar). Two full `just test-live` runs both green across all 14
suites/66 tests; one interleaved run hit the pre-existing, unrelated
sms-auth::rbac_layer2_live_postgres pg_type-cache flake (#102) in a
file this change never touches, confirmed by passing cleanly in
isolation immediately after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
stephane-segning added a commit that referenced this pull request Aug 7, 2026
Three real bugs in the live-suite gate, each found by actually running
it repeatedly, not by reading the code:

1. rbac_layer2_live_postgres.rs (added in #112, the day after #102's
   own fix landed in ca653a1) never picked up ca653a1's per-binary
   TEST_MUTEX pattern. Its 7 tests all call setup(), which calls
   rotate_signing_key (an insert) plus several distinct seeding query
   shapes, racing on Postgres's own pg_type catalog exactly the way
   ca653a1 already diagnosed and fixed for six other files. Confirmed
   structurally via git history (this file postdates the fix) and by
   grep (every other multi-test live suite has TEST_MUTEX; this one
   didn't). Fixed by applying the identical, already-proven pattern.

   crates/sms-worker/tests/live_postgres.rs had the same gap from the
   other direction: it predates ca653a1 by months (#92) and was never
   updated, protected only by being #[ignore]d and never run in CI
   until #118. Its four tests use distinct RoleLease role keys so they
   don't race on lock *state*, but that doesn't protect against the
   unrelated pg_type first-use race, which is about query-shape
   preparation, not which lock is held. Same fix applied.

   Reproduction: could not reproduce the pg_type collision locally
   despite 9 attempts across escalating conditions (default, 32x
   oversubscribed test threads, container CPU throttled to 0.5 and
   0.25 cores, full-workspace cold sweeps) — consistent with this
   being a genuine low-probability race that needs CI's slower/fewer-
   core runners to reliably manifest, as ca653a1 itself found. The fix
   is the existing, already-proven mitigation applied to the two files
   that were missing it, not a new mechanism.

2. sms-test-support's stale-schema detection keyed on whether
   public.messages merely exists in the template database, not on
   whether it reflects the current migration files. Because the
   container (and its template) is deliberately left running between
   local runs, a bootstrap-only schema edit (0002_bootstrap changed,
   0001_init untouched) was silently invisible to every suite until
   something forced a full `just test-live-clean` — already bit one
   real PR, which only passed after a forced clean rebuild.

   Fixed with a content fingerprint (FNV-1a over every up.sql's path
   and bytes) stamped onto the template via `COMMENT ON DATABASE`
   (pg_shdescription — cluster metadata, never copied into per-binary
   scratch databases, cleaned up automatically when the template is
   dropped). Any mismatch triggers a full drop-and-remigrate.

   Verified empirically: built a baseline 14-row operator_prefix_rules
   template with the old code, edited 0002_bootstrap to add a 15th row
   without touching 0001_init, reran with the old code (bug
   reproduced: count silently stayed at 14, tests still reported ok),
   then reran the identical scenario with the fix (count correctly
   became 15, fingerprint changed, no manual clean needed).

3. Folded in a test-only fix to claim_live_postgres.rs's
   two_concurrent_claimers_never_both_win_the_same_row, per a parallel
   investigation's finding: it asserted the seeded row appears in at
   most one claimer's combined output, which wrongly flags the
   harmless accepted->queued routing hop (deliberately lease-free,
   per Claimable for Message's own take_lease doc) as a false
   double-claim. Now asserts the real invariant — at most one claimer
   may reach `routed`, the only state that triggers a submission.
   Sanity-checked by temporarily removing if_match from the
   queued->routed write locally (never committed) and confirming the
   suite still compiles and runs against the real path cleanly; full
   CAS soundness was independently verified by the parallel
   investigation (240 concurrent attempts, exactly one winner per
   round).

Verification: just check/lint/parity/assert-no-raw-sqlx all green.
Three consecutive full `cargo test --workspace -- --ignored` sweeps
(two against a warm container, one against a freshly cleaned cold
container) all green, 37/37 test groups, 0 failures.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
stephane-segning added a commit that referenced this pull request Aug 7, 2026
CI reproduced a real, ~30%-flaky failure in this suite (3/10 runs), not
a fluke: two different tests' wiremock .expect(1) mocks intermittently
saw 2 requests instead of 1.

Root cause: dispatch::tick's claim loop (claim.rs::candidates())
selects any eligible accepted/queued/routed message system-wide with
an expired-or-absent lease -- correctly matching production, since the
loop has no way to know which test seeded which row. This binary's
database (sms-test-support's per-binary design, #118) is shared by
every test in the file and never reset between runs. TEST_MUTEX
already serializes execution, but does nothing about residual state: a
message a previous test left non-terminal is exactly as claimable as
the row the current test is about to seed, and claim_batch's budget
(up to tps_ceiling rows per tick) means a single tick() can claim both
in the same batch -- so a test's own tick() submits a foreign leftover
message to its own wiremock server on top of its own legitimate one.
That mismatch is what tripped the affected mocks' .expect(1), and it
is order-dependent (hence intermittent), not a data race.

Fixed by draining the claimable backlog to a terminal state
(cancelled, reachable directly from all three claimable states per
§2.10) before every test seeds its own message, through CrateStack
delegates only (R1) -- an isolated_db() helper that every test now
calls instead of db() directly, so isolation can't be forgotten at a
new call site. Under the same TEST_MUTEX every test already holds, so
by induction the candidate set at the start of any test contains only
rows that test itself goes on to seed.

Verified: 15 consecutive clean runs of
`cargo test -p sms-worker --test dispatch_live_postgres -- --ignored`
(0 failures across 120 individual test executions, well past the
10-run bar). Two full `just test-live` runs both green across all 14
suites/66 tests; one interleaved run hit the pre-existing, unrelated
sms-auth::rbac_layer2_live_postgres pg_type-cache flake (#102) in a
file this change never touches, confirmed by passing cleanly in
isolation immediately after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
stephane-segning added a commit that referenced this pull request Aug 7, 2026
…nding (#119)

* Add ProviderError::Indeterminate for a submit that timed out after sending (#36's double-send gap)

A submit whose response/read timed out after the request was already
written to the wire was indistinguishable from "provider down" and got
resubmitted on retry -- a real duplicate-SMS risk with no idempotency
key anywhere, already flagged as a known, accepted gap in
app/sms-worker/tests/kill9_reclaim_live.rs.

- ProviderError::Indeterminate (crates/sms-provider): the request
  reached the provider, or may have; retrying is unsafe. Distinct from
  Unavailable in exactly that respect. RoutingConsequence gains
  HoldIndeterminate so the routing() match stays compiler-checked.

- OrangeCmProvider::classify_transport_error (sms-provider-orange-cm):
  reqwest::Error::is_connect() is checked first (never wrote a byte,
  safe to retry -> Unavailable); is_timeout() || is_body() once past
  that point is a post-connect failure -> Indeterminate. A 2xx whose
  body is unparseable or missing resourceURL is also Indeterminate,
  not Unavailable -- Orange already accepted the submission by then.
  OrangeCmConfig::connect_timeout/request_timeout are now configurable
  (defaulted in production()) so tests can force a deterministic
  timeout without waiting on the 10s/30s production values.

- routed -> uncertain (docs/architecture.md §2.10 table + §7.4
  diagram, schema/migrations/postgres/0002_bootstrap/up.sql
  regenerated via ci/gen-bootstrap-sql.py, not hand-edited). Verified
  with ci/assert-state-machine-parity.py.

- dispatch.rs drives the new edge and, on Indeterminate only, stamps
  Message.providerMessageRefAlt = message.id (== the callbackData
  Orange was sent, known before the network call regardless of
  outcome) so a later DLR can still correlate via
  sms_api::dlr::ingest_one's providerMessageRef-OR-Alt match. Found
  live: without this the claim genuinely did not hold -- an uncertain
  message from a timed-out submit had neither ref column set and a
  matching DLR silently failed to correlate.

Live tests (crates/sms-worker/tests/dispatch_live_postgres.rs) prove
the message lands in uncertain, that no second submit request ever
reaches the mock (wiremock's own received-request count, not just DB
state), that a connect-level failure still backs off to queued as
before, and that a DLR arriving afterward now genuinely resolves the
message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix cross-test claimable-message leakage in dispatch_live_postgres.rs

CI reproduced a real, ~30%-flaky failure in this suite (3/10 runs), not
a fluke: two different tests' wiremock .expect(1) mocks intermittently
saw 2 requests instead of 1.

Root cause: dispatch::tick's claim loop (claim.rs::candidates())
selects any eligible accepted/queued/routed message system-wide with
an expired-or-absent lease -- correctly matching production, since the
loop has no way to know which test seeded which row. This binary's
database (sms-test-support's per-binary design, #118) is shared by
every test in the file and never reset between runs. TEST_MUTEX
already serializes execution, but does nothing about residual state: a
message a previous test left non-terminal is exactly as claimable as
the row the current test is about to seed, and claim_batch's budget
(up to tps_ceiling rows per tick) means a single tick() can claim both
in the same batch -- so a test's own tick() submits a foreign leftover
message to its own wiremock server on top of its own legitimate one.
That mismatch is what tripped the affected mocks' .expect(1), and it
is order-dependent (hence intermittent), not a data race.

Fixed by draining the claimable backlog to a terminal state
(cancelled, reachable directly from all three claimable states per
§2.10) before every test seeds its own message, through CrateStack
delegates only (R1) -- an isolated_db() helper that every test now
calls instead of db() directly, so isolation can't be forgotten at a
new call site. Under the same TEST_MUTEX every test already holds, so
by induction the candidate set at the start of any test contains only
rows that test itself goes on to seed.

Verified: 15 consecutive clean runs of
`cargo test -p sms-worker --test dispatch_live_postgres -- --ignored`
(0 failures across 120 individual test executions, well past the
10-run bar). Two full `just test-live` runs both green across all 14
suites/66 tests; one interleaved run hit the pre-existing, unrelated
sms-auth::rbac_layer2_live_postgres pg_type-cache flake (#102) in a
file this change never touches, confirmed by passing cleanly in
isolation immediately after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
stephane-segning added a commit that referenced this pull request Aug 7, 2026
…NTS.md (#123)

Four PRs landed in one session (#118 harness, #120 gate stabilisation,
#119 ProviderError::Indeterminate, #121 fake Orange + chaos suite) and the
context file only picked up what the last two agents wrote inline. The
biggest omission was the most consequential change: `sms-test-support` and
the fact that CI now runs the 14 live-Postgres suites at all — previously
`cargo test --workspace` ran without `--ignored`, so they were skipped in
CI and had only ever run by hand.

Records, in each case why it exists rather than just that it does:

- The harness's five load-bearing constraints, each learned by something
  breaking: no container handle in a `static` (Drop never runs for statics,
  which leaked 56 containers), label-scoped cleanup only (an image-scoped
  sweep would destroy a developer's own database), one database per test
  binary (a shared one let a real dispatch subprocess steal other suites'
  messages), migration-content fingerprinting (an existence check served a
  stale schema silently), and the global container name that makes
  concurrent test runs corrupt each other.
- That `ca653a1`'s mutex was never applied to two suites, both of which
  flaked in CI within hours of #118 running them for the first time.
- `Indeterminate`'s connect-vs-read predicate, the `routed -> uncertain`
  edge it needed, and the fact that it trades a possibly lost message for
  never sending a duplicate — a product decision, not a free win.
- A CLI-skew warning placed at both sites that tell you to run
  `cratestack migrate diff`, since the installed 0.7.4 CLI emits DDL the
  pinned 0.6.7 library never does. The warning previously existed only in
  an unrelated paragraph 150 lines away from the commands themselves.
- The missing-`hasRole('system')` gap as an explicitly *unguarded* failure
  mode, now found seven times. The three entries above it each turned a
  silent failure loud; this one has no equivalent, which is why it recurs.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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