CI: make a push report sooner and cost less, without new hardware #1033

Open
opened 2026-10-22 01:06:16 +00:00 by tiagoagueda · 2 comments
Owner

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/1 with {"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.

Job Wall Costly step Its time Runs
Browser tests (Chromium) 29.3 min Drive the critical path in a browser 27 min 29 s 869 tests, one process, 1.9 s each
Unit tests and coverage (3.14) 13.7 min Test, and measure what the suite covers 11 min 55 s 19,639 tests, two workers; plus 39 s for 19 heavy tests in a fresh process
Checks 2.5 min checkout with the whole history 1 min 9 s the pack is 569 MiB; every check after it is 1–24 s
Database tests on PostgreSQL 3.9 min the tests, then the backup round trip 1 min 41 s + 1 min 5 s
Security audit 0.9 min pip-audit over the exported lock 33 s
Dev image (own runner) 50.1 min wait for CI; scan; multi-arch build 29 min 34 s + 5 min 41 s + 13 min 10 s the wait holds no CI slot; the build and scan share the six cores with the next push's CI

When 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.

Day CI on push CI on pull request Every Python on push (2 legs) Dev images
18 Oct 4 6 1 4
19 Oct 12 13 8 4
20 Oct 20 19 14 6
21 Oct 12 12 7 5

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.yml has on.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.yml carries the same filter. One push to an issue branch therefore starts CI on push (48 runner-minutes), CI on pull_request for 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:

on:
  push:
    branches: [main, "[0-9]*.[0-9]*.[0-9]*"]

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; main keeps its push run, which the release gate and the dev image's wait-ci read 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.py and tests/test_image_build.py read 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:

  • A second worker, measured again. -n 2 --dist worksteal was 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.
  • Every browser test flushes 85 tables. live_server makes 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=0 run; if it is over a minute, available_apps on the live-server tests is the cheap remedy.
  • The walks are a quarter of the suite in ten tests. Light with every instrument 77.7 s plus a 37.5 s setup, dark 69.6 s, two phone walks 61.4 s and 58.8 s, 200 % zoom 21.9 s, compact target size 24.2 s, reflow in three languages 20 + 19 + 19 s: about 6.5 of 27 minutes. Parametrising each walk over two halves of 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.
  • Map pages fetch OpenStreetMap tiles during the walks. tests/e2e/test_map.py routes tile.openstreetmap.org locally; the walks do not, and settings/test.py leaves POSTULO_MAP_TILES at osm. 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 chromium runs apt every time, 29 s with the browser cached. Put Chromium's libraries in the existing apt step, drop --with-deps, consider --only-shell.
  • Not this: splitting the browser suite into two jobs takes both slots and pushes the unit chain behind them; the push reports later.

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_CORE is not worth touching.

  • A third worker. -n 2 dates from three matrix legs sharing the host (#233). After item 1 the host runs one unit and one browser job per push; -n 3 beside 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.
  • The test database is 215 migrations, 32–35 s per process, five times per push: each xdist worker, the heavy step, 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 the heavy step its file, pytest -m heavy tests/security/test_markdown.py, instead of re-collecting 25,632 items, about 15 s; (b) a django_db_setup override 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, which tests/test_migration_*.py (naming accounts_0021, documents_0005) would need rewriting for. --no-migrations is not an option: the migration tests need the graph.
  • The slowest tests (CI, run 1169): test_migration_identifier_case::test_colliding_identifiers_are_folded_and_the_migration_completes 50.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_reads 23.2 s (compiles all 2,184 catalogues; one language proves the same); test_migration_reversal::test_accounts_0021_refuses_to_be_undone 21.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_alone 9.5 s.
  • Where the rest goes. A local run with every duration recorded (Windows, six workers, 9 min 44 s wall, 44.9 worker-minutes; the shape carries over, the absolute times do not): 80 phases over 5 s are 22.3 of the 44.9 worker-minutes, half the suite; 12,901 phases under 0.1 s are 5.6. By file: test_plugin_install.py 4.4 worker-minutes and test_plugin_rollback.py 2.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.py 3.8, with 27–32 s fixture setups that load every catalogue once per worker.
  • 6,126 of the 19,067 collected items are 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 ci 4 s). Three steps remain:

Step Jobs Each Change Saves per push
Compile the translations browser, unit, PostgreSQL, each Every Python leg 18–37 s cache the .mo files with actions/cache keyed on hashFiles('**/*.po') and the compiler's file 1–1.5 min of runner time, 20 s of each job
Checkout with the whole history (Checks) checks 69 s the guard needs merge-base origin/main HEAD and rev-list; try filter: blob:none, and if Forgejo refuses partial clone, fetch-depth: 200 plus git fetch --deepen until the merge base is found about 1 min of the chain
Install Chromium browser 29 s see item 2 25 s of the critical path

Checks and Security could be one job (saves one fixed cost of about 20 s and one queue place; REQUIRED_JOBS in scripts/release_tools.py would need its regex adjusted). The repeated Install uv lines 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:

Layer Time Architecture-dependent
messages.py compile + delete .po 381 s no
apt update, upgrade, install (runtime) 282 s yes
npm ci (styles) 164 s no
uv sync 162 s yes
collectstatic 120 s no
npm run build:css 38 s no
  • Native stages for architecture-free work: FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS styles and a --platform=$BUILDPLATFORM python:3.14-slim-bookworm AS catalogues stage that compiles once; the build stage copies src from 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. collectstatic can follow with a native venv of its own, a second step. tests/test_image_build.py holds the Dockerfile's shape and needs the new stage names.
  • One builder for scan and push. The scan builds into the daemon and the push with a docker-container builder; they share no layers. Using buildx for both (--load for the scan, --push with --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".
  • Trivy scans three times in scripts/scan-image.sh (table, CycloneDX, fixable gate), then Grype once. One trivy image --format json plus two trivy convert calls (the gate's exit code from trivy 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 the postulo-trivy-cache volume; worth finding out why the volume does not hold between runs on this daemon, not skipping the update.
  • Keep: the wait for CI before building (#716), cancel-in-progress, both architectures.

Order of work

Each item is one pull request; savings are estimates to re-measure from the step tables.

  • 1. Push filter matches release branches only (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.
  • 2. Browser suite on two workers, re-measured (ci.yml) — about 12 min of wall per push; three green runs with no click timeouts.
  • 3. Unit suite on three workers (ci.yml, every-python.yml) — about 3.5 min of the second slot's chain.
  • 4. Native stages for catalogues and styles (docker/Dockerfile, tests/test_image_build.py) — about 6 min of host load per dev image.
  • 5. One Trivy scan converted three ways; one buildx builder for scan and push (scripts/scan-image.sh, dev-image.yml, image.yml) — 3–5 min of the dev image.
  • 6. Cache the compiled catalogues; blobless checkout for Checks; Chromium libraries into the apt step (ci.yml) — about 1 min of each chain, 2 min of runner time.
  • 7. heavy step by path; map tiles off in settings/test.py — 15 s and a removed outside dependency.
  • 8. Slow tests: one language in the compile test; a shared forward migration in the reversal tests; a session wheel for the plugin install tests; one lint test per template (tests/) — 1–2 min of the unit step.
  • 9. Decide on the migration count: squash, or a schema built once per job — 35 s on every job's path, 3 min of processor per push.

After 1–3 a green push to main should 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 if on 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).

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/1` with `{"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`. | Job | Wall | Costly step | Its time | Runs | | --- | --- | --- | --- | --- | | Browser tests (Chromium) | 29.3 min | Drive the critical path in a browser | 27 min 29 s | 869 tests, one process, 1.9 s each | | Unit tests and coverage (3.14) | 13.7 min | Test, and measure what the suite covers | 11 min 55 s | 19,639 tests, two workers; plus 39 s for 19 `heavy` tests in a fresh process | | Checks | 2.5 min | checkout with the whole history | 1 min 9 s | the pack is 569 MiB; every check after it is 1–24 s | | Database tests on PostgreSQL | 3.9 min | the tests, then the backup round trip | 1 min 41 s + 1 min 5 s | | | Security audit | 0.9 min | pip-audit over the exported lock | 33 s | | | Dev image (own runner) | 50.1 min | wait for CI; scan; multi-arch build | 29 min 34 s + 5 min 41 s + 13 min 10 s | the wait holds no CI slot; the build and scan share the six cores with the next push's CI | When 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. | Day | CI on push | CI on pull request | Every Python on push (2 legs) | Dev images | | --- | --- | --- | --- | --- | | 18 Oct | 4 | 6 | 1 | 4 | | 19 Oct | 12 | 13 | 8 | 4 | | 20 Oct | 20 | 19 | 14 | 6 | | 21 Oct | 12 | 12 | 7 | 5 | 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.yml` has `on.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.yml` carries the same filter. One push to an issue branch therefore starts CI on `push` (48 runner-minutes), CI on `pull_request` for 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: ```yaml on: push: branches: [main, "[0-9]*.[0-9]*.[0-9]*"] ``` 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; `main` keeps its push run, which the release gate and the dev image's `wait-ci` read 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.py` and `tests/test_image_build.py` read 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: - **A second worker, measured again.** `-n 2 --dist worksteal` was 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. - **Every browser test flushes 85 tables.** `live_server` makes 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=0` run; if it is over a minute, `available_apps` on the live-server tests is the cheap remedy. - **The walks are a quarter of the suite in ten tests.** Light with every instrument 77.7 s plus a 37.5 s setup, dark 69.6 s, two phone walks 61.4 s and 58.8 s, 200 % zoom 21.9 s, compact target size 24.2 s, reflow in three languages 20 + 19 + 19 s: about 6.5 of 27 minutes. Parametrising each walk over two halves of `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. - **Map pages fetch OpenStreetMap tiles during the walks.** `tests/e2e/test_map.py` routes `tile.openstreetmap.org` locally; the walks do not, and `settings/test.py` leaves `POSTULO_MAP_TILES` at `osm`. 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 chromium` runs apt every time**, 29 s with the browser cached. Put Chromium's libraries in the existing apt step, drop `--with-deps`, consider `--only-shell`. - **Not this:** splitting the browser suite into two jobs takes both slots and pushes the unit chain behind them; the push reports later. ## 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_CORE` is not worth touching. - **A third worker.** `-n 2` dates from three matrix legs sharing the host (#233). After item 1 the host runs one unit and one browser job per push; `-n 3` beside 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. - **The test database is 215 migrations, 32–35 s per process, five times per push**: each xdist worker, the `heavy` step, 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 the `heavy` step its file, `pytest -m heavy tests/security/test_markdown.py`, instead of re-collecting 25,632 items, about 15 s; (b) a `django_db_setup` override 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, which `tests/test_migration_*.py` (naming `accounts_0021`, `documents_0005`) would need rewriting for. `--no-migrations` is not an option: the migration tests need the graph. - **The slowest tests** (CI, run 1169): `test_migration_identifier_case::test_colliding_identifiers_are_folded_and_the_migration_completes` 50.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_reads` 23.2 s (compiles all 2,184 catalogues; one language proves the same); `test_migration_reversal::test_accounts_0021_refuses_to_be_undone` 21.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_alone` 9.5 s. - **Where the rest goes.** A local run with every duration recorded (Windows, six workers, 9 min 44 s wall, 44.9 worker-minutes; the shape carries over, the absolute times do not): 80 phases over 5 s are 22.3 of the 44.9 worker-minutes, half the suite; 12,901 phases under 0.1 s are 5.6. By file: `test_plugin_install.py` 4.4 worker-minutes and `test_plugin_rollback.py` 2.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.py` 3.8, with 27–32 s fixture setups that load every catalogue once per worker. - **6,126 of the 19,067 collected items are `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 ci` 4 s). Three steps remain: | Step | Jobs | Each | Change | Saves per push | | --- | --- | --- | --- | --- | | Compile the translations | browser, unit, PostgreSQL, each Every Python leg | 18–37 s | cache the `.mo` files with `actions/cache` keyed on `hashFiles('**/*.po')` and the compiler's file | 1–1.5 min of runner time, 20 s of each job | | Checkout with the whole history (Checks) | checks | 69 s | the guard needs `merge-base origin/main HEAD` and `rev-list`; try `filter: blob:none`, and if Forgejo refuses partial clone, `fetch-depth: 200` plus `git fetch --deepen` until the merge base is found | about 1 min of the chain | | Install Chromium | browser | 29 s | see item 2 | 25 s of the critical path | Checks and Security could be one job (saves one fixed cost of about 20 s and one queue place; `REQUIRED_JOBS` in `scripts/release_tools.py` would need its regex adjusted). The repeated `Install uv` lines 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: | Layer | Time | Architecture-dependent | | --- | --- | --- | | `messages.py compile` + delete `.po` | 381 s | no | | apt update, upgrade, install (runtime) | 282 s | yes | | `npm ci` (styles) | 164 s | no | | `uv sync` | 162 s | yes | | `collectstatic` | 120 s | no | | `npm run build:css` | 38 s | no | - **Native stages for architecture-free work:** `FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS styles` and a `--platform=$BUILDPLATFORM python:3.14-slim-bookworm AS catalogues` stage that compiles once; the build stage copies `src` from 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. `collectstatic` can follow with a native venv of its own, a second step. `tests/test_image_build.py` holds the Dockerfile's shape and needs the new stage names. - **One builder for scan and push.** The scan builds into the daemon and the push with a `docker-container` builder; they share no layers. Using buildx for both (`--load` for the scan, `--push` with `--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". - **Trivy scans three times** in `scripts/scan-image.sh` (table, CycloneDX, fixable gate), then Grype once. One `trivy image --format json` plus two `trivy convert` calls (the gate's exit code from `trivy 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 the `postulo-trivy-cache` volume; worth finding out why the volume does not hold between runs on this daemon, not skipping the update. - **Keep:** the wait for CI before building (#716), `cancel-in-progress`, both architectures. ## Order of work Each item is one pull request; savings are estimates to re-measure from the step tables. - [ ] 1. Push filter matches release branches only (`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. - [ ] 2. Browser suite on two workers, re-measured (`ci.yml`) — about 12 min of wall per push; three green runs with no click timeouts. - [ ] 3. Unit suite on three workers (`ci.yml`, `every-python.yml`) — about 3.5 min of the second slot's chain. - [ ] 4. Native stages for catalogues and styles (`docker/Dockerfile`, `tests/test_image_build.py`) — about 6 min of host load per dev image. - [ ] 5. One Trivy scan converted three ways; one buildx builder for scan and push (`scripts/scan-image.sh`, `dev-image.yml`, `image.yml`) — 3–5 min of the dev image. - [ ] 6. Cache the compiled catalogues; blobless checkout for Checks; Chromium libraries into the apt step (`ci.yml`) — about 1 min of each chain, 2 min of runner time. - [ ] 7. `heavy` step by path; map tiles off in `settings/test.py` — 15 s and a removed outside dependency. - [ ] 8. Slow tests: one language in the compile test; a shared forward migration in the reversal tests; a session wheel for the plugin install tests; one lint test per template (`tests/`) — 1–2 min of the unit step. - [ ] 9. Decide on the migration count: squash, or a schema built once per job — 35 s on every job's path, 3 min of processor per push. After 1–3 a green push to `main` should 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 `if` on 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).
tiagoagueda added this to the 0.5.0 milestone 2026-10-25 17:05:20 +00:00
Author
Owner

From 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_server transactional, so each browser test ends with flush. tests/e2e/conftest.py:43 wraps live_server; nothing sets available_apps or serialized_rollback, and Postulo registers no post_migrate handler 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") then call_command("flush") five times each way): migrate 54.3 s, 98 tables; flush with inhibit_post_migrate=False (what a transactional test does today) 0.261, 0.244, 0.231, 0.235, 0.233 s; with inhibit_post_migrate=True 0.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_apps on 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's django_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 inhibits post_migrate. Permissions and content types would then be emptied by the first flush and not recreated, so tests that rely on Permission rows or ContentType ids need checking (a session fixture that recreates them once, or serialized_rollback, are the alternatives).

Checked against main at daee30b15 (2026-10-30).

From 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_server` transactional, so each browser test ends with `flush`. `tests/e2e/conftest.py:43` wraps `live_server`; nothing sets `available_apps` or `serialized_rollback`, and Postulo registers no `post_migrate` handler 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")` then `call_command("flush")` five times each way): migrate 54.3 s, 98 tables; `flush` with `inhibit_post_migrate=False` (what a transactional test does today) 0.261, 0.244, 0.231, 0.235, 0.233 s; with `inhibit_post_migrate=True` 0.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_apps` on 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's `django_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 inhibits `post_migrate`. Permissions and content types would then be emptied by the first flush and not recreated, so tests that rely on `Permission` rows or `ContentType` ids need checking (a session fixture that recreates them once, or `serialized_rollback`, are the alternatives). Checked against `main` at `daee30b15` (2026-10-30).
tiagoagueda modified the milestone from 0.5.0 to 0.6.0 2026-10-31 15:55:19 +00:00
Author
Owner

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.

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.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#1033
No description provided.