|
| 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