OXYGENHUB

Jetstream: the fleet as a service of its own (design) 2814f4213e7c

The runner fleet leaves OxygenHub as Jetstream (crate and CLI jet): the
queue, runners and their measured facts, placement that explains itself,
pools on hosts, Kubernetes and GCP/AWS/Azure, and images served by hash.
The forge keeps pipelines, runs, checks, receipts and reports, embeds
Jetstream by default and may point at a jet server. Sources are
pluggable: Oxygen states are mounted, Git commits checked out. flow run
becomes an in-process Jetstream with one local runner.

refs #38
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 10:23 UTC
Signature
✔ signed by Andy McCall
Hash
2814f4213e7cd997f4319b8e4eb56dfe78479480319b480742ea5998b9a2b8d2
Depends on
e0918b887850
Depended on by
32f54b393396 3e3cc9475ab3 8aa45fa6ece5 85275e1ef3b7 c90dd8d5fc36 ecce3719da80 b14aeaae683a acf7f363f19e
In bookmarks
main newer-pipeline push-uses-login
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
1 atom, 1 tree operation, 1 file

Browse the tree at this change

added docs/jetstream.md +409 −0
1 +
# Jetstream
2 +
3 +
*Design and architecture decision, 2026-10-11 (oxygenflow#38). Builds on
4 +
spec §11, runner capabilities and placement (#36). Crate and CLI: `jet`.*
5 +
6 +
**Flow** says what to run: a pipeline of hermetic jobs, each in a sandbox,
7 +
with its outputs, reports and signed receipt. **Jet** carries each job to
8 +
where it should run: a fleet of runners on your own machines, on
9 +
Kubernetes or in the cloud, that measure themselves, explain every choice
10 +
they make, and fetch what they need on their own.
11 +
12 +
## 1. The decision
13 +
14 +
**Context.** Today OxygenHub holds the job queue and talks to runners
15 +
directly (`/api/v1/flow/claim`, logs, outputs, finish). Runners are
16 +
configured by hand: images are copied onto each machine, labels steer
17 +
jobs, and a job that cannot run anywhere simply waits. Every
18 +
self-hosted CI system has the same shape and the same problems (§2), and
19 +
none of it is specific to Oxygen.
20 +
21 +
**Decision.** The fleet becomes a service of its own, **Jetstream**:
22 +
23 +
- Jetstream owns the queue, the runners and their measured facts,
24 +
  placement and its explanations, pools (hosts, Kubernetes, cloud
25 +
  providers) and images. It knows nothing about repositories.
26 +
- The **forge** owns pipelines, runs, checks, receipts, artifacts,
27 +
  reports and the UI. It hands Jetstream jobs and receives their events.
28 +
- **OxygenHub embeds Jetstream** in its own process by default, so nothing
29 +
  changes for anyone who never configures it, and can instead point at a
30 +
  standalone `jet server` shared by several forges.
31 +
- **Sources are pluggable**: an Oxygen repository is mounted lazily
32 +
  through flow-casfs; a Git repository is checked out.
33 +
- **`flow run`** runs a pipeline on your own machine through an
34 +
  in-process Jetstream with one local runner: the same code path as CI.
35 +
36 +
**Consequences.**
37 +
38 +
- Jetstream can be reused by others, and could later run jobs for forges
39 +
  that are not OxygenHub (§12) without a rewrite.
40 +
- One more boundary to keep stable: the forge contract (§4) is versioned.
41 +
- Oxygen's advantage becomes visible: the same fleet runs both kinds of
42 +
  source, and an Oxygen job starts without a checkout (§5).
43 +
44 +
```mermaid
45 +
flowchart LR
46 +
    subgraph Forges
47 +
        OH["OxygenHub<br/>pipelines · runs · checks<br/>receipts · reports"]
48 +
        OTHER["another forge<br/>(an adapter, later)"]
49 +
    end
50 +
    subgraph JS["Jetstream (jet)"]
51 +
        Q["queue"]
52 +
        P["placement<br/>+ why"]
53 +
        F["runner facts"]
54 +
        I["images"]
55 +
        D["demand"]
56 +
    end
57 +
    subgraph Pools
58 +
        H["hosts<br/>earth-1 · plex-1 · narvi-1"]
59 +
        K["Kubernetes<br/>controller, pod per job"]
60 +
        C["cloud<br/>GCP · AWS · Azure"]
61 +
    end
62 +
    OH -- "jobs" --> Q
63 +
    OTHER -. "jobs" .-> Q
64 +
    Q -- "events: logs, outputs,<br/>finish, receipt" --> OH
65 +
    Q --> P
66 +
    F --> P
67 +
    H -- "claim + facts" --> P
68 +
    K -- "claim + facts" --> P
69 +
    C -- "claim + facts" --> P
70 +
    I -- "by hash" --> H
71 +
    I -- "by hash" --> K
72 +
    I -- "by hash" --> C
73 +
    D -- "how many, of what" --> K
74 +
    D -- "how many, of what" --> C
75 +
```
76 +
77 +
## 2. What it fixes
78 +
79 +
Research into self-hosted runners (GitHub Actions, GitLab, Buildkite,
80 +
Jenkins, Forgejo; 2023–2026) found the same complaints everywhere. Most of
81 +
them are fleet problems, not pipeline problems.
82 +
83 +
| What goes wrong elsewhere | Jetstream |
84 +
|---|---|
85 +
| Persistent runners leak state; fork PRs and compromised actions reach other jobs' secrets | A fresh microVM per job (or the syscall tier); nothing survives a job |
86 +
| A job waits for hours, then fails, with no reason given | Every waiting job says why, in terms of facts (§7) |
87 +
| Disk fills mid-job; an OOM kill shows up as a "lost runner" | Disk and memory checked on every claim against what the job needed last time; OOM reported as OOM |
88 +
| Images copied to each machine by hand; slow pulls | Images served by hash, fetched by the runner on demand, chunked so a rebuild moves only what changed (§8) |
89 +
| Exact-match labels that sprawl | Facts measured by the runner; labels only steer (§7) |
90 +
| Lost runners and spot reclaims leave jobs hanging | Requeue on a lost runner or a reclaim notice (§11) |
91 +
| Forced runner upgrades with a cut-off date | Runners update themselves between jobs; the contract is versioned |
92 +
| Ephemeral runners start cold, 30–60 s and no caches | Firecracker boots in about a second; images and caches stay warm by hash |
93 +
| Multi-arch builds through QEMU, 5–30× slower; no RISC-V runner at all | Native runners per architecture, RISC-V included (narvi) |
94 +
| No queue, utilisation or cost figures | Queue time, utilisation and cost by job, pool and region, built in (§11) |
95 +
96 +
## 3. Responsibilities
97 +
98 +
| | Jetstream | The forge (OxygenHub) |
99 +
|---|---|---|
100 +
| Pipeline from the repository | | ✔ |
101 +
| Runs, jobs, `needs`, the run page | | ✔ |
102 +
| Queue, placement, why a job waits | ✔ | shows it |
103 +
| Runners, their facts and tokens | ✔ | shows them |
104 +
| Pools: hosts, Kubernetes, cloud | ✔ | |
105 +
| Images | ✔ (by hash) | |
106 +
| Secrets and variables | carries them to the job | ✔ (stores them) |
107 +
| Job identity (OIDC) | carries the token | ✔ (issues it) |
108 +
| Logs | streams them | ✔ (keeps them) |
109 +
| Outputs, reports, receipts | carries them | ✔ (keeps, checks, shows) |
110 +
| Checks on a change | | ✔ |
111 +
112 +
## 4. The contracts
113 +
114 +
Two contracts, both HTTPS and JSON, versioned from the start.
115 +
116 +
**Forge ↔ Jetstream.** The forge submits a job and follows it.
117 +
118 +
- `submit`: the job's spec (sandbox, image hash per architecture, exec,
119 +
  outputs, time limit), its **source** (§5), its secrets and OIDC token,
120 +
  the forge's run and job ids, and optional steering (`runs-on`,
121 +
  `prefer`).
122 +
- `events`: a stream per forge of `placed` (where, and why), `started`,
123 +
  `log`, `output` (a file or blob), `finished` (exit code, peak disk and
124 +
  memory, duration), `receipt` (signed by the runner), and `waiting`
125 +
  (why, when nothing fits).
126 +
- `cancel`, and `queue` (what is waiting, for the forge's pages).
127 +
128 +
**Runner ↔ Jetstream.** What runners already do with the hub today, moved:
129 +
`claim` (with its facts, §6), `log`, `output`, `finish`, `decline` (with a
130 +
reason). A runner talks only to Jetstream, never to a forge: moving from
131 +
embedded to external Jetstream is a URL change for it.
132 +
133 +
```mermaid
134 +
sequenceDiagram
135 +
    participant Forge as OxygenHub
136 +
    participant Jet as Jetstream
137 +
    participant R as earth-1
138 +
    Forge->>Jet: submit(job, source, secrets, token)
139 +
    Note over Jet: queued: needs x86_64, microvm,<br/>~6 GiB disk (from its last run)
140 +
    R->>Jet: claim(facts: idle, 41 GiB free, holds image)
141 +
    Jet->>Forge: placed on earth-1 (holds the image, idle, score 100)
142 +
    Jet->>R: job
143 +
    R->>Jet: log, log, output
144 +
    Jet->>Forge: log, log, output
145 +
    R->>Jet: finish(exit 0, peak 5.2 GiB disk, 3.1 GiB memory)
146 +
    Jet->>Forge: finished + receipt
147 +
```
148 +
149 +
## 5. Sources: mounted, or checked out
150 +
151 +
A job's source says how a runner gets its files.
152 +
153 +
- **Oxygen** (`oxy://HUB/OWNER/NAME@STATE`): flow-casfs mounts the state
154 +
  read-only and reads objects as the job touches them (spec §5.1). Nothing
155 +
  is checked out; a job that reads 50 files of a 100,000-file repository
156 +
  fetches 50 files. Jobs whose scope a change does not touch keep their
157 +
  previous result (spec §6), and receipts name the exact state.
158 +
- **Git** (`git+https://…@COMMIT`): the runner clones or fetches the
159 +
  commit into the job's disk before it starts, as every CI system does.
160 +
161 +
Both run through the same fleet, so the difference is measured, not
162 +
claimed: the job page shows how long the source took.
163 +
164 +
```mermaid
165 +
flowchart TB
166 +
    subgraph O["Oxygen source"]
167 +
        O1["job starts"] --> O2["mount state (ms)"] --> O3["read files as used"]
168 +
    end
169 +
    subgraph G["Git source"]
170 +
        G1["clone / fetch commit"] --> G2["check out every file"] --> G3["job starts"]
171 +
    end
172 +
```
173 +
174 +
## 6. Facts: runners know what they are
175 +
176 +
A runner measures itself, and sends what it measured **with every claim**.
177 +
Idle runners claim every few seconds, so Jetstream always holds every idle
178 +
runner's facts from moments ago; there is no separate audit channel to go
179 +
stale.
180 +
181 +
| Fact | Measured by | When |
182 +
|---|---|---|
183 +
| Architecture, CPU model, cores | `uname`, `/proc/cpuinfo` | at start |
184 +
| Tiers: microvm (KVM opened, not assumed), syscall (Landlock ABI), wasm | probing | at start, then hourly |
185 +
| Memory free, disk free where jobs run | `/proc/meminfo`, `statvfs` | every claim |
186 +
| Load, jobs running | `/proc/loadavg`, its own state | every claim |
187 +
| Images held | its image store | every claim |
188 +
| Speed score | a fixed few-second benchmark (hashing, compression, a small compile), normalised so earth-1 ≈ 100 | at start, then about every 15 minutes while idle |
189 +
| Where it runs | the pool: host, cluster and node, or cloud, region, instance type, spot | at start |
190 +
191 +
The score is checked against reality: Jetstream compares each runner's
192 +
claimed speed with how fast its jobs actually ran, and ranks a runner
193 +
whose real timings drift (thermal throttling, noisy neighbours) by what
194 +
it achieved.
195 +
196 +
## 7. Placement: requirements, ranking, and why
197 +
198 +
**A job's requirements are mostly derived**, never typed by the user:
199 +
200 +
- architecture from its image;
201 +
- tier from its sandbox;
202 +
- disk and memory **learned from its own past runs** (peak plus
203 +
  headroom); a new job goes to the roomiest runner the first time;
204 +
- an explicit floor when the pipeline gives one.
205 +
206 +
**Placement** filters, then ranks:
207 +
208 +
1. **Filter on hard facts:** architecture, tier, enough free disk and
209 +
   memory now, steering that requires a pool or label.
210 +
2. **Rank on soft ones:** already holds the image (warm), idle, speed
211 +
   score, the shortest estimated finish.
212 +
3. **A short hold for the best fit:** when the runner asking is a poor
213 +
   fit and a better one claimed seconds ago, the job is held a few seconds
214 +
   for it, then goes to whoever is next.
215 +
4. **The runner has the last word:** it checks again on accept and
216 +
   declines with a reason if its disk filled meanwhile.
217 +
218 +
**Every decision explains itself**, on the job and through the API:
219 +
220 +
> ran on **earth-1**: holds the image, idle, score 100, estimated 4 min
221 +
222 +
> **waiting**: needs ~12 GiB disk (its last run used 10.4); plex-1 has 9;
223 +
> earth-1 is busy until about 11:40
224 +
225 +
**Labels only steer.** `runs-on "plex"` pins, `prefer "fast"` biases. A
226 +
user who never writes one never needs to.
227 +
228 +
## 8. Images
229 +
230 +
- **Content-addressed.** An image is an ext4 root file system named by
231 +
  its BLAKE3 hash (`cas://blob/…`), as today. A sandbox may name one per
232 +
  architecture.
233 +
- **Served by Jetstream.** A runner that lacks an image fetches it by hash
234 +
  and verifies it; nobody copies images between machines. Images are
235 +
  chunked, so a rebuilt image moves only its changed chunks.
236 +
- **Built by Flow.** An image is a job's output: a project's
237 +
  `.flow/image/` becomes a job, its image is kept and served, and the
238 +
  sandbox's pin follows it (a proposal: "image changed: af3c… → 91be…").
239 +
- **Architecture recorded at build**, so an x86 image never reaches a
240 +
  RISC-V runner.
241 +
242 +
## 9. Pools
243 +
244 +
A **pool** is one place runners come from. All pools report the same
245 +
facts and follow the same placement.
246 +
247 +
```mermaid
248 +
flowchart TB
249 +
    J["Jetstream"]
250 +
    subgraph Hosts["Host pool"]
251 +
        E["earth-1<br/>x86_64 · microvm"]
252 +
        PL["plex-1<br/>x86_64 · microvm"]
253 +
        N["narvi-1<br/>riscv64 · syscall"]
254 +
    end
255 +
    subgraph K8s["Kubernetes pool"]
256 +
        CTL["jet controller"]
257 +
        KP1["KVM node pool<br/>Firecracker in pod"]
258 +
        KP2["Kata / gVisor<br/>RuntimeClass"]
259 +
        KP3["ordinary pods<br/>syscall tier"]
260 +
        CTL --> KP1
261 +
        CTL --> KP2
262 +
        CTL --> KP3
263 +
    end
264 +
    subgraph Cloud["Cloud pool (europe-west2)"]
265 +
        PRV["jet provider"]
266 +
        VM1["warm VM, nested KVM<br/>several microVMs"]
267 +
        VM2["VM per job<br/>syscall tier"]
268 +
        PRV --> VM1
269 +
        PRV --> VM2
270 +
    end
271 +
    J --> Hosts
272 +
    J --> CTL
273 +
    J --> PRV
274 +
```
275 +
276 +
**Hosts.** Machines you own run `jet runner` (today's `flow runner`).
277 +
278 +
**Kubernetes.** `jet controller` runs in the cluster, claims for it, and
279 +
creates **one pod per job**, the pattern every major CI system uses on
280 +
Kubernetes. The tier follows what each node pool offers:
281 +
282 +
| Node pool offers | Tier |
283 +
|---|---|
284 +
| `/dev/kvm` through a device plugin (a tainted pool) | microvm: Firecracker inside the pod |
285 +
| A Kata or gVisor RuntimeClass (AKS Pod Sandboxing, GKE Sandbox) | syscall, inside the platform's sandbox |
286 +
| Nothing special (GKE Autopilot included) | syscall, in an unprivileged pod |
287 +
288 +
Dedicated KVM hosts beside a cluster stay first class: that is how
289 +
microVM CI is run in practice.
290 +
291 +
**Cloud providers.** `jet provider` creates and removes VMs in a cloud
292 +
account, keyless through OIDC (OxygenHub is already an issuer, OxygenHub spec
293 +
§13.5):
294 +
295 +
- **Packing first:** a warm 8–16 vCPU VM with nested KVM runs several
296 +
  microVM jobs, saving the 30–60 s boot, the cold disk and per-VM minimum
297 +
  charges. Nested KVM: GCP Intel types and N4D, AWS C7i/C8i and newer
298 +
  (since 2026), Azure Dsv5.
299 +
- **One VM per job** with the syscall tier inside is the fallback where
300 +
  nested KVM is not offered.
301 +
- **Regions are pinned** per pool and never fall back outside the set:
302 +
  for the UK, `europe-west2`, `uksouth`, `eu-west-2`. Every job records
303 +
  where it ran.
304 +
- **Spot**, with on-demand fallback; a reclaim notice requeues the job.
305 +
- **Scale-in** only of idle VMs near the end of their billing increment.
306 +
- Build order: GCP, then AWS, then Azure.
307 +
308 +
The provider interface: `create(n, spec)`, `delete(ids)`, `list()`,
309 +
`health(id)`, `capabilities()`, `quota()`.
310 +
311 +
## 10. Demand and autoscaling
312 +
313 +
Jetstream answers **demand by pull**, so nothing needs a way into your
314 +
network:
315 +
316 +
- `GET /demand?pool=…` (long-poll): waiting jobs grouped by architecture,
317 +
  tier and size, and the age of the oldest. Kubernetes pools scale on it
318 +
  through a KEDA scaler; cloud providers through their own loop.
319 +
- **One-shot runner tokens** for ephemeral runners and pods: issued for
320 +
  one start, never long-lived.
321 +
322 +
## 11. Reliability and running it
323 +
324 +
- **Lost runner:** its jobs are requeued after a short grace; a spot
325 +
  reclaim notice requeues at once.
326 +
- **OOM** is reported as OOM, with the job's peak, and raises its learned
327 +
  memory need.
328 +
- **Updates:** a runner told of a newer version updates itself between
329 +
  jobs. No cut-off date; the contract is versioned and old runners keep
330 +
  working until they update.
331 +
- **Figures:** queue time (the single most useful CI metric), utilisation
332 +
  per runner and pool, and cost per job, pool and region where a provider
333 +
  knows its prices.
334 +
335 +
## 12. Embedded or on its own
336 +
337 +
```mermaid
338 +
flowchart LR
339 +
    subgraph A["Embedded (the default)"]
340 +
        OH1["OxygenHub process<br/>+ jet inside"]
341 +
        R1["runners"] --> OH1
342 +
    end
343 +
    subgraph B["On its own"]
344 +
        OH2["OxygenHub"] --> JS2["jet server"]
345 +
        OH3["another OxygenHub"] --> JS2
346 +
        R2["runners"] --> JS2
347 +
    end
348 +
```
349 +
350 +
- **Embedded:** the same crate in OxygenHub's process, with no
351 +
  configuration. This is what OxygenHub does unless told otherwise.
352 +
- **On its own:** `[flow] jetstream = "https://jet.example"` in the hub's
353 +
  configuration points it at a standalone `jet server`; several hubs may
354 +
  share one fleet.
355 +
- **Other forges, later:** the forge contract (§4) and Git sources (§5)
356 +
  are what an adapter needs, such as one that runs GitLab or GitHub jobs
357 +
  on Jetstream runners. That would be a contained piece of work, not a
358 +
  rewrite; it is not planned yet.
359 +
360 +
## 13. Running a pipeline on your own machine
361 +
362 +
```sh
363 +
flow run                     # every job of .flow/pipeline.kdl, on this directory's files
364 +
flow run test                # one job, and what it needs
365 +
flow run --var X=1 --secret TOKEN=…   # what the hub would supply
366 +
```
367 +
368 +
`flow run` exists today: it runs the pipeline's jobs in the same sandboxes
369 +
CI uses, before anything is pushed. With Jetstream it becomes an
370 +
in-process Jetstream with one local runner, the same placement and the
371 +
same code path as CI, so passing locally means passing in CI. With images
372 +
served by Jetstream (§8), the first local run fetches the pinned image by
373 +
hash instead of needing it imported by hand.
374 +
375 +
## 14. Security
376 +
377 +
- **Isolation per job:** a microVM where KVM exists, the syscall tier
378 +
  (Landlock and seccomp) elsewhere, a platform sandbox where a cluster
379 +
  offers one. No job ever shares a Docker daemon or a host socket.
380 +
- **Secrets** are mounted as files in the job and masked in logs (spec
381 +
  §9), never written to a runner's disk outside the job.
382 +
- **Cloud VMs** have no cloud identity of their own, the metadata service
383 +
  is blocked from inside job microVMs, and job subnets accept nothing
384 +
  inbound.
385 +
- **Runner tokens** are per runner, and one-shot for ephemeral ones.
386 +
387 +
## 15. Order of work
388 +
389 +
1. **The boundary, inside the hub:** queue, placement and runners behind
390 +
   the Jetstream contract in a `jet` crate, embedded; no behaviour change,
391 +
   CI proves it.
392 +
2. **Images served by Jetstream**, fetched by hash; images built by Flow
393 +
   jobs.
394 +
3. **Facts on every claim, learned requirements, placement with its
395 +
   explanation**, on the job page and the queue.
396 +
4. **narvi-1**, the first runner brought up this way (riscv64, syscall).
397 +
5. **`jet server`**, OxygenHub pointing at it.
398 +
6. **The Kubernetes controller**, with a KEDA scaler on `/demand`.
399 +
7. **Cloud providers:** GCP, then AWS, then Azure.
400 +
401 +
## 16. Open questions
402 +
403 +
- Whether artifacts and report files travel through Jetstream or go
404 +
  straight from the runner to the forge (today: straight to the hub).
405 +
- Multi-tenancy in a shared `jet server`: pools per organisation, and
406 +
  who may submit to which.
407 +
- How long the best-fit hold (§7) should be, and whether it should learn.
408 +
- Receipts: the runner signs; whether Jetstream countersigns where it
409 +
  placed the job.