Skip to content

test: a sandboxed test takes the write lock before it reads - #115

Merged
HackTuah merged 2 commits into
mainfrom
fix/sandbox-write-lock
Oct 8, 2026
Merged

HackTuah merged 2 commits into
mainfrom
fix/sandbox-write-lock

Conversation

@HackTuah

@HackTuah HackTuah commented Oct 7, 2026

Copy link
Copy Markdown
Member

The three findings left over from #113: SCR-376, SCR-377, SCR-378.

The mechanism was already filed; the premise for a fix was not

Ecto.Adapters.SQL.Sandbox begins every test's transaction with an explicit mode: :transaction, which Exqlite.Connection resolves ahead of the configured default_transaction_mode: :immediate, and which is a plain DEFERRED BEGIN. Slice 005's fix never reached the suite. A test that reads then writes is upgrading, and SQLite answers SQLITE_BUSY on an upgrade without consulting the busy handler, whatever busy_timeout says.

Five failures across three tables came from that — gateway_identities twice, personas once — each time while the other run of the same commit passed.

What was missing was the premise. Measured now, as a third case in slice 005's own two-connection harness:

A write that is the transaction's first statement is not an upgrade, so the handler is consulted and the writer waits instead of failing.

The case asserts both the success and, by elapsed time, that it genuinely contended rather than sailing through. Without that second assertion it would pass on a race it never ran.

The fix

DataCase.setup_sandbox/1 issues one zero-row DELETE per repo before the test body runs. Two details that are not incidental:

  • The table is the repo's own migration table, read from configuration. Trinity.Repo.Receipts sets migration_source: "receipts_schema_migrations", and hard-coding schema_migrations failed every test using the template rather than only the receipts ones. I found that by breaking it.
  • SQLite only. Postgres has no SQLITE_BUSY and no upgrade path to guard, so taking a lock there would be cost with no reason.

All five instances are covered, checked rather than assumed

identities_test uses DataCase directly. approvals_test and subagents_test use SessionCase — and a grep for setup_sandbox does not list session_case.ex, which initially suggested they were uncovered. SessionCase does use Trinity.DataCase internally, so they route through the same setup. Worth stating because the grep was misleading.

Cost

No change I can distinguish from this machine's run-to-run variance: 148.8 s before, 153.8 s after, on a suite that has reported times further apart than that between identical runs. I am not claiming 5 s; I am claiming it is small.

The reason it is cheap is structural rather than lucky: every file using the template is async: false, so there is no concurrency to serialise. The only async: true occurrence of the template is its own documentation.

SCR-378, separately

Trinity.SandboxTest's os.time/os.clock case inherited the default 1 s wall clock and failed once on a loaded runner, for a reason it was not about: it asserts the two functions are available. It now names a generous limit.

That is not a weakening. The limit's own binding is proven on its own by "an infinite loop is killed on wall clock and the caller is told which limit bound", which passes max_time_ms: 200 and asserts the refusal by value. A budget asserted in two places is a budget that gets loosened in the wrong one.

Evidence

mix gate
prod_check: PASS
3824 mods/funs, found no issues.
trinity.names: OK over 676 tracked files
Result: 952 passed (20 properties, 932 tests), 18 excluded
trinity.coverage: 042 80.31% vs 005 80.31%: OK
GATE_EXIT=0

952 against 951 on main: the one new harness case. Branched from main, so it is independent of #114 and touches none of the same files.

No lib/ change. No new slice id. Not merged.

SCR-376, SCR-377 and SCR-378, the three findings left over from PR #113.

The mechanism, already filed: Ecto.Adapters.SQL.Sandbox begins every test's
transaction with an explicit mode: :transaction, which Exqlite.Connection resolves
ahead of the configured default_transaction_mode: :immediate, and which is a plain
DEFERRED BEGIN. So slice 005's fix never reached the suite. A test that reads and
then writes is upgrading, and SQLite answers SQLITE_BUSY on an upgrade without
consulting the busy handler, whatever busy_timeout says. Five failures across
three tables came from that: gateway_identities twice, personas once, each time
while the other run of the same commit passed.

What was missing was not the mechanism but the premise for a fix. Measured now,
as a third case in slice 005's own two-connection harness: a write that is the
transaction's FIRST statement is not an upgrade, so the handler is consulted and
the writer waits instead of failing. The case asserts both the success and, by
elapsed time, that it genuinely contended rather than sailing through.

So DataCase.setup_sandbox/1 issues one zero-row DELETE per repo before the test
body runs. The table is the repo's own migration table read from its
configuration, not assumed: Trinity.Repo.Receipts sets
migration_source: "receipts_schema_migrations", and hard-coding schema_migrations
failed every test using the template rather than only the receipts ones. SQLite
only; Postgres has no SQLITE_BUSY and no upgrade path to guard.

All five observed instances are covered. identities_test uses DataCase directly;
approvals_test and subagents_test use SessionCase, which does `use
Trinity.DataCase` internally, so they route through the same setup. I checked that
rather than assuming it, having first read a grep that suggested the opposite.

Cost: no change I can distinguish from this machine's run-to-run variance.
148.8 s before, 153.8 s after, on a suite that has reported times further apart
than that between identical runs. The reason it is cheap is structural rather than
lucky: every file using the template is async: false, so there is no concurrency
to serialise. The only async: true occurrence of the template is its own
documentation.

SCR-378, separately. Trinity.SandboxTest's os.time and os.clock case inherited
the default 1 s wall clock and failed once on a loaded runner, for a reason it was
not about: it asserts the two functions are available. It now names a generous
limit. That is not a weakening, because the limit's own binding is proven on its
own by "an infinite loop is killed on wall clock and the caller is told which
limit bound", which passes max_time_ms: 200 and asserts the refusal by value. A
budget asserted in two places is a budget that gets loosened in the wrong one.

mix gate: 952 passed, exit 0.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
Signed-off-by: Ayla Croft <aylacroft@proton.me>
@HackTuah
HackTuah merged commit 06fdde2 into main Oct 8, 2026
12 checks passed
@HackTuah
HackTuah deleted the fix/sandbox-write-lock branch October 8, 2026 04:05
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