CI: make a push report sooner and cost less, without new hardware #1033
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#1033
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
A review of where CI's time goes and what the workflows, the Dockerfile and the test suite can give back, without touching the runner host. Figures are from runs 1145–1175 (18–22 October 2026), read from the task API and the step tables (
POST …/runs/<n>/jobs/<pos>/attempt/1with{"logCursors":[]}), and from a local run of the unit suite with every duration recorded. Every number here should be re-measured the same way once a change lands rather than trusted.Where a push's time goes today
The runner takes two jobs at a time, in file order. On a quiet host the browser job holds one slot for 29 minutes while the other runs the unit job, the checks, PostgreSQL and the audit one after another in about 21 minutes, so a push reports when the browser job ends. Set-up is about 1.5 minutes per job; the tests are the rest. Reference: run 1169, a green push to
main.heavytests in a fresh processWhen the host is busy these stretch rather than queue: on 20 and 21 October the browser job took 36, 43, 47 and 80 minutes on four pushes, and the unit job 18–36 minutes, with the same tests. Load is the first problem.
A CI run is about 48 runner-minutes (29 + 13 + 2.5 + 4 + 1); an Every Python run about 28. The 20th asked the two-slot runner for roughly 2,300 runner-minutes, 19 hours of its day, before the dev images.
1. Every issue branch runs CI three times
ci.ymlhason.push.branches: [main, "[0-9]*"], written for release branches (0.3.0). Since #940 every issue has a branch named after its number (933-date-pickers,941-company-status) and the glob matches them all.every-python.ymlcarries the same filter. One push to an issue branch therefore starts CI onpush(48 runner-minutes), CI onpull_requestfor the same commit (48), and Every Python's 3.12 and 3.13 legs (28), the legs #713 took out of the per-push path. Runs 1171 (push), 1175 (PR #1032) and 1172 (Every Python) are one commit. Branches without a leading digit (weblate-*,sec2-*,audit-*) got the pull-request run alone, which is the intended behaviour.Change, in both workflows:
No issue branch has a dot; every release branch has two. The pull-request event keeps checking every issue branch on every push to it;
mainkeeps its push run, which the release gate and the dev image'swait-ciread as(push)contexts. A branch push goes from 122 runner-minutes to 48 and the host's load roughly halves, which is what turns the 36–80 minute browser jobs back into 29 minute ones. Nothing is tested less. Cost: a branch pushed before its pull request exists is unchecked until it does; a draft PR closes that gap.tests/test_release_tools.pyandtests/test_image_build.pyread the workflows and must still pass; the comment above the filter should say why issue branches are excluded, since the glob's intent was misread once.2. The browser job is the critical path
869 tests in 27 min 20 s, one process. Sign-in is a cookie (#720), traces are off (#715), the walks share loads (#722) and start first. What is left, largest first:
-n 2 --dist workstealwas tried in run 723 under three CI runs per push plus a dev image build (load average near 19) and Chromium went 30 s without answering a click. With item 1 in place the host carries one run per push; re-measure before anything else here. The work spreads nearly evenly, so two workers put the job at roughly 15 minutes and the push reports at about 21 instead of 29. Guard rail: browser plus unit workers at most five on six cores; read the pressure counters the diagnose step prints.live_servermakes a test transactional in pytest-django, so every one of the 869 tests ends with a truncate of every table instead of a rollback. Measure with the teardown column of a--durations=0run; if it is over a minute,available_appson the live-server tests is the cheap remedy.signed_in_paths()lets two workers carry half a walk each; worth two to three minutes of tail once a second worker exists, nothing before.tests/e2e/test_map.pyroutestile.openstreetmap.orglocally; the walks do not, andsettings/test.pyleavesPOSTULO_MAP_TILESatosm. Seconds per walk, and an outside dependency inside the gate. Set the built-in outline in the test settings, or add the route to the walks' page.playwright install --with-deps chromiumruns apt every time, 29 s with the browser cached. Put Chromium's libraries in the existing apt step, drop--with-deps, consider--only-shell.3. The unit job
19,639 tests in 11 min 47 s on two workers, 72 ms a test. Coverage costs nothing measurable (the 3.12 leg without it: 11 min 46 s in run 1172), so
COVERAGE_COREis not worth touching.-n 2dates from three matrix legs sharing the host (#233). After item 1 the host runs one unit and one browser job per push;-n 3beside a one-process browser leaves about four cores busy and takes the step from about 12 to about 8.5 minutes. Keep browser plus unit workers at five or fewer.heavystep, the first browser walk (its 37.5 s setup) and the PostgreSQL job. About three minutes of processor per push, 35 s on every job's path. In order of effort: (a) give theheavystep its file,pytest -m heavy tests/security/test_markdown.py, instead of re-collecting 25,632 items, about 15 s; (b) adjango_db_setupoverride that migrates a file-backed SQLite once per job and copies it per worker, measured against the in-memory cost on every query; (c) squash the migrations, whichtests/test_migration_*.py(namingaccounts_0021,documents_0005) would need rewriting for.--no-migrationsis not an option: the migration tests need the graph.test_migration_identifier_case::test_colliding_identifiers_are_folded_and_the_migration_completes50.3 s;test_csv_import::test_a_sheet_at_the_cap_…29.5 s;test_messages_tool::test_compile_writes_the_status_report_the_picker_reads23.2 s (compiles all 2,184 catalogues; one language proves the same);test_migration_reversal::test_accounts_0021_refuses_to_be_undone21.9 s + 10.1 s teardown and…documents_0005…12.7 s + 10.1 s teardown (a module-scoped fixture that migrates back once would save about 30 s of a worker);test_translations::test_the_interface_renders_in_every_language[af]15.7 s setup;test_imports_alone9.5 s.test_plugin_install.py4.4 worker-minutes andtest_plugin_rollback.py2.8, 135 phases that install real plugin packages through subprocesses at 11–18 s for the slowest (a session-built wheel and a shared environment per worker would cut most of it);test_translations.py3.8, with 27–32 s fixture setups that load every catalogue once per worker.tests/test_template_lint.py: 296 templates × about 20 lints, each its own test. One test per template that runs every lint and reports every failure drops about 5,800 items and 15–30 s per worker (collection is done by the controller and each worker), at the price of a less specific test id.4. Set-up repeated in every job
The caches work (uv restores in 6–18 s, Chromium from cache,
npm ci4 s). Three steps remain:.mofiles withactions/cachekeyed onhashFiles('**/*.po')and the compiler's filemerge-base origin/main HEADandrev-list; tryfilter: blob:none, and if Forgejo refuses partial clone,fetch-depth: 200plusgit fetch --deepenuntil the merge base is foundChecks and Security could be one job (saves one fixed cost of about 20 s and one queue place;
REQUIRED_JOBSinscripts/release_tools.pywould need its regex adjusted). The repeatedInstall uvlines are not a performance item: a composite action would have to resolve from this instance.5. The dev image
Its 50 minutes hold no CI slot, but its 20 minutes of building and scanning use the same six cores as the next push's CI. Run 1170's push build, slowest arm64 layers under QEMU:
messages.py compile+ delete.ponpm ci(styles)uv synccollectstaticnpm run build:cssFROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS stylesand a--platform=$BUILDPLATFORM python:3.14-slim-bookworm AS cataloguesstage that compiles once; the build stage copiessrcfrom it. BuildKit runs such a stage once and both platforms copy from it: about 580 s out of the arm64 half, the two builds at about seven minutes instead of thirteen.collectstaticcan follow with a native venv of its own, a second step.tests/test_image_build.pyholds the Dockerfile's shape and needs the new stage names.docker-containerbuilder; they share no layers. Using buildx for both (--loadfor the scan,--pushwith--cache-from/--cache-to type=local) reuses every amd64 layer, saves one to two minutes, and makes the scanned image and the published amd64 image the same layers rather than "the same commit at the same moment".scripts/scan-image.sh(table, CycloneDX, fixable gate), then Grype once. Onetrivy image --format jsonplus twotrivy convertcalls (the gate's exit code fromtrivy convert --exit-code --ignore-unfixed --severity) produce all three from one scan: two to three of the 5 min 41 s. The vulnerability database downloads on every run despite thepostulo-trivy-cachevolume; worth finding out why the volume does not hold between runs on this daemon, not skipping the update.cancel-in-progress, both architectures.Order of work
Each item is one pull request; savings are estimates to re-measure from the step tables.
ci.yml,every-python.yml) — 74 of 122 runner-minutes per branch push; verify one branch push starts one CI run and no Every Python run.ci.yml) — about 12 min of wall per push; three green runs with no click timeouts.ci.yml,every-python.yml) — about 3.5 min of the second slot's chain.docker/Dockerfile,tests/test_image_build.py) — about 6 min of host load per dev image.scripts/scan-image.sh,dev-image.yml,image.yml) — 3–5 min of the dev image.ci.yml) — about 1 min of each chain, 2 min of runner time.heavystep by path; map tiles off insettings/test.py— 15 s and a removed outside dependency.tests/) — 1–2 min of the unit step.After 1–3 a green push to
mainshould report in about 21 minutes instead of 29 and a branch push should cost 48 runner-minutes instead of 122. After 4–7 the chain behind the browser job is about 15 minutes, at which point a third browser worker is the next question.Two rules from the measured history survive all of it: never skip a job with a job-level
ifon this runner (it fails the job), and re-measure the worker counts whenever something else starts sharing the host; every starvation so far came from a change elsewhere.Not measured
The per-test flush cost in the browser suite (inferred, not timed); a second browser worker on the quieter host (the only trial was under the load item 1 removes); whether Forgejo accepts
filter: blob:none; why Trivy's cache volume does not persist; collection time on CI (the log's timestamps are flush times; 18 s locally stands in).compileparses every catalogue twice and builds each .mo by repeated byte concatenation #1145proposejob has a job-levelif, which this runner turns into a failure #1159extract --checkfinds the catalogue sets 29 times, andextractrewrites all 2,352 catalogues even when none changed #1169From the efficiency and reliability audit of 2026-10-30 (finding T2), what this issue does not yet say:
pytest-django 4.14 makes a test that uses
live_servertransactional, so each browser test ends withflush.tests/e2e/conftest.py:43wrapslive_server; nothing setsavailable_appsorserialized_rollback, and Postulo registers nopost_migratehandler of its own (no hit outside migrations), so the rebuild is Django's content types and auth permissions.Measured with a script under the test settings (
postulo.config.settings.test, database pointed at a SQLite file,call_command("migrate")thencall_command("flush")five times each way): migrate 54.3 s, 98 tables;flushwithinhibit_post_migrate=False(what a transactional test does today) 0.261, 0.244, 0.231, 0.235, 0.233 s; withinhibit_post_migrate=True0.012, 0.008, 0.008, 0.008, 0.008 s. The browser suite itself was not run (the brief forbids it).#1033 section 2 ("Every browser test flushes 85 tables") already names this, proposes
available_appson the live-server tests, and lists the per-test cost under "Not measured". This file supplies that number; it is not a new issue.Nothing new to file. Add the measurement to #1033 as a comment: the cost is almost entirely
post_migrate, so the remedy is whatever inhibits it.available_apps(pytest-django'sdjango_db(available_apps=[...])) does that but limits which apps' tables are flushed, and a test touching a table outside the list leaks rows into the next; listing every installed app keeps the flush complete and still inhibitspost_migrate. Permissions and content types would then be emptied by the first flush and not recreated, so tests that rely onPermissionrows orContentTypeids need checking (a session fixture that recreates them once, orserialized_rollback, are the alternatives).Checked against
mainatdaee30b15(2026-10-30).Moved to 0.6.0 at the maintainer's word (2026-10-31), to release 0.5.0 today. Items 1–3 and 6–7 landed in 0.5.0 (#1072, #1083, #1085); items 4–5, 8 and 9 continue here.