Skip to main content

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 unconditional TRUNCATE <all tables> RESTART IDENTITY CASCADE path 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:
    1. Probe in one round-trip which tables hold rows: a UNION ALL of SELECT '<table>' WHERE EXISTS (SELECT 1 FROM <table>) fragments built from the already-cached truncatable-table list (no second cache; the existing lazy cache + its invalidate() interaction are untouched). Table names come from pg_tables via quote_ident, so they are safe to interpolate into the FROM clause; single quotes are escaped where the name is used as a string-literal label.
    2. 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.
    3. The tenancy()->end() preamble and the spatie forgetCachedPermissions() step run in both modes, unchanged (order: end tenancy → probe/truncate → forget cached permissions).
    4. 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 /tmp is suppressed (@file_put_contents) so it can never break a test.
  • TEST_FAST_RESET_PARANOID (default OFF, only meaningful with TEST_FAST_RESET on). Immediately after the subset TRUNCATE, re-probe ALL truncatable tables (not just the previously-dirty set) and throw a loud RuntimeException naming 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-back DB::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 fresh id = 1. The public API is the uuid (UUIDv7, DB-default), the route key is uuid (HasPublicUuid::getRouteKeyName()), and ModelConventionsTest enforces that every non-exempt model uses HasPublicUuid — the internal bigint id is 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_logs session independent of the command transaction, so a late/async commit is not purely hypothetical. TEST_FAST_RESET_PARANOID is 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 in tearDown() 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

  1. Ship default OFF — a toggle-off run is byte-for-byte the current behaviour.
  2. Ship instrumented/tmp/fast-reset-stats.log records 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 a tenants-closure table appears).
  3. 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).
  4. 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::listen write-tracking (record every table an INSERT/UPDATE touched during a test, truncate exactly those). Provably incomplete: the audit module's BYPASSRLS pgsql_logs writer session, DB-level CASCADE deletes, and any raw SQL all commit outside any DB::listen hook, 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_tables delta tracking (diff n_tup_ins/n_live_tup snapshots 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, which RAISE on any row deletion; DELETE would also not RESTART IDENTITY and 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.

← Decision log index