Skip to content

CI cloud sizing baseline dataset

The measured baseline for PLAN-ci-cloud-sizing.md, harvested in step 2d of PLAN-ci-cloud-sizing-phase-02-baseline.md (D16, D17, D22). Every figure in the master plan's Situation section that is described as measured is traceable to a record in records.jsonl.

There are two files here, with the same record schema and different jobs to do. records.jsonl is the baseline: the retrospective window, and the source of every distribution the plan reasons about. records-addendum.jsonl is step 2g's confirmation window, harvested after the two instrument fixes this phase made had merged, and it exists to close the two things the baseline could not see. It is a classification, not a second baseline, and no distribution should be recomputed from it -- it is an eighth the size and covers a day and a half.

The bundles this was computed from expire ninety days after their merge run, so this directory is the only durable copy. The raw series and census are deliberately not committed -- D22 -- only the per-job summary records the report tool derives from them.

The window

Repository shakenfist/shakenfist
Workflow .github/workflows/functional-tests.yml, merge_group events
Nominal window runs created at or after 2026-08-30
Effective window 2026-08-30T07:48:03Z to 2026-09-05T07:15:07Z
Harvested on 2026-09-05
Merge runs enumerated 66
Merge runs contributing a bundle 55
Merge runs contributing probe output 52
Records 217
Records carrying a usable committed-CPU series 204
Distinct head SHAs 55

The nominal and effective windows differ for a reason worth knowing before reading anything else. Eleven of the 66 enumerated runs banked no functional cluster bundle at all: three were cancelled, and eight had Check paths decide nothing relevant had changed, so every functional job was skipped. Of the 55 that did, the three earliest -- 33283945854 (00:42), 33287041288 (02:02) and 33289851537 (03:15), all on 2026-08-30 -- carry bundles with no traces/headroom.jsonl in them: the shakenfist/actions change that starts the probe had not landed yet when they ran. It landed between 03:15 and 07:48 that morning. Those twelve records, plus one job cancelled mid-run, are the thirteen records whose summary is null. They are kept rather than dropped, so that the count of what could not be measured is itself in the dataset.

The tool and the command

tools/ci_headroom_harvest.py in this repository (added in step 2c), which calls summary_record() from tools/ci_headroom_report.py (step 2b) on each bundle it extracts.

python3 tools/ci_headroom_harvest.py \
    --since 2026-08-30 --until 2026-09-05T07:15:07Z \
    --cache-dir /var/tmp/ci-headroom-harvest-cache \
    --output records.jsonl

That took 331 gh api calls and downloaded 217 bundles totalling 1.1 GB, which took a little over two hours of wall clock. The cache directory is keyed by artifact id and anything already in it is reused, so a re-run is cheap; any writable directory outside the repository will do, and the run above used a scratch directory rather than the path shown.

The harvest was run with --since alone; --until was added to the tool afterwards, and the command above carries it because a window with no end is not reproducible -- it grows with every merge run, so the command as originally run enumerates more today than it did on the day it was run. The boundary shown is the last run in the dataset, and the two together name exactly the window this file covers. Re-running it will still produce a shorter file eventually: these bundles expire ninety days after their run.

What was changed before committing

One normalisation, and only one. summary.series.path and summary.census.path were removed from every record. They held the temporary directory the harvest unpacked each bundle into (/tmp/ci-headroom-<random>/<artifact id>/...), which is different on every harvest of the same artifact, names nothing a reader of this file can open, and identifies nothing that artifact_id and artifact_name do not identify better. Leaving them in would have made the dataset gratuitously irreproducible: two harvests of the same window would differ on 408 fields and agree on every number. The harvest tool itself is unchanged and still emits them; only this committed copy is normalised.

records-addendum.jsonl was re-harvested once, after the report tool gained the four series counters described below, so that the two things step 2g concluded about the ledger-unreadable samples could be recomputed from the committed records instead of from bundles which expire. The re-harvest was verified record by record against the file it replaced: same 32 records, same 8 runs, and no field different except record_version and the four new counters.

Nothing else was pruned. D22 budgeted 2 MB and allowed absences.classifications[].nodes and the guard block to be dropped if that was exceeded; at just under one megabyte it was not, so both are intact and a re-analysis can use them.

Record schema

One compact JSON object per line. The framing fields come from the harvest; summary is verbatim the report tool's summary_record() output, minus the two path fields above.

Field Meaning
harvest_version Schema version of the framing fields (currently 1).
repo, run_id, run_attempt, run_url The merge run.
head_sha, run_created_at, run_conclusion Which tree, when, and how the whole run ended.
artifact_id, artifact_name The bundle this record was computed from.
job The readable matrix name: Debian 12 cluster, Ubuntu 24.04 cluster, Guests, Debian 12 tier.
github_job_name What the jobs API calls it, which is not the same string -- the reusable workflow contributes its own name.
job_conclusion That job's own conclusion, or null if the prefix match failed. Never the run's, as a fallback.
topology slim-primary or slim-tier, from D17's explicit artifact-name table.
topology_source, topology_table_says, label How the topology was established, and what the series' own label said.
series_present, census_present, absent_reason Whether the bundle carried each file, and why not.
summary The report record, or null when there was no series.

Inside summary:

Field Meaning
record_version Schema version of the report record. records.jsonl is 1; records-addendum.jsonl is 2, which adds the four series counters below. A consumer that reads both must expect the older shape.
series Sample counts (usable, failed, unparseable), the window start/end/duration, ledger_unreadable_samples, and -- from record version 2 -- ledger_unreadable_prefix_samples, ledger_unreadable_prefix_seconds, capacity_degraded_samples and capacity_degraded_absent_samples. The last four are what say whether an unreadable ledger was a warm-up or a fault.
ledger_provenance Node-samples with a real capacity row, with a fallback to cpu_hard_max, with a fallback inside an unreadable sample, and with no ledger at all.
cluster Committed vCPU and committed memory MB, cluster-wide: n, p90, peak, ledger_min, ledger_max, and the two as fractions of ledger.
per_node The same, keyed by node uuid, each carrying that node's own ledger.
per_node_max_cpu_fraction Per sample, the highest committed-over-ledger ratio any one node stood at; then p90 and peak over samples. This is D21's statistic.
absences What the node roster named that /admin/resources did not return, classified.
census Per-stage tally: events, aborts, drops, shortage drops and drop reasons, plus capacity_shortage_drops, unclassified_shortage_drops, disk_bandwidth_drops and missing_data_drops.
guard The capacity guard census. In this window its state is not_collected on every record -- see below.
verdict The D3 band verdict against the provisional 0.35/0.70 bounds, and the per-node maximum D21 adds.

What this dataset does not know

Nothing, now. Both entries that stood here are answered, one by step 2f from the shape of the baseline itself and one by step 2g's confirmation window. They are kept below because a reader of records.jsonl alone will meet both and should not have to rediscover them.

Resolved: capacity guard refusals

summary.guard.state reads not_collected on all 204 summarised records in records.jsonl, and that is a fact about the LogQL query, not about the cluster: the census filter in shakenfist/actions matched the scheduler's per-candidate stage events and neither instance placement denied nor placement admitted over namespace capacity claim. No count of guard refusals over the baseline window exists, and none may be inferred from the absence of one.

Step 2e widened the filter and step 2g re-measured. In records-addendum.jsonl the state is collected on all 32 records, and what it holds is in The confirmation window below.

Resolved: the ledger-unreadable samples

This section was written listing a second unknown, and step 2f resolved it from the data's shape rather than needing step 2a's flag. It is kept here because cluster.committed_cpu.n is smaller than series.samples_usable in every record and a reader will want to know why.

2,276 of 21,517 usable samples (10.6%) had cpu_committed_row_present false for every node at once, and are excluded from every committed-CPU figure. They are an unpopulated table during warm-up, not a failing read. In every one of the 204 job-runs those samples are a contiguous prefix of 9 to 14 samples, never mid-run, never scattered and never twice, and the prefix ends within seconds of the capacity reconciler's first pass. A failed gRPC read is an independent per-sample event; it would not land only on the head of 204 independent runs.

That warm-up window is itself the subject of the defect step 2f drafted -- scheduler_node_capacity has no rows for the first 135 to 210 seconds of a cluster's life, so admission is unguarded throughout it. See A node can record twice its own ledger, and sizing would hide it in the master plan, and #4087.

Step 2g confirmed it directly, and the confirmation is in the records rather than in bundles which expire. /admin/resources now publishes total.capacity_degraded, and the report counts it: summing series.capacity_degraded_samples over records-addendum.jsonl gives 0 across all 3,359 samples, and series.capacity_degraded_absent_samples is 0 as well, so the flag was present on every sample and false on every sample -- including in all 367 whose capacity rows read as absent. A failed capacity read would have set it.

The prefix pattern repeats exactly, and is counted the same way: series.ledger_unreadable_prefix_samples equals series.ledger_unreadable_samples in all 32 records, so every unreadable sample is in the run the series opens with and none follows it. The prefix is 9 to 14 samples and series.ledger_unreadable_prefix_seconds spans 120 to 195 seconds between its first and last sample, so 135 to 210 seconds of wall clock at the 15 second interval. That is the same figure #4087 was filed with, measured a second time from a different window with an instrument that can now tell a failing read from an empty table.

The baseline's own 204 job-runs were classified before those counters existed, from the raw series inside the bundles. records.jsonl is record version 1 and does not carry them, so the contiguity claim made about the baseline is not recomputable from this directory -- which is the reason the counters were added.

The confirmation window

records-addendum.jsonl, harvested by step 2g on 2026-09-08.

Why Close the baseline's two blind spots against runs that used the fixed instrument
Nominal window runs created between 2026-09-07T10:00:00Z and 2026-09-08T02:00:00Z
Effective window 2026-09-07T10:25:26Z to 2026-09-08T01:55:33Z
Merge runs enumerated 10
Merge runs contributing a bundle 8 (two had every functional job skipped by Check paths)
Records 32, all four instrumented jobs in every run
Records carrying a usable series 32
Distinct head SHAs 8

Both fixes were live throughout: step 2a's capacity_degraded merged to develop at 2026-09-06T06:17Z and step 2e's census filter merged to shakenfist/actions at 2026-09-06T00:09Z.

python3 tools/ci_headroom_harvest.py \
    --since 2026-09-07T10:00:00Z --until 2026-09-08T02:00:00Z \
    --cache-dir /var/tmp/ci-headroom-harvest-cache \
    --output records-addendum.jsonl

This window was first harvested as --since 2026-09-07 --limit 10, which is not a window at all: --limit takes the newest runs as of the day it runs, and two more merge runs landed within hours of the harvest, after which the same command enumerated a different ten. The boundaries above are the same eight runs, named. The file was re-harvested with them, and every record came back identical to the one it replaced apart from the four series counters record version 2 adds.

The guard census, which the baseline could not see

3,480 denials over 32 job-runs -- a median of 121 per slim-primary job-run and 134.5 per slim-tier one. The CPU pre-filter, over the same 32 runs, dropped 283 candidates in 3,862 evaluations and aborted 11 times. The composition of the denials is almost entirely one thing:

count
Denials whose sole exceeded dimension is demand 3,477
Denials whose sole exceeded dimension is cpus 3 (one at the node stage, two at cluster)
... of the demand denials, demand_measured_alone 1,789
... demand_estimate_tipped 1,688
malformed, unenforced, empty_dimensions, nothing_exceeded, demand_unsplit 0 each
Unrecognised stages, unrecognised dimensions none

A denial is not a failed create. Both placement walks (external_api/instance.py and operations/node_inst_netdesc_op.py) catch CapacityAdmissionDenied, try the next candidate, and -- when nothing admitted and every refusal was demand-only, which is what 3,477 of 3,480 of these are -- re-walk with the demand clause waived. So this is overwhelmingly a count of the walk absorbing a refusal, not of work the cluster refused.

There were also 24 claim exceedances, one per namespace across 24 distinct ci-claimaccount-* namespaces, on all three dimensions. Those are admitted placements reported over an advisory claim (CLAIM_ENFORCEMENT_HARD is false), never refusals, and they are the claims suite exercising exactly the path it was written for.

The widened filter did not cost the census its completeness: summary.census.truncated is false on all 32 records, which is the empirical form of the argument made when the filter was widened.

What the census still cannot see

Named because it is the same shape of mistake the baseline made once. The filter matches the two guard messages and the stage events; it does not match the two events that say what became of a denial -- no candidate admitted and some refused on demand alone, waiving demand guard, and schedule failed, every candidate refused by capacity guard. So the number of creates that actually failed on the guard cannot be derived from this file. It is not zero, and it is not 3,480.

Comparability with the baseline

Stated so that a reader does not mistake the addendum for a shift. Committed-CPU medians land where records.jsonl left them: cluster-wide p90 fraction 0.333 median on slim-primary (n=24, max 0.481) against 0.296-0.333 per job in the baseline, and 0.792 on slim-tier (n=8, max 0.833) against 0.750. Memory dropped nothing in 3,851 evaluations, so D5's narrowing holds. Ledger provenance is zero fallbacks and zero missing ledgers over another 13,274 ledgered node-samples, so D7 stays closed at zero. Of the 7 job-runs with a sufficient_idle_cpu abort, 7 failed, and 7 of the window's 8 job failures had one -- the same relationship the baseline found over 204 runs, at a sample size that could not have established it alone.

Reproducing an analysis over this file

The dataset is deliberately plain: each line is one JSON object and nothing needs to be joined. To recover, say, the per-topology distribution of the cluster-wide committed-CPU p90 fraction:

import json
records = [json.loads(line) for line in open('records.jsonl')]
usable = [r for r in records
          if r['summary'] and r['summary']['cluster']['committed_cpu']['n']]
tier = [r['summary']['cluster']['committed_cpu']['p90_fraction']
        for r in usable if r['topology'] == 'slim-tier']

Three figures in the master plan's Situation section are not recomputable from this directory, and are named as such where they are used. Two are not in the summary record at all: the measured-versus-committed vCPU comparison, and the classification of each sufficient_idle_cpu refusal by which of the two ledgers actually refused. They come from cpu_measured/cpu_committed in the raw series and from the measured_cpus/committed_cpus fields of the refusal payloads in the raw census.

The third is in the record now but was not when the baseline was harvested: the shape of the ledger-unreadable samples in records.jsonl's 204 job-runs -- a contiguous prefix, 9 to 14 samples, never mid-run. That classification was made by reading the raw series. records-addendum.jsonl carries the counters which state it, so the same claim about the confirmation window is recomputable from the file, and a future re-measure will be too.

📝 Report an issue with this page