CI cloud sizing and headroom¶
How the clouds are sized¶
A CI cluster is sized against a number which has very little to do
with the machines it runs on. What runs out first in CI is the
scheduler's admission ledger -- an allocation figure derived from a
node's thread count and two configuration values -- and not real CPU or
memory. The conductor's per-instance samples put a slim-primary
cluster VM at 0.71 cores of its 4 vCPU allocation, about 18%, and peak
memory on a 12 GB node at 4.9-7.6 GB, with zero swap-out on every node
of every job (PLAN-ci-cloud-sizing,
Allocation is roughly double actual usage). So a CI cloud can refuse
a create while four fifths idle, and the lever that relieves it is a
wider node rather than a quieter one.
The ledger is also the denominator of every band verdict in the rest of this page, which is why it is described first.
What admission actually tests¶
Scheduler._has_sufficient_cpu (shakenfist/scheduler.py) keeps a
node in the candidate set only while
Both of the terms on the left count vCPU, not load: measured is the
running-domain census the resources daemon republishes once a minute
(cpu_total_instance_vcpus), and committed is the used_cpus
counter the placement transaction maintains. The node is charged
whichever is larger, so an instance which is placed but not yet running
counts against the cloud for the whole time it spends fetching images.
That filter is a pre-filter rather than the decision: the binding
guard is the atomic UPDATE Instance.place_instance() makes against
scheduler_node_capacity -- introduced by
PLAN-scheduler-reservations --
whose limit_cpus is floor(cpu_schedulable x CPU_OVERCOMMIT_RATIO)
(_derive_cpu_memory_limits() in shakenfist/mariadb.py). The two therefore test the same
arithmetic on purpose, and for sizing they can be read as one bound.
The full stage list, and the four other things admission can refuse on,
are in the placement
pipeline.
Throughout this page, a cluster's ledger is that right-hand side summed over the cluster's hypervisors.
Where each term comes from¶
cpu_schedulable is published per node by the resources daemon as
max(1, cpu_threads - cpu_reservation_threads)
(_compute_reservations() in shakenfist/daemons/resources/main.py). The floor of one thread
is a clamp for over-large reservations, and in CI it is load-bearing
rather than theoretical -- see "Two things the topology files do not
show" below.
The reservation is NODE_CPU_RESERVATION_THREADS, a per-node value in
each host's /etc/sf/config rather than cluster config
(system reservations
covers all three reservations and both clamps). Nothing in the
scheduler bumps it for a node's roles; what does is the deploy, which
computes a per-host default of (1 + (network or database ? 1 : 0)) *
2 threads and fills it in only where the operator has not set the
value (the Default the per-host CPU thread reservation task in
examples/_shared/site.yml). In CI nobody sets it, so
the default is what runs: 2 threads on a plain hypervisor, 4 on a
network or database node.
CPU_OVERCOMMIT_RATIO is cluster-wide and defaults to 3.0. What it
means, why it is 3.0 rather than the historic 16, and how to restore
the old behaviour are in CPU
overcommit and the
configuration
reference;
they are not repeated here.
The two CI topologies, and the ledgers they yield¶
Both cluster topologies are defined in the shakenfist/actions
repository, as ansible/ci-topology-slim-tier.yml and
ansible/ci-topology-slim-primary.yml. There is no copy of them in
this repository, and every caller reaches them at @main with no pin,
so an edit there is live for every run in flight. They are the source
of truth for the two tables below; if the tables and the files
disagree, the files are right and the tables are stale.
Every node is in allsf, which is elided below.
slim-tier -- three VMs, all of them hypervisors:
| Node | vCPU | Other groups | Reserved | Schedulable | Ledger |
|---|---|---|---|---|---|
primary |
6 | database_node, primary_node, network_node |
4 | 2 | 6 |
sf1 |
6 | database_node |
4 | 2 | 6 |
sf2 |
6 | -- | 2 | 4 | 12 |
| Total | 24 |
slim-primary -- six VMs, five of them hypervisors:
| Node | vCPU | Groups | Reserved | Schedulable | Ledger |
|---|---|---|---|---|---|
primary |
4 | database_node, primary_node -- not hypervisors |
-- | -- | 0 |
sf1 |
4 | hypervisors, network_node |
4 | 1 (floored) | 3 |
sf2-sf5 |
4 | hypervisors |
2 | 2 | 6 each |
| Total | 27 |
24 is not a round number chosen for a test: it is exactly slim-tier's
total, and it is MINIMUM_HYPERVISOR_LEDGER in
shakenfist/deploy/shakenfist_ci/sizing.py, which "The topology
assertion fails rather than skipping" below asserts against every
deployed cluster. The floor therefore sits directly on the topology
with no slack, so a change which takes capacity out of slim-tier
fails by name, in a test whose message says the cluster is too small,
rather than as a scheduling flake somewhere else in the suite. The
deliberate cost is that a smaller-on-purpose topology cannot be
deployed without editing that constant in the same change.
27 is not derived on paper either. The sizing plan's baseline window
(PLAN-ci-cloud-sizing) read a
cluster ledger of exactly 27.0 in all 154 slim-primary job-runs,
which is what makes the arithmetic above a description of the system
rather than a model of it.
Two things the topology files do not show¶
slim-primary's primary contributes nothing. It carries
database_node,primary_node and not hypervisors, so it is not a
scheduling candidate at all, and summarize_resources() leaves it out
of /admin/resources's per_node mapping entirely. Six VMs, five of
them in the ledger, and a report which counts nodes reads one fewer
than the topology creates.
The reservation is a fixed per-node tax, so small nodes are
disproportionately expensive. Two threads off a 4 vCPU node is half
of it; off a 6 vCPU node, a third. Worse, a 4 vCPU node carrying a
network or database role reserves all four threads and is clamped to
one schedulable thread, which is how slim-primary's sf1 yields a
ledger of 3 where its identically-sized siblings yield 6. Widening the
nodes of a topology therefore buys more ledger than adding nodes of the
same size does, per vCPU spent on the under-cloud, and that is why the
sizing plan reshaped slim-tier by widening its nodes rather than by
adding a fourth. It is the first thing to check against any proposed
new topology.
Re-measuring this from scratch¶
Four steps, none of which depends on the others being believed.
Harvest a window. tools/ci_headroom_harvest.py enumerates
merge_group runs and writes one record per job per run; its flags,
its caching and the reasons it refuses to guess are under
Where the output lands below. Name both ends of the window with --since and
--until if the output is going to be committed -- --since alone
grows with every merge and --limit moves with the day the harvest is
run on, so neither reproduces its own dataset.
A window older than about three months cannot be harvested at all,
because GitHub deletes the artifacts it would read. Harvesting one
that is merely old can still fail, in one specific way worth knowing:
BUNDLE_TOPOLOGIES in the harvest tool is keyed by artifact bundle
name, and a merge matrix lane which has been renamed since the window
ran produces a name the table may not hold, which stops the harvest
with UnknownBundleError rather than guessing. The table keeps a
renamed lane's old entry alongside its new one until the last bundle
carrying the old name has expired, so the fix is to add the row back
rather than to narrow the window.
Check a committed window. tools/ci_headroom_check_window.py
recomputes the figures the band gate was armed against from
docs/plans/data/ci-cloud-sizing-baseline/records-warn-window.jsonl
-- the band tallies per job, the highest cluster-wide p90 fraction, how
many job-runs the gate would have failed, how many would have had it
withheld, and the smallest sample count any of them rests on -- and
exits non-zero naming any figure that no longer holds. Every figure is
recomputed from the sample counters and then compared against the
record's own stored verdict, rather than read out of it, so a stored
verdict which disagrees with its own inputs fails. A record which does
not carry the counters the gate question needs -- anything written
before record version 3 -- is reported as unanswered rather than as
clean. It also re-derives verdict.refusal_warning across the three
committed datasets, which were written by three different versions of
the record schema and disagree about what a False there means. The
test suite runs it, so the arming decision is checked rather than
merely written down.
Read the verdicts. Each record's summary.verdict carries the
band, the p90 CPU fraction and whether the gate was allowed to judge
it; the script under
Asking whether a cloud is oversized
counts those over a window by topology and job.
Recompute a topology's ledger by hand, from its file in
shakenfist/actions, applying the rules above: skip any node not in
hypervisors, reserve 2 threads (4 with network_node or
database_node), floor the remainder at 1, and multiply by 3.0. The
script below does exactly that and nothing else. Run it from a
shakenfist/actions checkout; it prints 24 and 27.
import re
import sys
RATIO = 3.0 # CPU_OVERCOMMIT_RATIO default
def ledger(path):
text = open(path).read()
cpus = [int(m) for m in re.findall(r'^\s+cpu:\s*(\d+)\s*$', text, re.M)]
groups = re.findall(r'^\s+groups:\s*(.+)$', text, re.M)
if len(cpus) != len(groups):
sys.exit('%s: %d cpu values but %d groups' % (path, len(cpus), len(groups)))
total = 0
for cpu, group in zip(cpus, groups):
g = [x.strip() for x in group.split(',')]
if 'hypervisors' not in g:
continue
network_or_database = 'network_node' in g or 'database_node' in g
reservation = 2 * (1 + (1 if network_or_database else 0))
total += int(max(1, cpu - reservation) * RATIO)
return total
for path in sys.argv[1:]:
print('%s: ledger %d' % (path, ledger(path)))
It pairs each cpu: with the groups: that follows because every
topology file creates an instance and adds it to ansible in that order,
and it exits loudly rather than mispairing silently if that stops being
true. slim-primary's sf-absent is not an instance at all -- it is an
inventory entry in absent_deploy_hypervisors, deliberately never
deployed -- so it correctly never appears in either count.
An open question: slim-primary reads OVERSIZED¶
The window the gate was armed against
(PLAN-ci-cloud-sizing) read
slim-primary below the band's lower bound in 25 of 30 job-runs,
against slim-tier's 5 of 10. The topology the sizing plan
deliberately left unreshaped is the emptier of the two, the lower bound
exists precisely to detect that, it has detected it, and nobody has
acted on it.
This is recorded rather than answered. Asking it properly means a
window and not a run --
Asking whether a cloud is oversized
is the command -- and acting on it means shrinking a topology, which is
a change to the cloud every functional job runs on. The slim-tier
reshape set the bar for that: a prediction of what the reshape would do
to the band and to the refusal census, and a falsification criterion
stated before the window was harvested, so the reshape could be shown
to have failed. A shrink argued from these readings alone would have
neither, and the
MINIMUM_HYPERVISOR_LEDGER floor means slim-tier cannot be shrunk at
all without that argument being made in the same change.
CI headroom instrumentation¶
Every functional cluster job carries two data-gathering instruments,
so that CI's clouds can be sized from a distribution instead of the
handful of hand-collected numbers
PLAN-ci-cloud-sizing started from;
docs/plans/PLAN-ci-cloud-sizing-phase-01-headroom-probe.md records
the decisions behind them. Neither instrument gates anything itself
-- see
One bound gates, and everything else is information
below -- but the poller's own traffic
does interact with a check that gates, which is the one reason a
reader troubleshooting a CI failure might need this section; see "The
probe's traffic is exempted from the idle-load check". Otherwise it
matters when reading a job's bundle afterwards, or when working on the
sizing plan itself. Whether the instruments are working at all is a
separate, weekly question; see
Is the instrument still alive?.
Two instruments, not one¶
The headroom series is a poll. tools/ci_headroom_probe.py (in
this repository) runs in the background on the cluster primary for the
whole functional test step, sampling GET /admin/resources and the
node roster (GET /nodes) every 15 seconds and appending one JSON
record per sample to a JSONL file.
The refusal census is a scrape. After the tests finish,
tools/ci_headroom_collect.sh (in shakenfist/actions) runs a
filtered Loki query for every scheduler admission event the run
produced. Scheduler._log_and_raise_on_error() logs one at every
stage, whether or not candidates survived, so a green run's refusals
are on the record too, not only a failing one's.
The two are not substitutes for each other. A fifteen-second poll cannot see a refusal, which begins and ends between samples; a census cannot see a cloud sitting half empty for an hour, which is the oversizing case the whole plan exists to catch. Reporting one number derived from both would hide which of the two produced it, so they are built, collected and reported separately.
Where the output lands¶
Both instruments write into /srv/ci/traces/ on the cluster primary:
headroom.jsonl (the series) and headroom-census.json (the raw
Loki query_range response the census reads). Neither gets its own
upload-artifact step -- the existing Gather logs step already
scp's the whole of /srv/ci/traces/ into the job's 90-day artifact
bundle, so a reader looking at a bundle finds both files sitting
beside the other logs, with no separate download to go and find.
tools/ci_headroom_report.py (also in this repository) turns the two
into a printed summary at the end of the job log: p90 and peak
committed vCPU and memory, cluster-wide and per node, both absolute
and as a fraction of the admission ledger, plus the refusal census
broken out by stage. It also has a machine-readable form: everything
it prints is computed once into a plain dict by summary_record(),
the prose is rendered from that dict rather than computed separately,
and --json PATH writes the dict out as one JSON object. Anything the
report can print, a program can read without parsing the tables --
which is what stops a change to the report's wording silently breaking
a consumer. Two conventions hold throughout the record: a figure that
was not measured is null and never 0, and a ledger is recorded as
the range it moved over rather than averaged.
tools/ci_headroom_harvest.py is the batch counterpart, run by hand
from a checkout rather than in CI. It enumerates the merge_group
runs of functional-tests.yml, downloads each run's cluster bundles
through the gh CLI (which supplies the credentials), unpacks the
trace files -- an artifact download is a zip whose single member is
bundle.zip, and the traces are inside that -- calls
summary_record() on each, and writes one compact JSON object per job
per run. It needs at least one of --since, --until and --limit, because
an unbounded harvest downloads the whole ninety day retention window;
bundles are cached by artifact id under ~/.cache/shakenfist-ci-headroom
by default, outside any checkout, and an already-cached bundle is never
re-fetched. Two things about it are deliberate and worth knowing before
changing it. A merge run builds six cluster bundles, five are
classified for harvest, and all five carry the probe. Ansible modules
was classified ahead of the shakenfist/actions change that instruments
it (#4377); that
change merged on 2026-10-01, and the bundle has recorded a real series
since. Harvests over the window before it read series_present: false
for that bundle, which is an expected absence rather than a probe
failure. The sixth, Node
lifecycle, is skipped by name with the reason recorded in the source,
so it never appears as missing data; and a
bundle whose artifact name the tool has no entry for raises
UnknownBundleError rather than being guessed at or skipped, so adding
a job to the merge matrix stops the harvest until someone says what the
new job is. A bundle with no series is written out with a reason
instead of being dropped, because a harvest that silently shrinks its
own window is the failure mode that looks most like success.
That last principle has been tested once. --since and --until are
applied as a created= filter on the API call and again on the
returned runs, and nothing in the tool infers anything from the order
the listing arrives in -- an earlier version stopped at the first
out-of-window run on the belief that the runs API returns newest
first, which it does not promise and on at least one occasion did not
do, so a harvest of a perfectly good window enumerated nothing and
exited zero. If you add another listing to this tool, filter it server
side and sort what comes back. Three rules fell out of that, and each
is pinned by a test: a boundary is normalised to UTC before it is
formatted, because strftime ignores tzinfo and a +10:00 stamped
with a Z moves the window ten hours in the direction this project's
own timezone lies; an enumeration which finds no runs raises before
--output is opened, so re-running a harvest whose window has gone
wrong cannot be the thing that empties the last good dataset; and any
harvest whose output is going to be committed names both ends of its
window, because --since alone grows with every merge and --limit
moves with the day it is run on.
Why the report is one three-thousand-line file¶
tools/ci_headroom_report.py and its unit tests are each about three
thousand lines, which is well past the length at which a reviewer is
entitled to ask why they have not been split. The answer is about
packaging rather than about the code, so it is worth knowing before
anyone plans the split. This section is the full account; the report's
module docstring and the test file's both point here.
The code is three layers with no leakage between them: parsing and
modelling the series and the census (percentile() to
read_census()), building the summary record from those models
(ledger_bounds() to write_record()), and rendering prose from the
record (plural() to print_report()). The renderer layer, a little
over a thousand lines, is a cut nothing outside the file depends on,
and the comment above the record builders states the invariant that
makes it one: the printers compute nothing of their own. The surface
other code reaches is summary_record(), DEFAULT_CENSUS_LIMIT and
RECORD_VERSION -- the first two used by ci_headroom_harvest.py,
the third by the harvest's tests -- and none of them is in the
renderer layer.
Two things hold the cut up. tools/ is not a package, so the report
is loaded by path with spec_from_file_location -- by the harvest, by
the harvest's tests and by the report's own tests -- and the report
imports nothing from shakenfist, so there is no import path into
tools/ to use instead. Moving the renderers out means making
tools/ importable, which changes how every tool in it is loaded
rather than only this one. And the file is read as text as well as
run: ci_headroom_verdict.sh in shakenfist/actions greps its source
for the string BAND_VIOLATION_EXIT before it will believe an exit
status of 3, and ci_headroom_collect.sh greps it for the
--census-limit and --waits flags before passing them. Those greps
are given one path, so whatever moves has to leave the matched text in
the file that path names, or the two repositories have to change
together across a workflow reference that carries no pin to bump.
The test file is the same length for the same reason and one of its
own: it loads the module by path once, at import, and all but one of
its classes extend a single base, HeadroomReportTestCase, which holds
the series, census and waits fixture builders and the assertion that
tells a swallowed exception from a clean run. (The exception is
PercentileTestCase, which calls percentile() directly and needs
none of them.) Splitting the file means duplicating that preamble per
file or adding a shared helper module beside the tests, and the seam
it would split on is the tool's render seam, so it follows a split of
the tool rather than leading one.
Neither file is to be shortened by removing the comments above the
record builders and beside BAND_VIOLATION_EXIT. Those are what make
a file this long reviewable at all.
A third file: the capacity-wait trace (--waits)¶
A third file lands beside the other two, written by a different
mechanism. The self.create_instance() wrapper (see Creating instances in the functional
suite) appends one JSON line to
/srv/ci/traces/instance-waits.jsonl every time a create waits out a
transient 507. It reaches the bundle through the same "Gather logs"
scp as headroom.jsonl and headroom-census.json, with no separate
plumbing needed to get it there.
tools/ci_headroom_report.py --waits <file> summarises it: total
seconds waited, the number of waits, the longest wait and the test it
came from, and the split between waits that were informed by a
capacity read and waits that had to sleep blind because
/admin/resources itself could not be read (mode: degraded). Each
line also carries the create's three request dimensions, the
binding_dimension it was short of, and attempt_number -- a
1-indexed position, not a count, so a create refused three times
writes three lines carrying 1, 2 and 3. node is the placement the
test asked for, and roster_key is the per_node entry the wait
actually watched: /admin/resources keys that mapping by node UUID
while the suite pins by node name, so the two differ and the wait has
to resolve one to the other before it can read anything.
Because the report already runs over a downloaded bundle rather than
only inside a live job, this summary is available for any run that
wrote the trace -- the evidence does not wait for
ci_headroom_collect.sh to grow its own --waits plumbing and print
it into the job log, which is a separate, later change.
Since #4337
landed as 62bb1ddeb, a trace file that exists and is empty is a
real zero for any run whose base contains that commit:
BaseTestCase.setUp() in shakenfist/deploy/shakenfist_ci/base.py
calls ensure_capacity_wait_trace(), which touches the file
into existence at suite start-up and swallows every failure exactly
as the append path does. A missing file -- absent in D43's
vocabulary, which the tool reports as unreadable -- and one whose
every line was malformed both remain unknown, and a missing file
still cannot be read as zero, because the same on-disk signature
covers both a component ref predating the wrapper and a run whose
every write failed. tools/ci_headroom_report.py still nulls an
empty trace's counts, and that is correct: the report runs over a
downloaded bundle and cannot see the run's base, so the five states
it emits in the record's state -- not requested when --waits
was not passed, unreadable, empty, read, and unparseable
when the file was read but every line in it was malformed -- are the
raw reading, and applying the ancestry condition is the caller's
job.
PLAN-transient-capacity-refusals-phase-05-queue-decision.md's
D43 -- "empty is a real zero; absent and unparseable stay
unknown" -- is the decision this unlocked.
A file that was read but whose every line was malformed is unknown
as well -- not a zero -- for the same reason an absent one is, and it
carries its own state of unparseable in the machine-readable
record rather than appearing as an available file with a count of
zero. The prose and the record have to say the same thing, because
the tooling that reads this consumes the record, not the prose.
The series record format¶
headroom.jsonl is parsed as a contract, so treat the shape below as
load-bearing rather than as prose to paraphrase; the tool's own
docstring is the source of truth if the two ever disagree. Each
line is one JSON object. A successful sample carries:
sampled_at-- float, unix epoch seconds, wall clock at sample start.resources-- the verbatim/admin/resourcespayload (client.get_cluster_resources()).nodes-- the node roster at the time of the sample, reduced touuid,fqdn,is_hypervisor,is_network_nodeandis_database_nodefor each entry.
A failed sample carries only sampled_at and error (the exception
text) -- resources and nodes are absent, not null. The probe never
raises: a failure just writes an error record and the polling loop
continues, so one bad sample never costs the run the rest of the
series.
Every record, successful or failed, also carries:
writes_failed-- int, how many records this run has already failed to write before this one. A write which fails writes nothing at all, so this is the only place the failure can be recorded: the first line to land after a gap says how many lines the gap swallowed. It is cumulative for the run rather than per-gap, so any one surviving line carries the whole run's loss, and a healthy run carries an explicit0on every line. Read it with.get(): it is absent from records written by a probe predating the counter, and absent is "not counted" rather than "nothing lost". The one case it cannot report is a disk which stays unwritable to the end of a run, which leaves no line to carry the count and arrives instead as a series that simply stops.
The roster is recorded on every sample, not once at the start of the
run, and that is deliberate. summarize_resources() silently omits
from per_node any node that is not a hypervisor, whose metrics are
older than 120 seconds, or whose queue is unreasonably long, and does
not say which of those applied. A node missing from a given sample's
per_node therefore has several possible explanations, and only a
roster captured in that same sample can tell "not a hypervisor" apart
from "metrics gone stale" or "the node did not exist yet" -- a roster
fetched once at the top of the run cannot, because cluster membership
itself can change mid-run.
Four capacity stages, and a naming trap¶
The scheduler's admission checks that can refuse a candidate node for
being full are sufficient_idle_cpu, sufficient_idle_memory,
sufficient_free_disk and sufficient_idle_disk. The last two are
easy to swap: sufficient_free_disk is disk space, but
sufficient_idle_disk is disk bandwidth -- a rate predicate on
disk-busy delta, not a capacity check at all, and not something that
more or bigger disks in the same shape would fix. The cloud-sizing
plan itself named the wrong one as "disk" until a survey of the
admission code caught the mistake, which is why it is worth calling
out here: read a sufficient_idle_disk row in a census as a disk I/O
problem, never as evidence the cloud needs more disk capacity.
One bound gates, and everything else is information¶
The band verdict ci_headroom_report.py prints is committed vCPU as a
fraction of the admission ledger, against bounds of 0.35 and 0.70, plus a
per-node bound of 0.85. Those bounds are not provisional: they were
defended against a baseline distribution of 204 job-runs, recorded in
PLAN-ci-cloud-sizing.
Exactly one of them can fail a job: the cluster-wide upper bound of
0.70. Above it, the report returns status 3 and
shakenfist/actions's tools/ci_headroom_verdict.sh fails the step with
an annotation titled Cluster headroom outside the CI sizing band. That
is not a test failure and the annotation says so: the tests are whatever
the log says they are, and the verdict is a statement about the cluster
they ran on.
Nothing else gates, and the asymmetry is deliberate rather than incidental:
- The lower bound (0.35) never gates. Being oversized is not urgent, no build is at risk, and the response is a topology change nobody makes from one run. It is also the common reading -- 30 of the 40 cluster job-runs in the warn window that preceded the gate were below it -- so returning a status for it would redden three quarters of cluster CI immediately. Ask the question over a window instead; the command is below.
- The per-node bound (0.85) never gates. The statistic saturates at its ceiling on plenty of passing runs, so it cannot tell a bad run from a good one at the top of its range. Read it as what a topology should achieve, not as an alarm about one run.
- The refusal count never gates. Reshaping a cluster to double its
ledger left the guard refusing at essentially the same rate, because
expected_demandaccumulates until whatever bound it is given fills up. The count is the earliest sign the demand estimator has drifted, and nothing more;PLAN-transient-capacity-refusalsis what reads it. - A broken report never gates. An absent census or a bug in the tool itself is printed and exits 0. An instrument that can fail the job it measures changes the failure surface it exists to measure.
- Nor does a verdict the report could not read. An OVERSUBSCRIBED
band is withheld, printed with the reason and exit 0, when the p90 rests
on fewer than 20 samples which produced a cluster CPU fraction (five
minutes at the probe's 15 second interval; the smallest real job-run in
the 204-run baseline had 41), when any sample reported
capacity_degraded, when any sample predates that flag (a bundle whose probe cannot say whether its read failed), or when any sample was ledger-unreadable after the warm-up prefix every run opens with. Each of those is the capacity read failing, not the cloud being full. The summary record carries the decision asverdict.gatesand the reasons asverdict.gate_withheld. Because a single such sample withholds a whole run, the gate can go quiet without anything failing; the window command below counts withheld job-runs so that "the gate never fired" and "the gate was never allowed to fire" can be told apart.
Two switches sit in front of the gate, both of which fail towards not
gating. ci_headroom_verdict.sh believes a status of 3 only when the
string BAND_VIOLATION_EXIT appears in the report's source, so a cluster
running an older checkout cannot have its exit code misread -- the same
feature detection ci_headroom_collect.sh already does before passing a
flag. And the gate has an off switch that needs no commit: setting the
CI_HEADROOM_GATE repository variable to false (Settings, Secrets
and variables, Actions, Variables) turns it off for every run which starts
afterwards. Both switches matter because functional-tests.yml reaches
that workflow at @main with no pin to bump: a change there is live for
every run in flight and cannot be rolled back from here. The reverse
also holds: every call site here now passes headroom_gate, and GitHub
rejects a call passing an input the callee does not declare, so
reverting actions#94 would fail every functional job on an
undeclared input rather than ungate it. The rollbacks are the variable,
or removing the input from these call sites before the actions-side
revert.
The gate is armed only on job shapes a warn window has measured, which
today means the merge matrix in functional-tests.yml -- the four jobs
the warn window read. That call site passes
headroom_gate: ${{ vars.CI_HEADROOM_GATE != 'false' }}, so the variable
being unset leaves the gate on. The other three call sites of
smoke-cluster.yml pass headroom_gate: false: the smoke tier job (pull
requests only, a single node, never harvested because the harvest reads
merge_group runs), the Ansible modules job (measured since
#4377, but never
fitted to the band, and the reusable workflow resolves the gate to false
for any test_kind but functional whatever the caller passes), and the
dispatch-only matrix in scheduled-tests.yml (whose
single machine entry has never been harvested). Every call site has to
pass one of those two values explicitly. The reusable workflow leaves
the gate off when nothing is passed (actions#102; before it,
the default gated), but that default lives in another repository at
@main, so a call site's policy is stated here where a change to it
shows up in review. shakenfist/tests/test_headroom_gate_workflow_seams.py enforces
both rules: it derives each call site's shape per matrix entry (topology,
tier, test_kind, stestr_config) and fails if an armed one is not in
its MEASURED_SHAPES. An armed call site must pass all four inputs
itself, since the defaults live in shakenfist/actions at @main and a
shape derived from one could drift without anything here changing. The
base image is deliberately outside the shape: the band measures the
cloud, and the window covered Debian 12 and Ubuntu 24.04 on the same
topology. Arming a new shape means adding it there, with a
window of its own behind it.
Asking whether a cloud is oversized¶
A single OVERSIZED verdict is noise. The question is a window, and
tools/ci_headroom_harvest.py already computes the fraction, so it needs
a command rather than new infrastructure:
python3 tools/ci_headroom_harvest.py \
--since 2026-09-21 --until 2026-09-24 \
-o /tmp/window.jsonl
python3 - <<'EOF'
import collections, json, re
rows = [json.loads(l) for l in open('/tmp/window.jsonl')]
by = collections.defaultdict(list)
skipped = 0
pregate = 0
withheld = collections.Counter()
for r in rows:
v = (r.get('summary') or {}).get('verdict') or {}
if v.get('p90_cpu_fraction') is not None:
by[(r['topology'], r['job'])].append(v)
else:
skipped += 1
# Absent on records written before the gate existed, which is not
# the same as a series the gate was allowed to judge.
if 'gate_withheld' not in v:
pregate += 1
continue
for reason in v['gate_withheld']:
withheld[re.sub(r'\d+', 'N', reason)] += 1
for key, verdicts in sorted(by.items()):
bands = collections.Counter(v['band'] for v in verdicts)
fractions = sorted(v['p90_cpu_fraction'] for v in verdicts)
print(key, len(verdicts), 'max %.3f' % fractions[-1], dict(bands))
print(skipped, 'job-runs had no fraction to judge and are not counted')
verdicts = [(r.get('summary') or {}).get('verdict') or {} for r in rows]
print(sum(1 for v in verdicts if v.get('gate_withheld')),
'job-runs had a series which could not have supported a violation:',
dict(withheld))
print(sum(1 for v in verdicts if v.get('gate_withheld')
and v.get('band') == 'OVERSUBSCRIBED'),
'of those read OVERSUBSCRIBED, so withholding changed the outcome')
print(pregate, 'job-runs predate the gate and say nothing about it')
EOF
Filter on the fraction rather than on series_present: a series which
exists but produced no usable CPU sample (an aborted job, a probe that
died early) has a p90_cpu_fraction of null, and sorting that among
floats raises.
Name both ends of the window. --since alone grows with every merge, and
--limit moves with the day the harvest is run on, so neither reproduces
the dataset it produced.
Is the instrument still alive?¶
Nothing in the probe path can fail a job, deliberately: an instrument which can fail the job it measures changes the failure surface it exists to measure. The price of that honesty is that a dead probe and a healthy cluster produce the same green job. A probe which never started, one which authenticated against nothing, a collect which could not reach the primary, and a verdict withheld because the series was too thin are each recorded faithfully -- in fields that, until there was a consumer for them, nothing read.
.github/workflows/ci-headroom-health.yml is that consumer, weekly.
It harvests the ten newest merge runs and hands the records to
tools/ci_headroom_health.py, which returns non-zero when they say
the instrument is not working and names the records which say so. By
hand:
python3 tools/ci_headroom_harvest.py --limit 10 -o /tmp/headroom.jsonl
python3 tools/ci_headroom_health.py /tmp/headroom.jsonl
| Check | Fails when |
|---|---|
| window age | the newest harvested run is more than 14 days old, so the listing served a stale window |
| records harvested | fewer than 20 records, so bundles stopped arriving at all |
| series present rate | under 90% of records carry a summarised series, or any record claims a series and has no summary |
| usable samples per record | a record holds fewer samples than the band gate's floor, or than 5 on a lane the gate is not armed on |
| failed samples | any sample failed, which no record in the committed datasets does |
| census stage events | a census was not read, or was read and matched no scheduler event, so the filter stopped looking |
| sample cadence | mean sample spacing is more than a second from the 15 second poll, or a series has samples and no window |
| gate withheld | the band gate was not allowed to judge a verdict on a lane it is armed on |
Records from jobs the merge queue cancelled are set aside before any check but the window's age runs. A superseded merge group cancels its jobs routinely, often before their probes bank anything, and counting those as gaps would push the series-present rate towards its floor on an ordinary busy week. A job whose tests failed is still judged: its probe ran for the whole job.
The band gate's sample floor and its withheld verdict apply only to
lanes the gate is armed on, which the tool reads from each record's
label against GATED_LABELS. That set is the armed shapes in
MEASURED_SHAPES (in shakenfist/tests/test_headroom_gate_workflow_seams.py),
and a test pins the two together, so arming a shape without teaching
this tool fails a unit test rather than quietly holding the new lane to
the wrong floor. The Ansible modules lane is why the distinction
exists: it carries the probe but is not armed, and its job probes for
about three and a half minutes, so its series holds 9 to 15 samples and
the gate always withholds on it. That is the gate declining to judge a
short job, not the instrument failing.
It fails loudly because it is scheduled, and that is the whole
design rather than an oversight to tidy up. It sits in no pull
request's path, so there is nothing for its failure to perturb -- which
is why the workflow must not grow a pull_request or merge_group
trigger, and why nothing which gates may depend on it. A check which
cannot fail is the problem it was written to fix.
Every threshold is a constant at the top of the tool, with the
measurement behind it, and every one has a command line flag, because
each is a number somebody will eventually need to change: a probe
interval change moves the cadence, a matrix change moves the record
count. One is not defined there at all. The sample floor is read from
the report tool's BAND_GATE_MIN_SAMPLES, because it is the band
gate's own floor and a second copy of that number would drift from it.
The withheld ceiling is the one threshold with no committed dataset
behind it, since those records predate the field: it is zero because
none of the 24 gated records in the ten newest merge runs on
2026-10-05 was withheld, which is one window rather than a
distribution, and the first few scheduled runs are its real
measurement. --max-gate-withheld is there for when they disagree.
The series-present floor is an absolute floor and not a comparison
against the committed datasets, which is the honest shape for the data
that exists: records.jsonl carries a series on 204 of 217 records,
but twelve of those thirteen gaps are runs which predate the probe
existing, so its rate measures the instrument's arrival rather than its
health, and the confirmation window beside it is too small to recompute
a distribution from. What they do establish is that a running probe
banked a series on every bundle it wrote.
Three things to know before reading its output:
- Clearing the sample floor is necessary but not sufficient for the gate, which counts the samples that produced a CPU fraction. A cluster's first few minutes produce usable samples whose capacity table is still empty, so a record can clear the floor and still have its verdict withheld. The withheld check is what reads the gate's own answer.
- Run over the committed datasets in
docs/plans/data/ci-cloud-sizing-baseline/, the tool fails twice: their newest run is weeks old, and their records predateverdict.gate_withheld(and, inrecords.jsonl, the label which says which lane a record came from), so no record in them can say whether the gate was allowed to judge. That is the intended reading. An absent field is not an empty list, and a harvest pointed at a window of stale runs reads exactly the same way. - The window-age check exists because that harvest happened. On 2026-10-05 the runs listing twice, minutes apart, served a window weeks out of date as the newest merge runs -- its newest run on 11 September the first time and 24 September the second -- and the harvest, which trusts the listing, read each as the current window. Every record in it was well formed. Only the age of the newest run says anything was wrong.
The topology assertion fails rather than skipping¶
The other gate here is a test rather than a verdict, and it asks a
different question: not how full the cluster got, but whether it was ever
the right shape. cluster_ci_tests/test_nodes.py's
test_cluster_topology_meets_the_structural_minimum fails the job when
the deployed cluster reports fewer than three nodes, three hypervisors,
two non-network hypervisors, or a summed hypervisor ledger below 24. It
fails rather than skipping on purpose: it exists because four other
tests in that directory skip on exactly these preconditions, and a skip
reports as a pass, so a topology edit that removed capacity would stop
proving scheduler affinity and network teardown while every job stayed
green. The single exception is a single-machine deployment -- one node
holding every role, which is what the localhost topology deploys and
what scheduled-tests.yml runs cluster-ci.conf against on purpose --
where it skips, because none of those minimums means anything on one
node.
Neither gate is the same thing as the instrumentation being invisible to the checks that were already gating, which is what this part of the page used to claim and what issue 3975 disproved -- see below.
The probe's traffic is exempted from the idle-load check¶
The probe polls, and test_no_unbudgeted_fixed_rate_database_polling
in the functional suite exists to notice polling. Each sample's GET
/nodes hydrates each node from the iterator, which is one GetNode
each, then runs Node.external_view() per node, which is one
GetNodeAttributes and one GetAllNodeDaemonStates each, and its
GET /admin/resources reads node metrics per node, which is one
GetNodeMetrics each. On an N node cluster at the default 15 second
interval that is N/15 per second for all four, from the api caller,
flat across windows and indifferent to what the suite is doing --
which is exactly the signature of the server-side polling loop the
check is built to catch. It clears the unbudgeted ceiling, max(0.25,
0.05N) per second, from four nodes upwards; it was found on the six
node merge queue cluster, where three of the pairs read 0.3997/s
against 0.30/s and failed the build (issue 3975). The fix written for
that issue exempted only the three RPCs its body named, so the
GetNode the iterator issues came back as issue 4028 -- which of the
probe's pairs clears the check's activity-spread bar varies run to
run, and on those runs GetNode cleared it alone.
The iterator also has a read of its own, above all of that per-node
hydration: Nodes has no _find override, so both endpoints take
baseobject.py's default, which opens with one GetObjectsByState to
fetch the uuid list the per-node reads then hydrate. That read is once
per call rather than once per node, so it does not scale with cluster
size and clears only the 0.25/s floor of the ceiling -- which only the
one-node smoke topology meets. It failed there as issue 4359, the
second omission of the same kind: the 4028 fix enumerated the reads
downstream of the iterator and missed the iterator's own.
Those five (operation, caller) pairs are therefore listed in
HARNESS_DRIVEN_PAIRS in
shakenfist/deploy/shakenfist_ci/load_budget.py, alongside the events
reads the suite's own await helpers make. The budget file is
deliberately not the home for them: no deployed cluster runs the
probe, so a budget entry would model load that exists nowhere outside
a CI job, and it would have to be a per-node term, raising every real
cluster's ceiling in proportion to its size.
Two consequences worth knowing:
- The exemption costs coverage. CI can no longer see a new
fixed-rate poll of node state made through sf-api, whatever its
rate, nor one which lists objects through an iterator with no
_findoverride --GetObjectsByStatereaches sf-api through every such listing, not only the node roster. The blind spot is CI's alone -- none of the five pairs is budgeted for theapicaller, so theShakenFistUnbudgetedDatabasePollingalert, which reads its exclusions from the budget file rather than from that set, still watches all five at the unbudgeted ceiling on every real cluster. The pairs also stay visible in a run'sharness_drivenlist rather than being dropped from the report. - Retiring the probe means trimming the exemption. It outlives
its justification otherwise, and no test can catch that on its own:
the launcher (
ci_headroom_launch.sh) and the workflow steps live inshakenfist/actions, so a decommission done there stops the probe without touching anything in this repository.test_the_suite_still_probes_cluster_headroomcovers what it can -- it fails if the probe is deleted here, stops sampling on a timer, stops reading one of the two endpoints, or loses the note in its docstring that records this obligation -- but a probe that is simply never launched again looks identical to a running one from here.
An absent census is not zero refusals. When the Loki query failed or
log shipping was unhealthy, the report says so explicitly rather than
printing "0 refusals", which would look exactly like a run that
refused nothing. Read a bundle's headroom-census.json the same way
by hand: missing or empty means "unknown", never "clean".
A short census is not zero refusals either. The query carries an
entry limit -- 5000, which is both Loki's default
max_entries_limit_per_query and what ci_headroom_collect.sh asks
for -- and Loki gives no signal when it cuts a response off at that
limit. A response holding exactly the limit is indistinguishable from
a complete one, and the scheduler emits an event per stage per
schedule, so a run creating a few hundred instances can reach it. The
report is told the limit via --census-limit and prints CENSUS MAY
BE TRUNCATED when the count reaches it; every figure below that line
is a lower bound.
One trap is worth stating because it has already been fallen into. Do
not add a |= "Added event" line filter to the census query. That is
the message eventlog.add_event_multi logs under, but pylogrus'
JsonFormatter merges the caller's fields over the record last and
one of those fields is message -- so the shipped JSON's message is
the event's message, and Added event never appears in the line at
all. Such a filter matches nothing, and the empty census that results
is reported honestly as "no schedule stage events at all", which reads
like an idle cluster rather than a broken query.
The idle-load check measures the shape while it measures the load¶
test_no_unbudgeted_fixed_rate_database_polling evaluates the budget's
model, and two of that model's three terms are facts about the cluster
rather than about the code: node count and standing instances. The
instance count is sampled at every window boundary, not once when the
measurement is over.
The instance count is the one that matters, because the suite is what
creates and destroys instances and it tidies up after itself. A count
taken when the measurement is over is a count of what nobody got round
to deleting, and on the run in
#4039 that was
zero -- on a cluster whose sidechannel daemon had visibly been
monitoring instances the whole time. Every per_instance_qps ceiling is
a multiple of that number, so the model predicted a cluster that was not
there and the build failed for load sitting exactly where the model says
it should. The shipped Prometheus rules had already met the same problem
and solved it the same way, by averaging the model over the window it
compares against rather than evaluating it at the instant of the alert.
Three things follow, and each is easy to get wrong:
- The count comes from
instances_active, scraped fromsf-resourcesonRESOURCES_METRICS_PORTon every hypervisor in thecreatedstate -- not from the API'spower_statefield, and not from every record inGET /nodes, a roster which keeps a node's record after the node has gone and would otherwise hold a node that never answers. The roster is re-read for every sample, so a node deleted part way through drops out rather than going silent._doc.methodin the budget says a consumer which counts standing instances any other way evaluates the model against a quantity it was never fitted against, andsf-ctl database-loadand the Prometheus rules both read that gauge. - The largest sample wins, not the mean or the last. Each pair is
judged at its lowest observed rate, so the matching choice for the
shape is the largest: a failure then means a pair ran high against
every reasonable reading of the cluster it ran on, rather than against
the least generous one. Every sample is in the run's
standing_instances_per_sampledetail. - A failed scrape unenforces the per-instance ceilings rather than
failing the build, because a sample at every boundary on every node
would otherwise turn one refused connection into a red pull request.
A gauge not yet published is retried like a refused connection, since
sf-resourcescreates its gauges on its first update. Theunbudgetedhalf of the check needs no shape and still runs, and the run'sshape_unread_nodesdetail names each node that did not answer, how often, and why its last attempt failed. A node which answered none of the samples does fail the build: that is a port which is never open, not a transient, and left alone it would turn the per-instance half of the check off on every run. The gauge going missing altogether is the quiet version of the same thing, so its name is pinned by a unit test.