Skip to content

Commit 16d088f

Browse files
docs: RFC for Firecracker snapshots (instant bazel-diff starts) (#376)
Design doc for capturing a warm Bazel server in a Firecracker microVM snapshot so PR-time bazel-diff runs restore in ~sub-second instead of paying full server warmup + external-repo fetch on every cold start. Scope: CLI hooks (warmup, fingerprint) + a Go orchestration tool, full warm-server snapshots, self-hosted CI. Centers the correctness story (fingerprint cache key + fail-safe fall-back-to-cold) since an incorrect affected set is worse than none. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1753903 commit 16d088f

1 file changed

Lines changed: 223 additions & 0 deletions

File tree

docs/firecracker-snapshots.md

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# RFC: Firecracker snapshots for instant bazel-diff starts
2+
3+
**Status:** Draft
4+
**Audience:** bazel-diff maintainers / contributors
5+
**Scope decided:** CLI hooks in the Kotlin tool + a Go orchestration tool, capturing a
6+
*full warm Bazel server*, targeting *self-hosted CI* (we control the host kernel and CPU model).
7+
8+
---
9+
10+
## 1. Motivation
11+
12+
bazel-diff's own JVM CLI starts in well under a second. That is not where the time goes. The
13+
canonical workflow ([`bazel-diff-example.sh`](../bazel-diff-example.sh)) is:
14+
15+
1. `bazel run :bazel-diff` — build the tool
16+
2. `git checkout <prev>``generate-hashes` → runs `bazel query deps(//...:all-targets)`
17+
3. `git checkout <final>``generate-hashes` → another `bazel query`
18+
4. `get-impacted-targets` — cheap, pure JSON diff
19+
20+
The cost is the `bazel query` in steps 2/3, which forces:
21+
22+
- **Bazel server startup** (JVM warmup).
23+
- **External-repo / bzlmod resolution + repository-cache fetch.** `BazelQueryService` even shells
24+
out to `bazel mod dump_repo_mapping` and `bazel mod show_repo`
25+
([`BazelQueryService.kt`](../cli/src/main/kotlin/com/bazel_diff/bazel/BazelQueryService.kt)).
26+
- **Full Skyframe graph load + package analysis** for `deps(//...)`.
27+
28+
On a large monorepo this is minutes per cold start. A Firecracker microVM snapshot lets us capture
29+
that warm state once and restore it in ~sub-second, so the PR-time path re-analyzes only the changed
30+
packages.
31+
32+
**Goal:** instant starts of bazel-diff by restoring a microVM whose Bazel server already has the
33+
build graph loaded and external repos fetched.
34+
35+
---
36+
37+
## 2. Key architectural split
38+
39+
The Firecracker record/restore itself is a **host-level concern** — it talks to the Firecracker REST
40+
API over a unix socket (optionally via `jailer`). It is *not* something the Kotlin CLI does. The work
41+
therefore splits into two pieces:
42+
43+
| Piece | Where | Responsibility |
44+
| --- | --- | --- |
45+
| **CLI hooks** | Kotlin (`cli/`) | Make snapshots deterministic and *safe*: warm-then-signal, emit a cache key, bake base hashes. |
46+
| **Orchestration tool** | Go (`tools/firecracker/`) | Boot/warm/snapshot and restore/checkout/run the microVM via the Firecracker API. |
47+
48+
Consume needs **no new bazel-diff command** — it is the existing `generate-hashes` +
49+
`get-impacted-targets` run against base hashes baked into the snapshot.
50+
51+
---
52+
53+
## 3. Lifecycle
54+
55+
### Record (per base SHA — on merge to master, or nightly)
56+
57+
```
58+
host: build read-only rootfs ──► boot Firecracker microVM (TAP net for fetch)
59+
(bazel + JDK + git + bazel-diff binary + workspace @ baseSHA)
60+
VM: bazel-diff warmup ──► bazel query deps(//...) loads Skyframe + fetches externals
61+
──► writes /snap/base_hashes.json + /snap/fingerprint.json
62+
──► exits 0 = "safe to snapshot"
63+
host: pause VM ──► snapshot {mem_file, vmstate} ──► freeze rootfs as backing image
64+
store keyed by fingerprint + baseSHA
65+
```
66+
67+
### Consume (per PR / target SHA — the hot path)
68+
69+
```
70+
host: fingerprint(targetEnv) == snapshot.fingerprint? ── no ──► fall back to cold run
71+
│ yes
72+
restore microVM (COW overlay on disk, UFFD lazy memory load) ~sub-second
73+
VM: git checkout <target> ──► warm server does INCREMENTAL re-analysis of changed pkgs
74+
bazel-diff generate-hashes (fast — server already warm)
75+
bazel-diff get-impacted-targets -sh /snap/base_hashes.json -fh <new> -o <out>
76+
host: extract impacted targets ──► discard overlay
77+
```
78+
79+
---
80+
81+
## 4. New CLI surface
82+
83+
Both new subcommands slot into the existing picocli `subcommands` list in
84+
[`BazelDiff.kt`](../cli/src/main/kotlin/com/bazel_diff/cli/BazelDiff.kt) alongside
85+
`GenerateHashesCommand` and `GetImpactedTargetsCommand`.
86+
87+
### 4.1 `bazel-diff warmup`
88+
89+
The record-side entrypoint. Effectively `generate-hashes` for the base revision, plus:
90+
91+
- Writes base hashes to a known path (`--base-hashes`, default `/snap/base_hashes.json`).
92+
- Writes the fingerprint file (see §5).
93+
- Exits `0` **only** once the query has completed and the server is warm + quiesced. The host
94+
watches for this clean exit as the "safe to snapshot" signal.
95+
96+
Implementation reuses `GenerateHashesCommand`'s plumbing; warmup is essentially generate-hashes with
97+
metadata side-effects and a clear success contract.
98+
99+
### 4.2 `bazel-diff fingerprint`
100+
101+
Computes the snapshot **cache key** and writes it as JSON. Used both at record time (to tag the
102+
snapshot) and at consume time (to validate a candidate snapshot before trusting it). See §5.
103+
104+
### 4.3 Consume
105+
106+
No new command. The orchestrator runs the existing `generate-hashes` for the target revision, then
107+
`get-impacted-targets -sh /snap/base_hashes.json -fh <new>`.
108+
109+
---
110+
111+
## 5. Correctness — the cache key and the fail-safe
112+
113+
bazel-diff's core promise is *"an incorrect affected set is worse than none."* A restored snapshot
114+
must produce **the same answer as a cold run**. Two layers of defense:
115+
116+
### 5.1 bazel-diff already re-hashes file content itself
117+
118+
`SourceFileHasher` reads and hashes source file contents independently of the Bazel server. So
119+
*content* correctness does not depend on the warm server's incrementality — only the **graph
120+
structure / rule attributes** returned by `bazel query` do, and Bazel's incremental analysis is the
121+
trusted core there.
122+
123+
### 5.2 The fingerprint (cache key)
124+
125+
A snapshot is only safe to consume when the consuming environment matches the recording environment
126+
on everything that could change the graph. The fingerprint is a hash over:
127+
128+
- **Bazel version** (already detected in `BazelQueryService.determineBazelVersion`).
129+
- **`MODULE.bazel.lock`** (bzlmod resolution state).
130+
- **`.bazelrc`** (and any imported rc files).
131+
- **bazel-diff version** (`VersionProvider`).
132+
- **The relevant flag set**`--useCquery`, `cqueryCommandOptions`, `bazelCommandOptions`,
133+
`startupOptions`, `--includeTargetType`, `--targetType`, fine-grained external-repo config, etc.
134+
(anything that changes what `generate-hashes` queries or how it hashes).
135+
136+
**Fail-safe rule:** any fingerprint mismatch → do **not** use the snapshot; fall back to a cold run.
137+
A stale snapshot is never silently trusted.
138+
139+
### 5.3 CI canary
140+
141+
Recommended: a periodic CI job that runs the *snapshot-consumed* result against a *cold* result for
142+
the same revision pair and asserts set equality. This builds and maintains empirical trust and catches
143+
any Bazel incremental-analysis edge case (env vars, repository-rule re-trigger conditions, untracked
144+
files) before it reaches users.
145+
146+
---
147+
148+
## 6. Firecracker specifics (self-hosted)
149+
150+
Controlling the host makes several normally-hard issues tractable:
151+
152+
- **CPU model pinning.** Snapshots only restore on a matching microarchitecture. Pin the CI instance
153+
type or set a Firecracker CPU template. (This is exactly the constraint that the cloud-portability
154+
option would have made painful — out of scope here.)
155+
- **Disk.** Read-only backing rootfs + a per-restore copy-on-write overlay so each consumed VM is
156+
isolated and disposable.
157+
- **Memory.** Use diff snapshots + UFFD on-demand page loading; keep the mem file on local NVMe/tmpfs
158+
for fast restore.
159+
- **Clock + network.** Resync the guest clock on resume (a known snapshot gotcha) and re-attach the
160+
TAP device. To make consume fully offline, pre-bake full git history into the rootfs so
161+
`git checkout <target>` needs no network.
162+
- **Isolation.** Run under `jailer` in CI.
163+
164+
---
165+
166+
## 7. Snapshot store layout
167+
168+
Keyed by `fingerprint + baseSHA`:
169+
170+
```
171+
<store>/<fingerprint>/<baseSHA>/
172+
mem_file # guest memory image (diff snapshot)
173+
vmstate # Firecracker microVM state
174+
rootfs.backing # frozen read-only disk image
175+
base_hashes.json # produced by `bazel-diff warmup`
176+
metadata.json # fingerprint, baseSHA, bazel version, created-at, bazel-diff version
177+
```
178+
179+
Consume resolves a snapshot by: matching fingerprint, then choosing a `baseSHA` that is an ancestor
180+
of the target SHA (git merge-base), preferring the nearest ancestor to minimize incremental
181+
re-analysis.
182+
183+
---
184+
185+
## 8. Orchestration tool (Go, `tools/firecracker/`)
186+
187+
Go chosen for the official `firecracker-go-sdk`, clean API access, and a static binary for CI. UX
188+
mirrors `bazel-diff-example.sh` as the familiar entrypoint.
189+
190+
```
191+
bazel-diff-snap record --workspace <path> --base-sha <sha> --store <dir> [firecracker opts]
192+
bazel-diff-snap consume --workspace <path> --target-sha <sha> --store <dir> --out <impacted.txt>
193+
```
194+
195+
- `record`: build/prepare rootfs → boot VM → run `bazel-diff warmup` + `fingerprint` → pause →
196+
snapshot → freeze rootfs → write store entry.
197+
- `consume`: compute `fingerprint` for target env → resolve compatible snapshot (else cold fall-back)
198+
→ restore (COW overlay, UFFD) → `git checkout``generate-hashes``get-impacted-targets`
199+
extract → tear down.
200+
201+
---
202+
203+
## 9. Phasing
204+
205+
1. **CLI hooks**`fingerprint` + `warmup` subcommands, pure Kotlin, fully unit-testable, no VM
206+
required. Lands value independently (the fingerprint is useful for any snapshot/caching scheme).
207+
2. **Orchestration tool**`tools/firecracker/` in Go: `record` / `consume`.
208+
3. **Correctness canary + docs** — snapshot-vs-cold equality check in CI; README section.
209+
210+
---
211+
212+
## 10. Open questions
213+
214+
- **Quiescence detection.** How does `warmup` know the server is fully idle (no background Skyframe
215+
work) before exit? Likely "query returned + process exited 0" is sufficient since the query is
216+
synchronous, but worth validating.
217+
- **Snapshot freshness policy.** How far back can a base SHA be before incremental re-analysis stops
218+
being worth it vs. cold? Needs measurement; drives record cadence (every merge vs. nightly).
219+
- **rootfs build pipeline.** Reuse an existing base image + inject workspace, or build per-record?
220+
Affects record time and store size.
221+
- **Flag-set canonicalization.** Exact list of flags that must enter the fingerprint vs. those that
222+
are snapshot-neutral — needs an explicit, reviewed allow/deny list to avoid both false mismatches
223+
(wasted cold runs) and false matches (incorrect results).

0 commit comments

Comments
 (0)