D108 — Probe-then-truncate-subset fast reset toggle for the tenant-DB test reset (ACCEPTED)
Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. The mechanics this ADR describes live in
Tests\Support\TenantDatabaseState; the operational view is on Testing & the gates.
Context
Since D14/D106
the RLS test lifecycle provisions the schema + tenants:rls once per process and resets between
tests with a single TRUNCATE <every base table> RESTART IDENTITY CASCADE on the BYPASSRLS owner
connection (Tests\Support\TenantDatabaseState::truncate()). That between-test reset is the
dominant remaining per-test cost: it truncates ~70 tables every test, the overwhelming majority
of which a given test never wrote to. A TRUNCATE ... CASCADE still takes ACCESS EXCLUSIVE locks
and does WAL/catalog work per named table, so the cost scales with the table count, not with how
much data a test actually created.
The obvious win is to truncate only the tables a test dirtied. The hard part is knowing which those are, cheaply and correctly, without coupling the reset to the domain code under test. This ADR records the mechanism chosen, the alternatives rejected, the two real behavioural deltas it introduces, and the safety net + rollout plan that make it shippable default-off.
Decision
Add two native environment toggles to TenantDatabaseState::truncate(), read via getenv()
(not Laravel's env()/config()) at call time — so they work when injected with
docker compose exec -e TEST_FAST_RESET=1 app <cmd> onto an already-running container with no config-cache interaction, and can be
flipped at runtime by a test via putenv(). filter_var(..., FILTER_VALIDATE_BOOLEAN) semantics;
getenv() returns false when unset, which reads as off.
TEST_FAST_RESET(default OFF). When off,truncate()takes the exact same unconditionalTRUNCATE <all tables> RESTART IDENTITY CASCADEpath it always has, byte-for-byte — the fast path is a separate branch that returns early, so a toggle-off run is provably unchanged. When on:- Probe in one round-trip which tables hold rows: a
UNION ALLofSELECT '<table>' WHERE EXISTS (SELECT 1 FROM <table>)fragments built from the already-cached truncatable-table list (no second cache; the existing lazy cache + itsinvalidate()interaction are untouched). Table names come frompg_tablesviaquote_ident, so they are safe to interpolate into theFROMclause; single quotes are escaped where the name is used as a string-literal label. - TRUNCATE only the non-empty ("dirty") subset, again
RESTART IDENTITY CASCADE. An empty dirty set skips the statement entirely and still proceeds to the permission-cache step. - The
tenancy()->end()preamble and the spatieforgetCachedPermissions()step run in both modes, unchanged (order: end tenancy → probe/truncate → forget cached permissions). - Instrumentation: append one cheap text line per reset to
/tmp/fast-reset-stats.log(inside the app container —docker compose exec app cat /tmp/fast-reset-stats.log) (timestamp, pid, dirty-set size, table names). A write failure to/tmpis suppressed (@file_put_contents) so it can never break a test.
- Probe in one round-trip which tables hold rows: a
TEST_FAST_RESET_PARANOID(default OFF, only meaningful withTEST_FAST_RESETon). Immediately after the subset TRUNCATE, re-probe ALL truncatable tables (not just the previously-dirty set) and throw a loudRuntimeExceptionnaming every table still non-empty. This is the safety net for the one silent-bleed risk (see Consequences): a write that landed after the dirty-set probe ran, which the subset TRUNCATE therefore missed. It converts a silent cross-test data leak into a hard, named failure.
KEEP TRUNCATE — never DELETE (a code comment says so at the statement, because it is
load-bearing): the append-only audit/DLP tables (audit_events, data_access_logs,
dlp_egress_events, …) carry BEFORE UPDATE/DELETE triggers that RAISE an exception on any
row-level mutation. Only TRUNCATE (not a row operation) bypasses them.
Consequences
- Speed: the reset cost drops from "~70 tables every test" to "only the tables this test touched". The measured dirty-set distribution and the before/after suite wall-clock are collected under the rollout plan below; the go/no-go on flipping the default rests on that data, not on this ADR's reasoning.
- Behavioural delta #1 — sequences on never-dirtied tables are not reset. The full path always
RESTART IDENTITY-resets every table's sequence to 1; the fast path only resets the tables it truncates, so a table that stayed empty keeps its sequence wherever it was. This is a real, permanent difference between the two modes. It is safe here because Postgres sequences are non-transactional: a rolled-backDB::transaction()that inserted a row still advances the sequence even though the row never persists — so such a table probes empty (never dirty) and its sequence is never reset in the fast path, exactly as it would not have mattered under the full path either, because no test in this repo depends on a freshid = 1. The public API is theuuid(UUIDv7, DB-default), the route key isuuid(HasPublicUuid::getRouteKeyName()), andModelConventionsTestenforces that every non-exempt model usesHasPublicUuid— the internal bigintidis never asserted on. Verified by grep (below). - Behavioural delta #2 — the silent-bleed window → paranoid mode. The probe reads the dirty set
at one instant. If any write lands in a table after the probe but is not re-truncated, that row
survives into the next test. In this app's synchronous test flow the reset runs strictly between
tests, so the window is not normally reachable — but the audit/DLP side-effect writers commit on a
separate
pgsql_logssession independent of the command transaction, so a late/async commit is not purely hypothetical.TEST_FAST_RESET_PARANOIDis the mitigation: the re-probe would catch exactly that residue and fail loudly. Recommended on in CI while the toggle is being evaluated. - Modes coexist: because the toggle is read per-call, individual tests can (and the D108 tests
do) flip it via
putenv()regardless of how the suite as a whole was launched, and restore it intearDown()so it never leaks.
The id = 1 / sequence-safety grep
grep -rnE "assert(Same|Equals)\(1," tests/ app-modules/*/tests | grep -nE "id|getKey|sequence"
grep -rnE "->id (===|==) 1|'id' => 1|->getKey\(\) === 1" tests/ app-modules/*/tests
grep -rnE "nextval|currval" tests/ app-modules/*/tests
Result: every assert(Same|Equals)(1, …) hit is a ->count() (row-count) assertion, a spy
call-count, or a sweep-result count — none asserts a database-generated id. No ->id === 1 /
'id' => 1 / getKey() === 1 assertions exist, and no nextval/currval dependence. (The only
getKey() === 1 in the tree is D108's own new test, which deliberately dirties tenants before
resetting so RESTART IDENTITY applies in both modes.) Confirms delta #1 is safe.
Measured results (2026-08-03, dedicated dev server, 12 workers, commit 60d94a62)
- Speed: toggle OFF 194s wall / toggle ON 126s (~35% improvement), 2380 passed in every configuration.
- Paranoid validation: zero paranoid failures across toggle-on+paranoid-on, two --order-by random order shakes, and a 6-worker run.
- Dirty-set distribution over 1429 resets (toggle ON, paranoid OFF):
- 57.6% empty (probe only, no TRUNCATE)
- median dirty count: 0
- p90 dirty count: 4
- max dirty count: 8 tables
- vs. ~70 unconditional in the full path
- most-dirtied tables: audit_events 31%, the jurisdictions/levels seed cluster ~10% each, tenants itself 0.1%
Caveat: single machine, single day — CI and laptop numbers will differ in absolute terms, but the shape (mostly empty dirty set, p90 under 5 tables) should hold across environments.
Rollout plan
- Ship default OFF — a toggle-off run is byte-for-byte the current behaviour.
- Ship instrumented —
/tmp/fast-reset-stats.logrecords the dirty-set distribution so the go/no-go is decided on measured data (median / p90 / max dirty-set size, most-dirtied tables, and how often atenants-closure table appears). - Run the full suite toggle-on + paranoid-on and confirm it stays green across randomised order shakes (completed: see Measured results above; a paranoid failure is a hard stop, not a design to widen).
- Decision owner: Rishi. Consider flipping the default (or wiring it into
make test-parallel/ CI) after ≥5 consecutive clean paranoid-on full-suite runs and at least a week of opt-in local use with no residue throws. Until then it is experimental, opt-in, and reversible by unsetting one env var.
Alternatives rejected
DB::listenwrite-tracking (record every table anINSERT/UPDATEtouched during a test, truncate exactly those). Provably incomplete: the audit module's BYPASSRLSpgsql_logswriter session, DB-levelCASCADEdeletes, and any raw SQL all commit outside anyDB::listenhook, so the tracked set would silently under-report and leak rows between tests. A probe reads ground truth from the database; a listener trusts that every write went through the one instrumented path, which is false here.pg_stat_user_tablesdelta tracking (diffn_tup_ins/n_live_tupsnapshots to find touched tables). The stats collector is asynchronous and its counters lag commit by an implementation-defined flush interval, so a snapshot-diff races the very writes it is trying to observe — a flaky, timing-dependent dirty set is worse than none.DELETE FROM <dirty tables>instead of TRUNCATE. Defeated by the append-only BEFORE UPDATE/DELETE triggers on the audit/DLP tables, whichRAISEon any row deletion; DELETE would also notRESTART IDENTITYand is slower for full-table clears. TRUNCATE is mandatory, not a preference.- Always-on (no toggle). The behavioural deltas above, however well-reasoned, are exactly the kind of thing that should ship behind a measured, reversible flag on the highest-leverage shared surface in the suite. Default-off + instrumented + paranoid-checked is the conservative path to the same speed.