Module 5: Fast Ci Caching And Parallelism

8. Mini-project: a fast CI for Reservo

Description

This is the module's capstone. In the previous seven lessons you were gathering pieces —why speed matters, caching dependencies, parallelizing with pytest-xdist, splitting the suite, isolating the tests, and the trade-off of how much to parallelize—. Now you use them all together, you, from start to finish, to produce something deliverable: Reservo's CI, sped up, with proof that it goes faster and that it's still correct. You're not going to learn a new concept; you're going to demonstrate that you know how to make a fast pipeline without breaking it.

The deliverable has four parts, and we build them together: (1) the complete workflow with actions/cache and pytest -n auto, written and explained decision by decision; (2) the local parity that measures the real speedup —you run on your machine, for real, the suite serially and in parallel, and paste the times to verify the speedup—; (3) the diagnosis and fix of the test that broke in parallel —from shared Calendar to a fixture, verified by running before and after—; and (4) a trade-off and scope note that justifies your speed and cost decisions. That last part is as important as the YAML: a fast CI you don't know why is fast, or how much it costs, is a black box.

Connection to the module: this lesson introduces nothing; it integrates. Each decision you make here —where the cache step goes, which key it uses, -n auto or a fixed number, how to isolate the broken test— comes from a previous lesson, and the idea is that you apply them without being reminded. It's also the bridge to the rest of the guide: the scope note points to module 6 (the coverage gates, which require a threshold that breaks the build) and module 7 (the flakies). Here you make the pipeline fast; those modules make it more demanding and more stable.

The practical exam, back at the wheel

As in module 2's mini-project, this is the practical driving exam, not the written one. Lessons 1 to 7 took each piece separately: cache here, parallelize there, isolate elsewhere. This mini-project puts you in the car and asks you to really drive, making yourself the decisions that were previously given to you. Do I turn on the cache? (Yes, it almost always pays off.) -n auto or -n 4? (Depends on the runner.) How do I fix the test that fails in parallel? (By isolating it, not turning off xdist.) No one tells you; you decide with what you learned. The goal isn't theoretical perfection but real competence: at the end, a Reservo CI that runs faster, that still gives passed on all the tests, and that —verified with local parity— does exactly what it says.

The project you're going to speed up

Let's recall what Reservo has now, because the workflow is built around its structure. It's a pure Python project (booking logic, money in int cents) with its code in a reservo/ package and its suite split like this:

reservo/                    ← the domain code
├── models.py               (Room, Member, Booking with price_cents)
├── pricing.py              (price_cents)
├── refunds.py              (refund_cents)
├── calendar.py             (Calendar)
└── schedule.py             (overlaps, is_available, book, cancel)
tests/
├── test_pricing.py         ← 4 fast pricing tests
├── test_refunds.py         ← 3 fast refund tests
├── test_availability.py    ← 4 fast availability tests
└── test_reports.py         ← 12 SLOW monthly-report tests (@pytest.mark.slow)
requirements.txt            ← the dependency list
pyproject.toml              ← pytest config (pythonpath, testpaths, slow marker)

Its requirements.txt now has two lines —pytest to test, and xdist to parallelize—:

# requirements.txt
pytest==9.1.1
pytest-xdist

And its pyproject.toml registers the slow marker we use to split the suite:

[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
markers = [
    "slow: marks tests that take a noticeable amount of time (deselect with '-m \"not slow\"')",
]

Your job is to speed up the CI that runs these 23 tests, without losing any.

Step 1: write the fast workflow

Create (or extend) .github/workflows/tests.yml. This is module 2's —checkout, setup-python, install, pytest— with the module's two levers turned on: the pip cache and parallelism. Read it whole; then we break it down:

# .github/workflows/tests.yml
name: tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v5

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.14"

      - name: Cache pip dependencies
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run the test suite in parallel
        run: pytest -n auto

Review the new decisions compared to module 2's workflow, all from this module:

  • The Cache pip dependencies step (lesson 3) goes before installing, so pip install leverages the restored box. Its key, ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}, ties the cache to the content of requirements.txt: it reuses as long as the list doesn't change (cache hit, fast) and reinstalls when it changes (cache miss, correct). The restore-keys is the safety net to leverage a partial box. We turned on the cache without hesitation because, as we saw in lesson 7, it almost always pays off and its cost is nearly nil.
  • The Run the test suite in parallel step (lesson 4) runs pytest -n auto instead of bare pytest: it distributes the 23 tests across the runner's cores. Since runners have several cores and we don't know how many in advance, -n auto squeezes them without you nailing down a number.

An honesty detail worth choosing deliberately: here we put a single job with -n auto, which is the simplest and enough for Reservo. If you wanted lesson 5's layered feedback, you'd split into two steps —pytest -m "not slow" for the fast verdict and pytest -m slow -n auto for the expensive part—. For a suite of 23 tests, one job with -n auto is enough; the two-speed split shines when the suite grows. Naming that decision —"I chose one job because the suite is small"— is part of the deliverable.

Remember the module's rule: this file is content you wrote and understand; we're not going to spin up a runner. What we are going to do —and it's the part that runs for real— is measure locally the speedup this workflow will produce.

Step 2: the local parity that measures the speedup (this runs for real)

The idea that holds up the module: what CI does to your suite is the same thing your machine does. Here we verify it by measuring the speedup with your own hands. Each command below was executed for real with Python 3.14.0 and pytest 9.1.1; the times are real.

First, the complete suite serially, as it ran before this module:

python -m pytest

What to expect (real output):

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0
collected 23 items

tests/test_availability.py ....                                          [ 17%]
tests/test_pricing.py ....                                               [ 34%]
tests/test_refunds.py ...                                                [ 47%]
tests/test_reports.py ............                                       [100%]

============================== 23 passed in 6.10s ==============================

23 passed in 6.10s. Now the same suite with the workflow's lever, -n auto:

python -m pytest -n auto

What to expect (real output):

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0
created: 12/12 workers
12 workers [23 items]

........................                                                 [100%]
============================== 23 passed in 1.15s ==============================

23 passed in 1.15s. There's the parity and the speedup at a glance: the same 23 tests, all green, from 6.10 s to 1.15 s —about five times faster— just by adding -n auto. The only difference between this run and the runner's would be the platform line (darwin on your Mac, linux on the runner) and the number of workers (12 here, whatever the runner has there). The speedup is the direct evidence that your workflow will speed things up: you just ran, with your hands, what CI will run on its own. And something crucial: the count didn't drop. 23 passed, not 18 passed with five deleted. You sped up without losing coverage, which was the goal.

Step 3: diagnose and fix the test that breaks in parallel

Turning on -n auto has a requirement, and this step tests it. Reservo has, in a corner, three tests that share a module-level Calendar —lesson 6's anti-pattern—. Serially they pass; in parallel they break. A CI that runs with -n auto would expose them, so they have to be fixed before trusting the fast pipeline.

Diagnosis: reproduce the failure. First confirm the problem exists. Serially:

python -m pytest isolation_demo/test_shared_calendar.py
============================== 3 passed in 0.01s ===============================

Green. Now in parallel, as CI would do with -n auto:

python -m pytest isolation_demo/test_shared_calendar.py -n 3

What to expect (real output, trimmed):

created: 3/3 workers
3 workers [3 items]

.FF                                                                      [100%]
...
=========================== short test summary info ============================
FAILED isolation_demo/test_shared_calendar.py::test_third_booking_is_recorded - AssertionError: assert 1 == 3
FAILED isolation_demo/test_shared_calendar.py::test_second_booking_is_recorded - AssertionError: assert 1 == 2
========================= 2 failed, 1 passed in 0.28s ==========================

There's the diagnosis: 2 failed under -n 3, with assert 1 == 2 and assert 1 == 3. The shared Calendar doesn't travel between workers (each is a process with its own memory), so the tests that depended on the state another left find only their own booking. It's not a flaky —it fails every time you parallelize—; it's lack of isolation.

Fix: give each test its own world. Replace the module-level shared_cal with a fixture that creates a fresh Calendar per test, and make each test build the state it needs:

# isolation_demo/test_shared_calendar.py (fixed)
from datetime import datetime, timedelta

import pytest

from reservo.calendar import Calendar
from reservo.models import Room, Member
from reservo.schedule import book

ROOM = Room(id="r1", name="Focus", capacity=4, hourly_cents=2500)
PRO = Member(id="m2", name="Ben", tier="pro")
DAY = datetime(2026, 8, 1, 9, 0)


@pytest.fixture
def cal():
    # A new, empty Calendar for EACH test — no shared state.
    return Calendar()


def test_one_booking_is_recorded(cal):
    book(cal, ROOM, PRO, DAY, DAY + timedelta(hours=1), hours=1)
    assert len(cal.all_bookings()) == 1


def test_two_bookings_are_recorded(cal):
    book(cal, ROOM, PRO, DAY, DAY + timedelta(hours=1), hours=1)
    book(cal, ROOM, PRO, DAY + timedelta(hours=1), DAY + timedelta(hours=2), hours=1)
    assert len(cal.all_bookings()) == 2


def test_three_bookings_are_recorded(cal):
    for i in range(3):
        book(cal, ROOM, PRO, DAY + timedelta(hours=i), DAY + timedelta(hours=i + 1), hours=1)
    assert len(cal.all_bookings()) == 3

Verify the fix: run in parallel again.

python -m pytest isolation_demo/test_isolated_calendar.py -n 3

What to expect (real output):

created: 3/3 workers
3 workers [3 items]

...                                                                      [100%]
============================== 3 passed in 0.24s ===============================

3 passed under -n 3. Fixed. Now it doesn't matter which worker each test falls in: each creates its own Calendar and verifies what it built itself. You removed the shared-state dependency, and with it the failure in parallel. Now CI can run -n auto with confidence, because the whole suite is isolated. Notice the discipline of the fix: you didn't turn off parallelism to hide the problem —that would have given up the speedup—; you isolated the test, which also makes it more robust for everything else.

Step 4: the trade-off and scope note

The last deliverable isn't code: it's a short note that justifies your speed and cost decisions, and says what the fast CI does and what's left for later. Knowing why your CI is fast —and how much it costs— is part of the craft. An example note:

Scope and trade-off of tests.yml (fast). CI runs Reservo's 23 tests on every push and pull request, with two speedups: pip cache (actions/cache with a key from the requirements.txt hash) so as not to reinstall dependencies that didn't change, and pytest -n auto to distribute the tests across the runner's cores. Measured locally, this drops the suite from 6.10 s to 1.15 s (~5×) without losing any test. Cost decision: I turned on the cache without hesitation (cheap, almost always pays off) and used -n auto because the runner is dedicated; on a per-core-paid runner, the measured curve suggests -n 4 would capture most of the speedup (3.3×) at a third of the cost. Requirement met: I isolated the shared-Calendar test (from global to fixture) so -n auto doesn't break it; the whole suite passes serially and in parallel. I chose a single job with -n auto because the suite is small; if it grows, I'd split it into a fast job (-m "not slow") and a slow one (-m slow -n auto). Left for following modules: requiring a coverage threshold that breaks the build (module 6) and handling flakies in CI (module 7).

Notice what that note does: it doesn't just say what it sped up, but why with those numbers (the measured 5×), how much it costs (the -n auto vs -n 4 decision based on the runner), what requirement had to be met (the isolation), and what's missing. That's engineering honesty. A CI presented as "fast" without saying at what cost, or that hides that a test was poorly isolated, deceives; one that says "it goes 5× faster, I turned on the cache because it's free, I used -n auto because the runner is dedicated, and I isolated the test that broke it" is reliable and makes the map clear.

Common mistakes

Turning on -n auto without isolating first, and blaming xdist for the red. What happens: someone adds -n auto to the workflow, CI turns red over the shared-Calendar test, and concludes "parallelism breaks my suite, I'll remove it". Why it happens: the failure appears when you parallelize, so it's tempting to blame the tool. How to spot it: if the red is deterministic (every time you run in parallel, with assert 1 == 2), it's isolation, not xdist. How to fix it: isolate the test (step 3) instead of turning off -n auto; parallelism broke nothing, it exposed a test that was already wrong.

Delivering the workflow without measuring the speedup locally. What happens: someone writes the YAML with cache and -n auto, pushes it, and trusts that "it surely speeds up" without having verified it. Why it happens: the YAML "looks fast". How to spot it: if you didn't run pytest and pytest -n auto in your terminal and compare the times, you don't know how much it speeds up —or if it does—. How to fix it: do step 2's local parity, with the two times pasted; for an already-fast suite, -n auto might not help, and only by measuring do you know.

Writing the scope note without the cost part. What happens: someone documents "CI uses cache and -n auto" but doesn't say why -n auto and not -n 4, or how much it costs. Why it happens: speed is visible and presumed good; cost is invisible until the bill arrives. How to spot it: if your note doesn't mention the resource trade-off (workers vs cost, the curve), it's missing half. How to fix it: include the explicit cost decision —"-n auto because the runner is dedicated; -n 4 if paid per core"—, which is exactly what lesson 7 taught you to reason about.

Exercises

Exercise 1 — Detect the three defects. A teammate gives you this tests.yml "that should be fast but isn't and sometimes fails". It has three problems from what you learned in the module. Find and fix them.

name: tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v5
        with:
          python-version: "3.14"
      - run: pip install -r requirements.txt
      - name: Cache pip
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: pip-cache
      - run: pytest -n auto
See solution

The three defects:

  1. The cache step goes after pip install. That way the restoration never helps: by the time the cache is restored, everything was already installed by downloading from the internet. The actions/cache step must go before the pip install.
  2. The cache key is fixed (key: pip-cache), without the requirements.txt hash. It never invalidates: it reuses the first box forever, even if the dependencies change. It must be ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}.
  3. -n auto without guaranteeing isolation ("sometimes fails"): if the suite has tests with shared state, -n auto breaks them. They must be isolated (fixture with fresh state per test) before trusting parallelism. (Also missing is pytest-xdist in requirements.txt, without which -n auto would give unrecognized arguments; it counts as part of this defect.)

Fixed:

name: tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v5
        with:
          python-version: "3.14"
      - name: Cache pip
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-
      - run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
      - run: pytest -n auto

Plus the isolation of any test with shared state, and pytest-xdist in requirements.txt. The order (cache before installing) and the key (with hash) are the two cache defects; parallelism without isolation is the third.

Exercise 2 — Justify the speedup parity. A teammate says: "I already know -n auto speeds things up, why do I measure serial vs parallel locally if I'm going to put it in CI anyway?" Answer them with what step 2's parity gives you that trusting blindly doesn't.

See solution

Measuring serial vs -n auto locally gives you concrete evidence of how much it speeds up —or whether it speeds up—, instead of a guess. Three reasons:

  • It confirms there's a real speedup. -n auto speeds things up when there's enough work to distribute. For an already-fast suite (0.2 s), starting workers costs more than it saves, and -n auto would make it slower. Only by measuring the two times (6.10 s vs 1.15 s in Reservo) do you know yours falls in the case that benefits.
  • It gives you the number for the scope note. "It drops from 6.10 s to 1.15 s (~5×)" is a datum you document and that justifies the decision; "it surely speeds up" can't be written in a serious engineering note.
  • It exposes isolation problems before CI. Running -n auto locally is also the poorly-isolated-test detector. If a test breaks in parallel, you discover it in your terminal in seconds, not in the red CI after pushing. The parity moves the discovery to the cheapest place.

In one sentence: parity turns "I think it speeds up" into "it speeds up 5×, measured, and the suite is still isolated". It's not double work; it's the difference between trusting and knowing.

Exercise 3 — Rewrite the note for a paid runner. Step 4's scope note uses -n auto because the runner is dedicated. Rewrite it assuming CI now runs on runners that are paid per core and the budget is tight. Use lesson 7's measured curve to justify the change.

See solution

A reasonable note for a per-core-paid runner:

Scope and trade-off of tests.yml (fast, tight budget). CI runs Reservo's 23 tests on every push and pull request, with pip cache (key from the requirements.txt hash) and parallelism. Cost decision: since the runners are paid per core, I don't use -n auto (which would open all the cores) but pytest -n 4. The curve measured in lesson 7 shows that 4 workers capture a 3.3× of the speedup (from 6.11 s to 1.83 s in the slow tests), while going up to 12 workers only reaches 5.2× (1.17 s) —three times the resources for 60% more speed—. At a third of the cost, -n 4 is the curve's elbow: almost all the speedup, much less spending. I keep the cache on because its cost is nearly nil and it almost always pays off. Requirement met: the whole suite is isolated (fixtures with fresh state), so -n 4 breaks nothing. Left for following modules: coverage gates (module 6) and flakies (module 7).

The central change: -n auto-n 4, justified with the curve —4 workers are the point where the return flattens, so paying for more cores would give little extra speed for a lot of cost—. The cache stays the same (it's still free). That's lesson 7 applied: on paid resources, you pick the curve's elbow, not the maximum.

Summary and next step

In this mini-project you integrated the whole module by producing a real deliverable: Reservo's CI, sped up. You wrote the workflow with the two levers —actions/cache with the hash key (before the pip install) and pytest -n auto—, justifying each decision with the lesson it comes from. You established the local speedup parity: you ran the suite serially (23 passed in 6.10s) and in parallel (23 passed in 1.15s), measuring with your hands the ~5× real and verifying the count didn't drop —you sped up without losing coverage—. You diagnosed and fixed the test that broke in parallel: you confirmed the failure under -n 3 (2 failed, assert 1 == 2), isolated it with a fixture, and verified it now passes in parallel (3 passed), without turning off parallelism. And you wrote the trade-off and scope note, which documents the measured speedup, the cost decision (-n auto vs -n 4 based on the runner), the isolation requirement met, and what's left for later.

With this you close module 5. Look at everything you can do now that you couldn't at the start: cache dependencies with actions/cache and explain why the key is the requirements.txt hash; parallelize with pytest-xdist and measure the real speedup; split the suite into fast and slow with markers; isolate a test so it runs in parallel without breaking; and decide how much to parallelize weighing speed against cost with the curve in hand. You made Reservo's CI five times faster —the pipeline that used to be ignored for being slow now gives a verdict in a blink— without deleting a single test.

What's next is making the pipeline not just fast, but demanding. So far, your CI turns red if a test fails. But what about the code no test touches? A change can pass all the tests and still leave a whole function untested, and the green pipeline doesn't warn you. Module 6 is dedicated to quality gates: a coverage threshold that breaks the build when the tested code drops below a minimum (--cov-fail-under=N), failing when coverage drops, and —with the same honesty as always— when a gate helps and when 100% becomes a fetish that gets in the way. You already know how to make CI fast; now you're going to make it demanding.

Resources