Skip to content

Run the Python suite in parallel and stop vitest's double work - #980

Merged
ajslater merged 5 commits into
developfrom
v2-ci-test-speed
Oct 19, 2026
Merged

ajslater merged 5 commits into
developfrom
v2-ci-test-speed

Conversation

@ajslater

Copy link
Copy Markdown
Owner

Implements phases 1–2 of .claude/plans/ci-test-speed.md. CI's critical path is the single-core Test Python job (7:20 of a 9:41 run). This PR runs that suite on every core without checking less: no new skips, xfails, deselects or ignores, and the same coverage.

Changes

  1. Each pytest process gets its own config dir. A new -p tests.pytest_config_dir plugin points CODEX_CONFIG_DIR at a per-process tmp dir before pytest-django imports settings. Until now every run shared ./config, including the developer's real codex.toml and the file caches cachalot fills; under xdist that sharing produced hundreds of UNIQUE constraint failed: django_content_type errors. An explicit CODEX_CONFIG_DIR still wins in a serial run. With isolation in place, the SerializeMixin file lock on importer tests is no longer needed and is removed. --durations=25 --durations-min=1.0 makes slow tests visible.
  2. Two order-dependent tests fixed. The delete-backstop and custom-cover cleanup tests patched threading.Event.wait for every event in the process. Thread.start() waits on an Event too, and the first queue put in a process starts a feeder thread, so TestSecondLookBeforeDeleting failed whenever it ran first in a process, including serially and alone. Both now mock wait only on the abort event the code under test uses.
  3. pytest-xdist with --numprocesses=auto --dist=loadscope --no-loadscope-reorder. Without --no-loadscope-reorder, loadscope sorts scopes by size. That broke pytest-django's TestCase-before-TransactionTestCase order: a TTC flush wiped migration-seeded rows for the TestCases after it on the same worker (13 failures). -n0 runs serially.
  4. Frontend. vite-plugin-checker no longer runs ESLint inside vitest, since the Lint job already does it. reader-nav-button's snapshot assertion could never fail (a gitignored file rewritten by --update on every run). It is replaced with explicit assertions, --update is removed from test/test:ci, and a test:update script is added.

Verification (local, 12-core arm64)

Run Wall Result
Serial (step 1 only) 239 s 1668 passed, 1 xfailed
12 workers ×3 74 s each same counts
-n 4 (CI runner's vCPUs) 87 s same counts
Rebased on #978, 12 workers 73 s 1683 passed, 1 xfailed
  • Coverage TOTAL was identical in serial and parallel runs on the same tree (26871 / 4621 / 6508 / 952).
  • ./config was not touched by any run.
  • vitest: 97 files / 994 tests pass, and no __snapshots__ are written.
  • make lint and make ty are clean, apart from two existing basedpyright reportUnreachable warnings in codex/views/browser/annotate/cover.py that this PR does not touch.

Not in this PR

  • devenv items, in a separate devenv PR: the coverage-HTML CI artifact (plan Decision 4), plus two plan decisions that can't work codex-only. devenv merges package.json scripts and .gitignore lines as unions, so codex can't drop the template's root eslint_d --cache . (the frontend gets linted twice) or the root __snapshots__ ignore.
  • LOGLEVEL=DEBUG stays. On the parallel suite it costs about 4–5% (74 s vs 71 s).
  • --pool=threads for vitest waits on this PR's CI timing, per the plan.
  • No NEWS entry: NEWS items are for users, and nothing here is user-facing.

🤖 Generated with Claude Code

ajslater and others added 5 commits October 18, 2026 20:14
Every test run shared ./config: the developer's real codex.toml, secret
keys and, above all, the file-based Django caches that cachalot fills.
A -p plugin now points CODEX_CONFIG_DIR at a per-process directory
under the run's tmp root before pytest-django imports the settings, so
serial runs, parallel worktrees and (next) xdist workers never share a
cache. An explicit CODEX_CONFIG_DIR still wins in a serial run.

With that isolation the SerializeMixin file lock on BaseTestImporter
only serialized importer classes across processes, so it goes.

--durations=25 --durations-min=1.0 makes slow tests visible in every
run's output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The delete-backstop and custom-cover cleanup tests patched
threading.Event.wait for every Event in the process. Thread.start()
waits on an Event too, and the first multiprocessing.Queue put in a
process starts the queue's feeder thread, so a stray wait() call landed
on the mock whenever that test was the first in its process to put on
LIBRARIAN_QUEUE. TestSecondLookBeforeDeleting then failed its
assert_called_once_with(5), alone or on a fresh xdist worker, and the
janitor test's assert_called() could pass without the second look ever
waiting.

Both now patch wait on the abort event the importer or janitor is
built with, which is the wait the code under test calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--numprocesses=auto --dist=loadscope spreads test classes across every
core and keeps each class on one worker, so setUpTestData runs once
per class. Each worker gets its own in-memory test database and, from
the previous commit's plugin, its own config dir and file caches.

--no-loadscope-reorder keeps pytest-django's TestCase-before-
TransactionTestCase order on every worker. loadscope's default sorts
scopes by size, which put BookmarkDedupeMigrationTests mid-run; its
flush then stripped the migration-seeded rows (ComicboxTaggingDefaults,
AgeRatingMetron) and left the comic sequence at 14 for the TestCases
that followed on that worker: 13 failures.

Locally on 12 cores: 239 s serial -> 75 s; -n 4 (the CI runner's
vCPUs) 87 s. Same 1668 passed, 1 xfailed and the same coverage TOTAL
as the serial run. -n0 runs serially.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vite-plugin-checker ran a full eslint_d pass inside every vitest run.
The Lint job already lints the frontend, so the checker now loads only
outside the test environment.

reader-nav-button's only assertion on its initial markup was
toMatchSnapshot() against a gitignored file that --update rewrote on
every run, so it could never fail. It now asserts the link's tag, href,
title, disabled state and classes. --update leaves test and test:ci;
test:update regenerates snapshots on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ajslater
ajslater merged commit 86080f3 into develop Oct 19, 2026
13 checks passed
@ajslater
ajslater deleted the v2-ci-test-speed branch October 19, 2026 04:32
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