perf(tests): build the workspace schema once per session, not per test (#979) - #1101
Conversation
#979) The suite was fsync-bound. `_init_database` creates ~18 tables plus indexes and switches the DB to WAL, and that costs **1272.9 ms** on a real filesystem against 2.7 ms on tmpfs — the difference is entirely fsync. It ran once per test. Measured here before any change: full non-e2e suite ~910 s on disk (median of 8 runs, 883–967 s) versus 427.6 s with a tmpfs `--basetemp`, identical results. That 483 s delta is ~366 workspace builds at 1.32 s each. (The issue reports ~4 h on a slower WSL2 disk; the pathology is the same, the scaling is not.) `tests/conftest.py` now builds the schema once per session and copies the file — 0.1 ms, ~13,000× cheaper. This is the issue's option 2, chosen over the tmpfs workaround because it helps CI too, and over `PRAGMA synchronous=OFF` because that would mean touching production `_open_db` to serve tests. Full suite on disk: **~910 s → 448.3 s**, 6149 passed / 49 skipped / 2 deselected — the same 6139 as before plus this change's 10 new tests. It now matches the tmpfs figure without needing tmpfs. Safe because the property was verified, not assumed: `_init_database` is byte-identical across builds (md5) and leaves no -wal/-shm sidecars, so a single-file copy is exactly equivalent. Drift is structurally impossible — the template is produced by calling the real function, so a schema change or SCHEMA_VERSION bump is picked up automatically. One trap found by an existing test: a plain `shutil.copy` also copies the template's permission bits, so `state.db` landed 0644 under a 007 umask where sqlite gives 0640. The copy now lets sqlite create the file and only overwrites its contents, keeping the mode faithful by construction rather than by recomputing a umask formula. Production code is untouched; durability outside the test session is unchanged. The tmpfs opt-in is documented in CLAUDE.md as the remaining lever for disks where the gap is still large.
codex review [P2], and it is the more serious kind of bug: masked coverage rather than a visible failure. `_init_database` is `CREATE TABLE IF NOT EXISTS` plus ALTER TABLE steps, so calling it on an existing file is a *migration*, not a build. Copying the template over it discarded the caller's data and skipped every migration — and because the template already contains the column each migration adds, the assertions still passed. `test_blocker_origin.py::test_alter_table_migration_adds_created_by_column` is exactly that shape: it creates a pre-`created_by` blockers table, calls `_init_database`, and asserts the column appeared. Under the templated version it appeared because the table had been replaced wholesale. The test went green while proving nothing. The template now serves only a path that does not yet exist; anything else delegates to the real `_init_database`. The hot path is untouched — create_or_load_workspace builds at a fresh temp path — so the speedup stands. Two tests added, because the existing migration test cannot detect this by construction: one asserts an existing DB keeps its rows, the other that the created_by migration runs AND the pre-existing row survives (a rebuild would have dropped it).
378 s, not the 448 s measured before the existing-database guard landed. The tmpfs opt-in now buys nothing on this machine (378 s on disk beats the 428 s tmpfs figure from before the fix), so its framing is narrowed to the machines where the gap is still large.
|
Claude finished @frankbria's task in 7m 19s —— View job Precision bug-hunting review — completeReviewed PR #1101 for concrete defects (logic errors, data loss/corruption, races, test-isolation breakage, contract changes). Style, architecture, naming, and test-coverage are out of scope (covered by the general review).
✅ GLM review: no defects found.What I verified against the checked-out code (not just the diff):
|
Third-party review —
|
ReviewSolid change, and the diff shows real self-correction: the codex review's [P2] finding (template masking migrations) and the pre-existing permission test both caught genuine traps, and both are now handled correctly rather than patched over. I traced the fix independently rather than just taking the PR description's word for it — notes below. Verified correct:
Minor, non-blocking:
Test coverage: the new file is thorough — reproducibility of the build, byte-identity of the copy vs. a real build, schema version/WAL-mode sanity, permission bits parametrized over three umasks, an end-to-end workspace round-trip, and — the important pair — an existing DB keeping its rows and the concrete No new findings beyond what's already in the thread (the codex [P2] fix and the permission-bit trap are both correctly resolved, confirmed above by tracing rather than re-flagging). Nothing here blocks merge. |
claude-review, both non-blocking but both worth doing: - The autouse session fixture built the schema at session start, so every invocation paid ~1.3s — including `pytest -k one_unrelated_test`, which is the tight inner loop this issue is ultimately about. Now built on first use behind a lock, so a run that never creates a workspace pays nothing. Measured: a workspace-free selection is back to 0.30s, while tests/core/test_workspace.py still gets the speedup (6.14s baseline → 1.66s). - Triple blank line before the new block, single before pytest_configure. `ruff check` passes either way (E302/E303 are not in the enabled rule set here), but the reviewer was right that it reads as unintentional — it was. The third note, that `real_init_database` has one consumer, is accurate and left as-is: that consumer is the byte-identity self-check, which is the test the whole optimisation rests on.
|
All three notes addressed in the latest push. The ~1.3s flat tax was the one worth fixing — you're right that it lands on Blank lines: correct, and unintentional — collapsed. On the
Worth adding as evidence the fix helps CI, not just local runs — the issue predicted this and it shows up in this PR's own checks:
~12% off CI as well, on the same hardware and the same command. Re-running the full local gate on the final commit. |
|
Claude finished @frankbria's task in 4m 18s —— View job Precision bug-hunting review — completeReviewed the latest push (
✅ GLM review: no defects found.The delta since the prior (pre-synchronize) reviews is the lazy-build lock, so that's what I traced hardest. What I verified against the checked-out code (not the diff alone):
No new findings. The migration-masking and permission-bit traps were already raised and resolved on earlier commits (codex [P2] and the existing |
ReviewRe-reviewed the final commit ( Confirmed correct, independently traced:
No new findings. Everything substantive was already caught and fixed in this thread — the migration-masking bug (codex Nothing here blocks merge. |
Closes #979.
~910 s → 377.9 s (6m17s) on a real filesystem, identical results. This is the issue's option 2 (build the schema once, copy it per test), chosen over the tmpfs workaround because it helps CI too, and over
PRAGMA synchronous=OFFbecause that would mean touching production code to serve tests (AC4).I re-measured first — the issue's numbers don't reproduce here
The issue reports ~4 h locally vs 4m38s on CI (~47×). On this machine the gap is far milder, so the fix had to be justified by these numbers rather than the reported ones:
/dev/shm)tests/core/test_workspace.py(23)Same results either way. So AC1's "under ~15 min" was already met here, at 15m10s — barely. That doesn't make the issue wrong; it means the pathology is environment-scaled, and the fix has to help everywhere rather than only on tmpfs.
Where the time went
_init_databasecreate_or_load_workspaceshutilcopy of a prebuilt template910 − 428 = 482 s of the suite is fsync, and at 1.32 s per workspace that is ~366 builds. That is the whole gap, and copying a template is ~13,000× cheaper than rebuilding.
Result
6151 − 6139 = exactly this PR's 12 new tests. Nothing skipped or weakened to hit the number (AC2). On disk it now beats what tmpfs previously achieved, so the workaround is redundant here — and CI gets the same win.
Why the template is safe — verified, not assumed
_init_databaseoutput is byte-identical across builds (md5), with no-wal/-shmsidecars, so a single-file copy is exactly equivalent rather than merely similar._init_databaseonce per session, so a schema change orSCHEMA_VERSIONbump (three happened in this issue batch alone) is picked up automatically. Nothing is hand-maintained.Two traps, both caught and fixed
1. A file copy carries the source's permission bits.
state.dblanded 0644 under a 007 umask where sqlite gives 0640 — caught by the existingtest_state_db_permissions_match_a_plain_sqlite_create[7]. The copy now lets sqlite create the file and only overwrites its contents, so the mode is faithful by construction rather than by recomputing a umask formula (the formula is what the original #954 test was written to catch).2. An existing database is a migration, not a build — codex review [P2], and the more serious of the two because it masked coverage instead of failing.
_init_databaseisCREATE TABLE IF NOT EXISTSplus ALTER TABLE steps, so copying over an existing file discarded the caller's data and skipped every migration.test_blocker_origin.py::test_alter_table_migration_adds_created_by_columncreates a pre-created_byblockers table, calls_init_database, and asserts the column appeared — under the templated version it appeared because the table had been replaced. The test went green while proving nothing.The template now serves only a path that doesn't yet exist; anything else delegates to the real function. The hot path is unaffected (
create_or_load_workspacebuilds at a fresh temp path), which the final 377.9 s confirms. Two tests added, because the existing migration test cannot detect this by construction: one asserts an existing DB keeps its rows, the other that the migration runs and the pre-existing row survives.Acceptance criteria
CLAUDE.md→ "Suite speed ([P2.18] Full test suite takes ~4h locally vs 4m38s on CI — per-test SQLite fsync #979)" documents the fix and the tmpfs opt-in, with the caveats the issue lists (RAM-backed, Linux-only, opt-in because a real filesystem is what CI runs) ✅tests/conftest.pyand test files change — nocodeframe/source touched ✅Known limitations