Story: Canary CI: near-zero sccache hit rate makes every build a cold rebuild
Table of Contents
This page documents a story in Sprint 22. It captures the goal, current status, acceptance criteria, and the tasks that compose it.
Goal
Diagnose and fix why canary-linux.yml (linux-gcc-debug-ninja) gets
almost no benefit from its sccache cache, so canary stops taking
~3h/run for small PRs.
How this was first noticed (corrected)
Originally filed as "the CTest step doesn't respond to GitHub Actions
cancellation" — PR #1410's canary job appeared stuck in_progress
for 45+ minutes after two gh run cancel calls. That framing was
wrong: the job wasn't stuck. It eventually finished on its own with
conclusion: success after a genuine ~3h1m59s runtime — GitHub
Actions doesn't preempt a running job mid-step; a long single-step
job (Run CTest workflow) simply keeps running to completion
regardless of a cancellation request against a superseded run. There
is no bug in cancellation handling to fix.
The real question the incident surfaced: why does a PR touching ~20 files take 3 hours to build at all when sccache is configured and a full cache was restored? That's this story.
Evidence: sccache stats from PR #1410's canary run
From the completed run's sccache --show-stats step
(gh run view --log --job=84963313660):
Compile requests 2433 Compile requests executed 2433 Cache hits 33 Cache misses 2400 Cache hits rate 1.36 % Non-cacheable compilations 0 Cache size 2 GiB Max cache size 2 GiB
A full 2018 MB sccache archive was successfully restored at the
start of the job (~15s download over the Actions cache), and every
single compile was cache-eligible (Non-cacheable compilations: 0).
Yet 2400/2433 files were recompiled from scratch, despite the PR
diff touching only ~20 files. Ruled out:
- Precompiled headers: this GCC canary preset doesn't use PCH at
all (0 matches for
include-pch=/=cmake_pchin the full job log — that pattern only exists in a different, local clang preset). - Cache eviction from being at the size cap: plausible contributor (2 GiB for a ~5,300-translation-unit monorepo is tight), but doesn't by itself explain why unchanged files miss — sccache keys are content hashes, not identity, so an unrelated PR's build shouldn't evict entries this PR needs unless the working set genuinely exceeds 2 GiB.
- "PRs overwrite each other's cache" as the direct cause: initially
suspected (three PRs' canary jobs were running concurrently,
#1408/#1409/#1410, all reading/writing the same static key
sccache-linux-gcc-debug-ninja), but overwriting alone doesn't explain a low hit rate — sccache hits are keyed on preprocessed content, not on which run wrote the cache. Concurrent writes could matter only via GitHub Actions cache's own eviction/immutability semantics (see workflow notes below), not directly.
Working hypothesis: dependency/toolchain drift invalidates hashes broadly
sccache's cache key for a .cpp file is derived from its
preprocessed content, which pulls in every transitively-included
header. In a Boost-heavy codebase almost every translation unit
includes at least one vcpkg-provided header. If the installed vcpkg
package set differs even slightly between when the cache was last
populated and a given run, effectively every TU that includes the
affected headers gets a new hash simultaneously — which looks exactly
like "near-total miss despite touching only ~20 files."
The user's sensible prior: the vcpkg submodule (port recipes/build baseline) should not move mid-sprint — it's only supposed to bump once, at end-of-sprint. Checked and confirmed true:
$ git submodule status vcpkg 44819aa2a6c10e56065e2b0330e7d6c89d1d2574 vcpkg (2026.06.01-163-g44819aa2a6) $ git log -3 --format="%h %ai %s" -- vcpkg 0f96a1e30 2026-06-14 09:41:55 +0100 [vcpkg] Update to 2026.06.01-163-g44819aa2a6 fd91ce1b2 2026-06-12 13:03:56 +0100 [build] Update vcpkg submodule to latest master (b216ddff25) 9f36c3118 2026-06-07 14:54:20 +0100 [compass] Add timeline command: generate and show
The submodule pointer has been stable since 2026-06-14 — ~19 days before this PR's build, and unchanged for the whole of sprint 22 so far. So the submodule itself is not the cause.
But vcpkg.json (the manifest — dependency list + version-string,
separate from the submodule pointer) did change twice this week,
both times inside this very session's work:
$ git log -5 --format="%h %ai %s" -- vcpkg.json ec78e347f 2026-07-02 14:10:16 +0100 [build] Bump version to 0.0.22 dab2ec522 2026-07-02 03:24:26 +0100 Fix: add missing boost-circular-buffer dependency 9f3cfadd9 2026-06-12 13:21:49 +0100 [build] Bump version to 0.0.21
dab2ec522added a real new port (boost-circular-buffer) to the dependency list — a genuine vcpkg-install-cache-relevant change (vcpkg-cacheis keyed onhashFiles('vcpkg.json'), with arestore-keysprefix fallback so it should still partially reuse the prior cache, needing to build/cache just the one new port).ec78e347f(the routine sprint-22 version bump, done as part of this same session's work) only changed theversion-stringfield — no dependency change at all — but still changes the file's content hash, producing a new exact-matchvcpkg-cachekey on every version bump, purely from a metadata field.
Both commits landed on 2026-07-02, the day before PR #1410's canary
run (2026-07-03 08:46). This is concrete, provable evidence that
vcpkg.json — unlike the submodule — genuinely does change within a
sprint, and did so twice in the 24h before the slow build. What isn't
yet proven is the causal link to the 1.36% sccache hit rate
specifically (vcpkg-cache and sccache are two separate Actions
caches; a vcpkg-cache miss/rebuild for one new port doesn't
automatically explain ~2400 sccache misses across the whole
codebase). That link needs to be established with a live comparison,
not log archaeology after the fact — see Tasks.
Also noted, not yet actioned
vcpkg installitself took ~20 minutes (08:51:40=→=09:12:18) inside theRun CTest workflowstep, even with thevcpkg-cache(package binary cache) restoring ~1.9 GB successfully — worth understanding once the sccache question is settled, may be a related or separate cost.- The
sccache-linux-gcc-debug-ninjaandcmake-build-linux-gcc-debug-ninjacaches both use a single static key (no content hash, no branch scoping) with an explicit delete-then-save step (gh api --method DELETEimmediately beforeactions/cache/save) — this is a deliberate "always refresh" pattern, not neglect, but means whichever job'sDelete=+=Savepair runs last "wins" when multiple canary jobs finish close together; worth confirming this doesn't cause its own thrashing independent of the vcpkg.json question.
Status
| Field | Value |
|---|---|
| State | DONE |
| Parent sprint | Sprint 22 |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-07-03 |
Acceptance
- Root cause of the near-zero sccache hit rate is identified with
direct evidence (not inference) — e.g. a diff of sccache's actual
hash inputs (
SCCACHE_LOG=debug) between two back-to-back canary runs, showing exactly which input changed for a file that should have hit. - Either a fix lands (workflow/cache config change) and a subsequent canary run shows a meaningfully improved hit rate for an unrelated-file-only PR, or the story concludes with a documented reason why no fix is warranted right now (e.g. "confirmed vcpkg.json churn is the cause and it's expected to settle once sprint-22's remaining vcpkg-touching work lands").
- No changes made to the vcpkg submodule pointer as part of this story — that only moves at end-of-sprint per existing convention.
Tasks
| Task | State | Start | End | Description |
|---|---|---|---|---|
| Prove/disprove sccache cache-invalidation cause with live evidence | DONE | 2026-07-03 | 2026-07-03 | Landed quota cleanup, max-size bump, and SCCACHE_LOG=debug telemetry; the access-scoping/quota finding is real but doesn't explain this run's full magnitude — analysis of the live experiment split into a follow-up task. |
| Analyze sccache zero-source-diff experiment results | DONE | 2026-07-03 | 2026-07-03 | Decisive result obtained without needing the planned rerun: 0.00% hit rate, cache not found at all — evicted by cross-platform quota contention on GitHub's fixed 10 GB per-repo cache limit. Reverted the counterproductive max-size bump and removed the unhelpful debug instrumentation. |
| Make canary read-only for sccache/cmake-build caches | DONE | 2026-07-04 | Confirmed root cause via GitHub's cache API: canary-linux.yml's Delete+Save steps mean every PR branch writes its own full-size scoped cache copy under the same key text (GitHub Actions caches are scoped per-ref even with identical keys). Found two live sccache-linux-gcc-debug-ninja entries (~2.1GB each) belonging to two different open PRs, and zero entries scoped to main — canary only runs on pull_request, so main never populates a baseline. With several PRs open concurrently, redundant per-branch copies fill the shared 10GB quota and starve everyone. Removed canary's Delete/Save steps for both caches, leaving only the existing Restore steps; continuous-linux.yml already reads+writes and will build the shared baseline canary reads from. |
Decisions
Final finding (2026-07-03, same day, live experiment): PR #1417 (a trigger PR touching only a workflow comment — zero C++ diff from what had just been compiled minutes earlier) ran canary and got a 0.00% hit rate (0/2435), even worse than PR #1410's 1.36%. Crucially, the restore step didn't even find a cache to try:
Cache not found for input keys: sccache-linux-gcc-debug-ninja
Checking gh api repos/.../actions/caches at that moment explained
why: the main-scoped sccache-linux-gcc-debug-ninja entry had been
evicted entirely. The repo's cache list showed 12 other entries
competing for the same fixed 10 GB total repository quota:
sccache-windows-clang-debug-ninja (1083MB), sccache-macos-clang-debug-ninja
(1712MB), sccache-windows-clang-release-ninja (802MB),
sccache-macos-clang-release-ninja (602MB), plus their vcpkg-cache
and install-qt-action caches — all recently touched by
continuous-*.yml runs on other platforms, all sharing the same
10 GB pool as the Linux/gcc canary cache. GitHub's LRU eviction had
simply dropped the Linux-gcc entry to make room for the others.
This is the real mechanism, and it also explains PR #1410's partial
(1.36%, not 0%) result: whether the Linux-gcc-debug cache survives at
all, at any given moment, depends on which OS/compiler caches were
touched most recently across the entire repo, not on anything
specific to a given PR's diff. The earlier max-size 2000M→3000M
bump (from the first, premature pass at this story) was
counterproductive — it makes each cache entry larger, consuming more
of the shared 10 GB pool and accelerating eviction of the other
platforms' caches (and vice versa). Reverted it back to 2000M in both
canary-linux.yml and continuous-linux.yml. Also removed the
SCCACHE_LOG=debug instrumentation — it turned out to add no value
(pure per-invocation client boilerplate, no useful hash/lookup
detail at that log scope) and the decisive evidence came from the
plain actions/cache/restore log line and the GitHub cache API
directly, not from sccache's own logging.
What this means going forward: GitHub's 10 GB per-repo cache quota
is a hard, non-purchasable platform limit (same across all plan
tiers) — it cannot be raised. ORE Studio currently runs 6+ distinct
build variants (linux-gcc × {debug,release}, linux-clang ×
{debug,release}, windows-clang × {debug,release}, macos-clang ×
{debug,release}, roughly) all sharing that one pool alongside
vcpkg/Qt caches, and the combined working set is evidently larger
than 10 GB — hence constant thrashing. The durable fix is almost
certainly to move sccache off GitHub's built-in actions/cache onto
an external backend sccache natively supports (S3/GCS/Azure Blob, or
a self-hosted Redis/webdav endpoint) — bounded only by storage you
provision, not shared across unrelated platforms, and not subject to
GitHub's 7-day-unused eviction. That is a bigger infrastructure change
(needs a bucket/credentials, IAM setup, workflow changes across every
platform's workflow file) than fits in this story's scope — recorded
here as the follow-up recommendation rather than attempted now.
Correction (2026-07-03, same day): the entry below was written after
PR #1410's canary run and initially treated as settling the story
(task/story briefly marked DONE). Checked the numbers more carefully
after being challenged on them: the main-scoped cache that run
restored was only ~20 minutes stale (built by continuous-linux.yml
at 08:45, canary started 08:46), and the actual file diff between
that cache's basis commit and PR #1410's build was only 32 files
(git diff --name-only <continuous's commit> <PR's commit>). 32
changed files cannot plausibly explain 2400/2433 (98.6%) cache
misses even with generous header-cascade effects. So the
access-scoping/quota explanation below is a real, worth-fixing
architectural gap (it explains why the cache never improves across
days), but it is not the proximate cause of this specific run's
near-total miss rate. That cause is still unknown. Reopened; running
a live zero-source-diff experiment (see Plan) instead of relying on
inference.
Root cause: GitHub Actions cache access control, not vcpkg.json
churn directly. A PR job can only restore from its base branch's
(main) cache, never save to it — by design, to stop a PR (and
especially a fork PR) from poisoning the shared cache. Since
canary-linux.yml only runs on pull_request (no push-to-main
trigger), the main-scoped sccache-linux-gcc-debug-ninja cache is
only ever refreshed by continuous-linux.yml (which shares the
identical cache-key scheme via its linux/gcc/debug matrix leg).
Every PR's own incremental compiles are thrown away the moment its
PR-ref-scoped cache becomes unreachable (merge/close) — PRs never
compound on each other, only on whatever the last continuous run
produced. This was compounded by the repo sitting at 9.79/10 GB of
GitHub's per-repo cache quota, with dead refs/pull/1410/merge-scoped
entries (already-merged PR) crowding out room to grow.
vcpkg.json churn (boost-circular-buffer addition + version bump, both 2026-07-02) remains a plausible secondary contributor but was not proven as the primary cause — the access-scoping mechanism above is sufficient on its own to explain a near-zero hit rate regardless of vcpkg.json. Confirmed per the user's expectation: the vcpkg submodule pointer has not moved this sprint and was not touched by this story.
Scope of the fix: a quick, low-risk pass — freed ~2.7 GB by
deleting the orphaned PR-1410 cache entries, and raised sccache
max-size 2000M → 3000M for the shared key in both workflows that
write it. A full live SCCACHE_LOG=debug before/after hash-input
comparison was scoped but deferred — it needs two full ~3h canary
runs and isn't proportionate to "quick"; the mechanism above is
well-evidenced via the GitHub cache API directly, not inferred from
job-log archaeology.
Closing fix: canary-linux.yml's own Delete+Save steps were a direct
contributor to the quota contention diagnosed above — every PR branch
wrote its own full-size scoped copy under the same key text (GitHub
Actions caches are scoped per-ref even when the key string is
identical), so N concurrently open PRs meant N redundant ~2GB+ copies
competing for the same fixed 10GB quota. Confirmed live via the cache
API: two sccache-linux-gcc-debug-ninja entries existed
simultaneously, one per open PR, and zero existed scoped to main
(canary only triggers on pull_request, so main never got a chance to
seed a shared baseline). Made canary read-only (removed its Delete/
Save steps for both the sccache and cmake-build caches), leaving
continuous-linux.yml — which already reads and writes under the
identical key and runs on main via its cron schedule — as the sole
writer. GitHub Actions caches created on the default branch are
readable from any other branch, so canary's restore step will now pick
up a single shared, continuously-warmed baseline instead of every PR
cold-building and hoarding its own copy.
Out of scope
- Moving the vcpkg submodule pointer (only happens once, at end-of-sprint, per existing convention).
- A live
SCCACHE_LOG=debughash-input diff proving the exact invalidation trigger file-by-file — turned out unnecessary once the cache-quota mechanism was found directly via the GitHub API; see Decisions. - Migrating sccache to an external cache backend (S3/GCS/Azure/
self-hosted) to escape GitHub's 10 GB shared-quota contention —
this is the actual durable fix the evidence points to, but is a
real infra project (bucket/credentials/IAM, workflow changes across
every platform). Captured as a backlog item
(
doc/agile/product_backlog/inbox/sccache_external_cache_backend.org) rather than attempted in this story.