Setting Runtime Budgets for Spatial Test Suites
A runtime budget is a decision about what a lane may cost, made before the lane grows rather than after somebody complains. This guide sits beneath performance benchmarking spatial suites and covers the practical mechanics: choosing the number from how people behave rather than from what the suite currently takes, allocating it across the stages a spatial gate actually has, deciding which checks run against a sample, and enforcing it through a trend alert instead of a per-test assertion.
The budget’s purpose is not efficiency. It is to keep the pre-merge lane inside the window in which an engineer waits for the result, because a gate that is read after merging has stopped being a gate.
Root cause: the budget is set by behaviour, not by engineering
There are three durations that matter, and none of them is a property of the code. Below roughly five minutes an engineer waits and acts on the result. Between five and fifteen they context-switch and come back. Past fifteen they start the next task, and whatever the gate says arrives as an interruption rather than as feedback.
That means the budget is chosen first and the suite fitted to it, not the other way round. A team that measures the current runtime and declares it the budget has described the status quo; a team that picks four minutes and then decides what fits has made an engineering decision with consequences.
Allocation reference
A four-minute pre-merge budget for a spatial gate divides roughly as follows. The numbers are a starting point; the shape is the durable part, because it puts the cheap high-yield checks on everything and represents the expensive ones by a sample.
| Stage | Share | Runs against | Deferred to nightly |
|---|---|---|---|
| Container start | ~30 s | — | Image rebuild |
| Dependency restore | ~20 s | Cached, keyed on a lockfile | Fresh resolution |
| Schema and CRS contract | ~15 s | Every feature | — |
| Geometry validity | ~60 s | Every feature | — |
| Topology rules | ~90 s | One coherent spatial subset | Full-coverage dissolve |
| Cross-format parity | ~45 s | Primary output format only | Every output format |
| Service-level checks | 0 | Nothing | Tiles, routing, joins at volume |
Two features of that table are worth stating explicitly. The cheapest checks run against everything, because their cost is linear and their yield is high. The expensive ones are represented by a coherent sample rather than omitted — coherent because a random sample of features destroys adjacency and makes topology checks meaningless.
Step-by-step implementation
Step 1 — Measure the current shape before changing anything
The allocation only makes sense against a measurement, and the measurement is one line per stage.
# conftest.py — record per-stage duration without asserting on it
import time, json, os
from collections import defaultdict
STAGE_TIME = defaultdict(float)
def record_stage(name: str):
class _Timer:
def __enter__(self): self.t = time.perf_counter(); return self
def __exit__(self, *exc): STAGE_TIME[name] += time.perf_counter() - self.t
return _Timer()
def pytest_sessionfinish(session, exitstatus):
Path(os.environ.get("STAGE_REPORT", "stages.json")).write_text(
json.dumps(dict(STAGE_TIME), indent=2, sort_keys=True))
Step 2 — Enforce the lane, not the test
A per-test timeout catches a hang; the lane budget catches accumulation. Both are useful and they are different mechanisms.
- name: Spatial gate
timeout-minutes: 6 # a hard stop, above the 4-minute budget
run: pytest -q -m "not slow" --timeout=60
The per-test timeout guards against one test hanging forever; the job timeout guards against the lane as a whole. Setting the job timeout somewhat above the budget leaves room for ordinary variance while still bounding the worst case.
Step 3 — Alert on the trend, not on the limit
The limit fires when the lane is already unusable. The trend fires while there is time to act.
def test_lane_duration_trend(recent_durations):
"""recent_durations: last 20 runs of the pre-merge lane, in seconds."""
baseline = sorted(recent_durations[:10])[5] # median of the older half
recent = sorted(recent_durations[10:])[5] # median of the newer half
growth = (recent - baseline) / baseline
assert growth <= 0.25, (
f"pre-merge lane median grew {growth:.0%} over the window "
f"({baseline:.0f}s → {recent:.0f}s) — budget is 240s"
)
Step 4 — Make the sample coherent, and say so
A topology check over a random subset is meaningless because adjacency is destroyed. Sample by tile or administrative area, and record what was sampled.
@pytest.fixture(scope="session")
def coverage_subset(full_coverage, request):
"""One coherent area, not a random sample — adjacency must survive."""
area = os.environ.get("SAMPLE_AREA", "district-07")
subset = full_coverage[full_coverage["district"] == area]
request.config.stash["sampled_area"] = area
request.config.stash["sampled_features"] = len(subset)
return subset
Verify the fix
Run the lane and confirm the stage breakdown adds up to what the budget assumed:
pytest -q -m "not slow" && jq 'to_entries | sort_by(-.value) | .[:5]' stages.json
The top five stages should be the ones the allocation table anticipated. A stage that dominates and is not in the table is the finding — usually a fixture that is rebuilt per test, which is the cost described under performance benchmarking spatial suites.
When the budget cannot be met
Sometimes the suite genuinely does not fit, and there are only four honest responses. Choosing among them explicitly is much better than the default, which is to let the lane grow until somebody stops using it.
Move a check to the scheduled tier. The cheapest response and the one with a real cost: the check no longer gates, so a regression it would have caught reaches the default branch and is found the next morning. Acceptable for expensive checks whose failures are rare.
Reduce the sample. Halve the coherent subset, and record that the coverage is now smaller. This preserves the check’s presence in the gate while reducing its sensitivity, which is a genuine trade and should be recorded as one.
Make the check faster. Usually a fixture scope or an index, and usually the right answer when the measurement has not been taken yet. It is also the response most often assumed to be impossible without measuring.
Raise the budget. Legitimate when the previous number was arbitrary and the behavioural band has room — moving from three minutes to four costs little. Illegitimate as a repeated response, because each increment is small and the accumulation is what puts the lane past fifteen minutes.
The one response that is not on the list is to leave the lane growing and say nothing. That is what happens by default, and it converts a gate into a report over the course of a year without anybody deciding to.
Reviewing the budget on a cadence
A budget set once and never revisited becomes either an obstacle or a fiction. A short quarterly review keeps it honest and takes about twenty minutes.
Three questions cover it. Is the lane still inside the band? The measurement answers this directly, and if the lane has drifted past the chosen band the review is the moment to choose one of the four responses rather than to notice a year later. Has the allocation shifted? A stage that was ten per cent of the budget and is now forty has changed without anyone deciding, which is worth understanding whether or not the total moved. Is the sample still coherent and still representative? A district chosen two years ago may no longer contain the feature types the pipeline now handles, in which case the topology check is running against something unrepresentative and reporting confidence it has not earned.
The review is also the natural place to record deliberate increases. A budget raised from three minutes to four because the suite now checks something it previously did not is a good trade, and writing that down stops a future reader from treating the step as an unexplained regression to be recovered.
Failure modes and edge cases
- Per-test time assertions. They fail on a busy runner and remove the correctness signal along with the timing one. Use a per-test timeout as a hang guard and a lane budget for accumulation.
- A budget on the wrong lane. Constraining the nightly tier limits something nobody waits for while the pre-merge lane grows unwatched.
- A random sample for a topology check. Adjacency is destroyed, the check passes vacuously, and the gate reports coverage it does not have.
- A rotating sample. If the sampled area changes between runs, the durations and the results are both incomparable. Fix it, and change it deliberately.
- Ignoring the fixed costs. Container start and dependency restore are often half the budget and are the easiest to reduce, yet optimisation effort usually goes to the checks.
- A job timeout equal to the budget. Ordinary variance then fails the job. Set the hard stop above the budget and alert on the trend below it.
Conclusion
A runtime budget is a decision about engineer attention expressed in seconds. Choosing the number from the behavioural bands, allocating it so cheap checks run against everything and expensive ones against a coherent sample, guarding hangs with a per-test timeout and accumulation with a trend alert keeps a spatial gate inside the window where it functions — which is the operational half of performance benchmarking spatial suites.
Related
- Performance Benchmarking Spatial Suites — the parent layer and the measurements this budget is set against.
- Benchmarking Spatial Joins with pytest-benchmark — measuring the operation that most often breaks a budget.
- CI/CD Spatial Quality Gates — the lane structure this allocation fills in.
- Caching GDAL/PROJ Wheels in GitHub Actions — reducing the fixed costs that dominate a small budget.