Skip to content

Speed up CI: cache scoping, Python test staging, and Morango/postgres test overheads - #15167

Open
rtibbles wants to merge 9 commits into
learningequality:developfrom
rtibbles:ci-cache-scoping-and-test-staging
Open

Speed up CI: cache scoping, Python test staging, and Morango/postgres test overheads#15167
rtibbles wants to merge 9 commits into
learningequality:developfrom
rtibbles:ci-cache-scoping-and-test-staging

Conversation

@rtibbles

@rtibbles rtibbles commented Aug 8, 2026

Copy link
Copy Markdown
Member

Summary

Attempt to optimize CI runs on PRs:

  • Run pr build Kolibri on pushes to develop to warm all caches
  • Simplify pi build caching to avoid loading too much in the cache (we're close to the 10GB cache limit on Github)
  • Remove max-parallel on Python tests to remove artificial bottlenecks
  • Move postgres to the second tier of tests
  • Run postgres tests with less durability and reliability for more speed (fine because they're only test instances!)
  • Tweak morango integration tests to speed up by using cryptography, preseeding all migrations, and using a single kolibri shell invocation if possible to create data - also use a cheaper password hasher for creating users (parallel to what we already do for regular tests)

Measured effect

Medians over CI runs. Baseline is contemporaneous runs of the unmodified workflow (7 other PRs plus 2 develop pushes, 8-9 Aug); after is 7 runs on this branch.

Before After
Python tests: wall clock 26.1 min 21.7 min −17%
Python tests: time to first required check 12.7 min 7.5 min −41%
Python tests: runner time per push 107 min 95.5 min −11%
Postgres job: test step 635 s 540 s −15%
Morango integration: wall clock 38.5 min 33.4 min −13%
Morango integration: per-version leg 35.1 min 26.5 min −25%
Morango integration: runner time per run 357 min 276 min −23%
Project install, per job 31-55 s 3-6 s −90%
Actions cache held per PR ref 1.7-3.1 GB 0.79 GB 1.10 GB directly dropped

Spread matters as much as the median: the postgres test step was 560-1133 s and is now 537-572 s, and the slowest baseline run (46.4 min) was slow because postgres took 20.6 min. Runner time only drops on runs including the setuptools_scm commit; everything else here moves wall clock, not runner time.

References

Prior caching work: #14852.

Reviewer guidance

  • Do tests pass more quickly?
  • Any concerns with the tweaks to Morango tests?
  • Anything that is not going to get cached now?
  • kolibri/deployment/default/settings/test.py:28 — MD5 hashing now applies to integration runs too, and only works because the spawned servers get the matching hasher from settings/integration_test_server.py. Check nothing else spawns a server on base settings and then verifies a password.
  • kolibri/core/auth/test/sync_utils.py:193 — the preseeded home is now built once and copied per server, so anything that writes back to it would leak between tests rather than being rebuilt.
  • .github/workflows/pr_build_kolibri.yml:8 — the concurrency group falls back to github.ref on push; confirm that cannot cancel a PR run that shares a group.

AI usage

Used Claude Code to profile the test suites, identify the bottlenecks from CI logs and cProfile, and implement the changes. Verified with the Morango integration suite, the content test suite under both database engines, and prek.

rtibbles and others added 7 commits August 8, 2026 00:30
Every stage 2 job waited on stage 1, and stage 1 waited on postgres.
Postgres runs the same suite roughly three times slower than sqlite
(23.6 vs 8.0 minutes measured), so a run that could have started its
stage 2 jobs at 8 minutes started them at 24, and finished at 44.

Gate stage 2 on postgres instead. It still starts off pre_job, so it now
runs alongside stage 2 rather than in front of it, and the required
Python tests check still covers it. Expected wall clock ~27 minutes.

The tradeoff is that a postgres failure no longer short-circuits stage
2, so a PR breaking only postgres spends that compute before going red.

Drop max-parallel: 5 while here. It held the 3.14 leg back by 5.5
minutes on the stage 2 matrix; on the other three matrices it is below
the cap and never did anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The source image is an input to the base image and to nothing else —
kolibri.Pifile reads base.img and the debs. Caching both stored the same
work twice, at ~1.5GB per run.

Keep the base image, which is the last artifact that does not depend on
the deb. Dropping the source cache means make must not go looking for
source.img when a base image is already present: it is a missing
prerequisite, so make would re-download 500MB and then rebuild the base
image it already had. Guard on the base image instead, and give
images/base.img the real prerequisite for the case where it is absent.

pimod goes with it. A depth-1 clone of a pinned tag is 0.56s and 360KB,
it is needed on every run whatever the cache state, and it shared the
source key, so any Makefile edit re-cloned it anyway.

Add the Makefile to the base image key, since it pins SOURCE_URL. Both
old keys omitted it, so bumping the raspios version restored a base
image built from the previous one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cache is readable only from the ref that wrote it or from the default
branch, and fork PRs cannot write caches at all, so develop is the only
scope that serves every PR. Warming just the WHL build left the rest of
the graph writing caches nowhere useful.

The Pi image is why this matters. Its keys are exact-match with no
restore-keys, so a PR that misses rebuilds the base image and saves it
under refs/pull/N/merge, where no other PR can read it. Four in-flight
PRs were holding 5.95GB that way — 61% of the repo's 10GB budget — and
had evicted the develop-scoped webpack cache that build_whl.yml exists
to prime, so PRs were also paying for cold webpack compiles. Priming on
develop turns those misses into hits, and actions/cache skips its save
step on a hit, so the per-PR copies stop being written.

warm_build_cache.yml is now a subset of this workflow, so drop it.

pr_build_comment.yml needs no change: it already notices when no open PR
matches the head SHA and skips. Concurrency falls back to ref rather
than run_id, since head_ref is empty on push and successive develop
pushes would otherwise build concurrently and race on the same keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Morango picks a key implementation at import: Cryptography, then M2Crypto,
then a pure-Python fallback. Nothing pulled cryptography into the test
environment, so it took the fallback and spent 3.4s generating a 2048-bit
keypair for every facility root certificate. With it, the same keygen is
0.098s - a 34x difference, measured by profiling generateuserdata.

This moves tests towards production rather than away from it: shipped
Kolibri gets cryptography from requirements/cext.txt, so the fallback was
only ever exercised in development.

A floor rather than a pin, so 3.6 and 3.7 resolve to 40.0.2, their last
supported release, without pinning any newer Python to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three overheads, none of which are what these tests exercise.

Every multiple_kolibri_servers entry ran a full migration to build the
home its servers are copied from - 19s, 15 times over. The home depends
only on the kwargs it was built with and is never written to afterwards,
so build one per distinct set and reuse it. KOLIBRI_TEST_PRESEEDED_HOME
already existed for this but was read from nowhere, so it never took
effect; it now overrides the location rather than being the only way in.

The spawned servers ran base settings, so they hashed with Django's
PBKDF2 default while the test process used MD5 - which is why the MD5
tweak was disabled for integration runs, an md5$ hash being unverifiable
on the server. Give the servers a settings module that inherits base and
agrees on the hasher, and both sides can use it: pbkdf2_hmac was 1.2s per
generateuserdata alone.

Polling server startup every 0.5s spends a quarter second per start, on
average, after the server is already answering.

Together these take SingleUserSyncRegressionsTestCase from 325s to 210s,
alongside the cryptography change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every create_model is its own kolibri shell startup, and _create_objects
made seven of them per call, ten times per run. It also read each object
back through the server's db alias purely to get an id to pass to the
next create.

run_scenario sends a function reference instead, so a whole group of
related objects costs one startup and the intermediate lookups become
local variables. Creation still happens in the server's own process
through the normal ORM path, which is what the module docstring requires
and what Morango's save() hooks depend on - a fixture would not, since
loaddata saves raw and skips them.

test_chaos_sync goes from 305.5s to 283.8s. The scenarios live in a
module rather than the test file because the server has to import them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every COMMIT against the service container costs an fsync, and the test
suite makes a lot of them: profiling 18 content tests showed 1565 commits
at 1.9ms each, with the process at 20% CPU. It was waiting on the disk,
not computing. The database is created and thrown away within the job, so
there is nothing for that durability to protect.

The content suite, 1424 tests, goes from 505s to 158s. The same 3.2x
shows up on test_annotation.py alone (81s to 25s), so it is the commit
path rather than anything specific to one module.

Not applied to the SSL smoke test, whose test step is 14s - the rest of
that job is dependency install and container startup.

actionlint's schema has no `command:` key for services, though the runner
honors it; .github/actionlint.yaml already ignored that false positive for
tox.yml, so extend it to the two workflows that now need it as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added DEV: dev-ops Continuous integration & deployment DEV: backend Python, databases, networking, filesystem... SIZE: very large labels Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

setup-uv builds its cache key from the runner's system Python, not the matrix
version, so every leg of every matrix shared one key and raced to reserve it.
52 of the 88 warnings in a PR run were losers of that race.

Per-version keys would end the race but add ~400MB to a repo cache pool that is
already over cap and evicting. Not worth it: on a verified cache miss,
downloading 111 packages and building kolibri and three sdists took 2.46s in
total, and 860ms on the 3.6 buster container. Building kolibri dominates the
step at 30-60s, and uv cannot cache that.

cache-python was also set on four jobs that never install a managed Python, and
the WHL test matrix checks out nothing, so its key could never invalidate and
its empty workdir warned on every leg.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xxxJ57Fkk7mbU6kKLNrva
@rtibbles
rtibbles force-pushed the ci-cache-scoping-and-test-staging branch 5 times, most recently from b0423ff to 2e16095 Compare August 9, 2026 04:11
Its file finder enumerates the tree by streaming a full `git archive HEAD` and
parsing the tar — 69MB of blob content here to list 5378 names. Cold on a
runner that is 25-44s, and 35 jobs in a PR run install the project, so it costs
~23 minutes of runner time per push. Warm it is 0.2s, which is why it never
shows up locally.

SETUPTOOLS_SCM_IGNORE_VCS_ROOTS short-circuits the finder before the archive
call. Versioning is a separate code path and is unaffected. There is no
pyproject.toml equivalent — the finder is a global setuptools entry point that
never sees our config. Measured across the Python matrix, installs go from
31-55s to 3-6s.

Windows sets the variable from `git rev-parse --show-toplevel` instead of
github.workspace: setuptools_scm compares the ignored roots to git's own output
as strings, and github.workspace is backslashed there. The buster containers
neither set it nor need it — they install with `uv pip install --system` and
run from PYTHONPATH, so they never build the project.

MANIFEST.in becomes the sole authority, which is how it already reads. That
drops 2074 files and 13.5MB of repo furniture the finder was force-including
into the sdist on top of it: a 323KB nssm.exe, docs screenshots, uv.lock and
pnpm-lock.yaml, eslint/jest/prettier config, .github, packages, platforms.
Under kolibri/ it takes the locale .po files too, so those are now declared;
the LC_MESSAGES README.md and .tbx glossary go, being neither declared nor
wanted. Wheel contents are otherwise byte-identical, and the debian package,
which is built from the sdist, still builds and serves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xxxJ57Fkk7mbU6kKLNrva
@rtibbles
rtibbles force-pushed the ci-cache-scoping-and-test-staging branch from 2e16095 to 91de108 Compare August 9, 2026 04:46
@rtibbles
rtibbles marked this pull request as ready for review August 10, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: backend Python, databases, networking, filesystem... DEV: dev-ops Continuous integration & deployment SIZE: very large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant