OXYGENHUB
# b14aeaae683a oxygenflow / docs / jetstream.md
20.3 KiB · Markdown · last changed Jetstream: runners share images peer to peer 24 days ago renderedsource blame history raw
2814f421 Andy McCall ↶ 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
b14aeaae Andy McCall ↶ 242
### 8.1 The swarm: runners share images
243
244
Runners that are configured for it fetch image chunks from each other as
245
well as from the hub (oxygenflow #47, oxygenhub #144). Fifty fresh
246
runners asking for the same 2 GiB image then cost the hub about one copy,
247
not fifty, and a site behind a thin link pulls each chunk across it once.
248
249
- **Trust comes from the hashes.** The manifest comes from the hub, and
250
  every chunk is checked against its name before it is kept. A peer can
251
  fail to help; it can never change what a job runs.
252
- **The hub is the tracker.** A runner reports its swarm address and site
253
  with every claim. `POST /api/v1/flow/swarm/announce {image, state}`
254
  (`fetching`, `done` or `stopped`) returns the peers in the same site that
255
  hold the image or are fetching it, and a **ticket**.
256
- **Tickets admit peers.** A ticket names the runner, its site and an
257
  expiry (15 minutes), signed with the hub key that `/.well-known/oxyhub-key`
258
  publishes. A runner shows its ticket once to each peer
259
  (`POST /swarm/v1/hello`) and gets a session for the ticket's life; no
260
  other runner and no account can take chunks from a peer.
261
- **The peer protocol** is plain HTTP on the runner's swarm address:
262
  - `POST /swarm/v1/hello` with the ticket: a session token.
263
  - `GET /swarm/v1/images/{image}/have`: `complete`, or two bit maps over
264
    the manifest's needed list (`Manifest::needed`), in its order: the
265
    chunks it has, then the chunks it is fetching now.
266
  - `GET /swarm/v1/chunks/{chunk}`: the chunk's bytes, from the chunk
267
    cache or read out of a whole image; `503` when the peer is at its
268
    connection limit.
269
- **Rarest first.** The fetcher refreshes every peer's maps twice a
270
  second and asks each peer, four requests at a time, for the chunk the
271
  fewest peers hold. The hub is a seed that holds everything: it is asked
272
  only for chunks no peer has or is fetching, or any chunk once the peers
273
  have made no progress for two seconds. Runners that start together so
274
  split the hub's chunks between them rather than each taking them all. When every missing chunk is already asked
275
  for, idle workers ask a second source for it (the endgame; the hub joins
276
  it only once the peers stall). A peer that
277
  fails is dropped and its chunks go back to the pool.
278
- **Serving while fetching.** A chunk is offered to others as soon as it
279
  is checked and kept, so runners starting together share the load.
280
- **Bounded.** A runner serves at most 16 connections and `upload_mib_s`
281
  (default 50 MiB/s), so serving never starves its own jobs.
282
- **Configured, never assumed.** Without a `[runner.swarm]` section a
283
  runner neither listens nor asks peers. Traffic is plain HTTP: chunks are
284
  public within the fleet and checked by hash, so keep the swarm address
285
  on a private network.
286
287
In flow.toml (or as `[swarm]` in runner.toml, which stands in for the
288
`[runner]` section):
289
290
```toml
291
[runner.swarm]
292
listen = "0.0.0.0:7401"        # where peers reach this runner
293
advertise = "10.0.0.5:7401"    # the address peers are given (default: listen)
294
site = "home"                  # peers are only ever in the same site
295
upload_mib_s = 50
296
```
297
2814f421 Andy McCall ↶ 298
## 9. Pools
299
300
A **pool** is one place runners come from. All pools report the same
301
facts and follow the same placement.
302
303
```mermaid
304
flowchart TB
305
    J["Jetstream"]
306
    subgraph Hosts["Host pool"]
307
        E["earth-1<br/>x86_64 · microvm"]
308
        PL["plex-1<br/>x86_64 · microvm"]
309
        N["narvi-1<br/>riscv64 · syscall"]
310
    end
311
    subgraph K8s["Kubernetes pool"]
312
        CTL["jet controller"]
313
        KP1["KVM node pool<br/>Firecracker in pod"]
314
        KP2["Kata / gVisor<br/>RuntimeClass"]
315
        KP3["ordinary pods<br/>syscall tier"]
316
        CTL --> KP1
317
        CTL --> KP2
318
        CTL --> KP3
319
    end
320
    subgraph Cloud["Cloud pool (europe-west2)"]
321
        PRV["jet provider"]
322
        VM1["warm VM, nested KVM<br/>several microVMs"]
323
        VM2["VM per job<br/>syscall tier"]
324
        PRV --> VM1
325
        PRV --> VM2
326
    end
327
    J --> Hosts
328
    J --> CTL
329
    J --> PRV
330
```
331
332
**Hosts.** Machines you own run `jet runner` (today's `flow runner`).
333
334
**Kubernetes.** `jet controller` runs in the cluster, claims for it, and
335
creates **one pod per job**, the pattern every major CI system uses on
336
Kubernetes. The tier follows what each node pool offers:
337
338
| Node pool offers | Tier |
339
|---|---|
340
| `/dev/kvm` through a device plugin (a tainted pool) | microvm: Firecracker inside the pod |
341
| A Kata or gVisor RuntimeClass (AKS Pod Sandboxing, GKE Sandbox) | syscall, inside the platform's sandbox |
342
| Nothing special (GKE Autopilot included) | syscall, in an unprivileged pod |
343
344
Dedicated KVM hosts beside a cluster stay first class: that is how
345
microVM CI is run in practice.
346
347
**Cloud providers.** `jet provider` creates and removes VMs in a cloud
348
account, keyless through OIDC (OxygenHub is already an issuer, OxygenHub spec
349
§13.5):
350
351
- **Packing first:** a warm 8–16 vCPU VM with nested KVM runs several
352
  microVM jobs, saving the 30–60 s boot, the cold disk and per-VM minimum
353
  charges. Nested KVM: GCP Intel types and N4D, AWS C7i/C8i and newer
354
  (since 2026), Azure Dsv5.
355
- **One VM per job** with the syscall tier inside is the fallback where
356
  nested KVM is not offered.
357
- **Regions are pinned** per pool and never fall back outside the set:
358
  for the UK, `europe-west2`, `uksouth`, `eu-west-2`. Every job records
359
  where it ran.
360
- **Spot**, with on-demand fallback; a reclaim notice requeues the job.
361
- **Scale-in** only of idle VMs near the end of their billing increment.
362
- Build order: GCP, then AWS, then Azure.
363
364
The provider interface: `create(n, spec)`, `delete(ids)`, `list()`,
365
`health(id)`, `capabilities()`, `quota()`.
366
367
## 10. Demand and autoscaling
368
369
Jetstream answers **demand by pull**, so nothing needs a way into your
370
network:
371
372
- `GET /demand?pool=…` (long-poll): waiting jobs grouped by architecture,
373
  tier and size, and the age of the oldest. Kubernetes pools scale on it
374
  through a KEDA scaler; cloud providers through their own loop.
375
- **One-shot runner tokens** for ephemeral runners and pods: issued for
376
  one start, never long-lived.
377
378
## 11. Reliability and running it
379
380
- **Lost runner:** its jobs are requeued after a short grace; a spot
381
  reclaim notice requeues at once.
382
- **OOM** is reported as OOM, with the job's peak, and raises its learned
383
  memory need.
384
- **Updates:** a runner told of a newer version updates itself between
385
  jobs. No cut-off date; the contract is versioned and old runners keep
386
  working until they update.
387
- **Figures:** queue time (the single most useful CI metric), utilisation
388
  per runner and pool, and cost per job, pool and region where a provider
389
  knows its prices.
390
391
## 12. Embedded or on its own
392
393
```mermaid
394
flowchart LR
395
    subgraph A["Embedded (the default)"]
396
        OH1["OxygenHub process<br/>+ jet inside"]
397
        R1["runners"] --> OH1
398
    end
399
    subgraph B["On its own"]
400
        OH2["OxygenHub"] --> JS2["jet server"]
401
        OH3["another OxygenHub"] --> JS2
402
        R2["runners"] --> JS2
403
    end
404
```
405
406
- **Embedded:** the same crate in OxygenHub's process, with no
407
  configuration. This is what OxygenHub does unless told otherwise.
408
- **On its own:** `[flow] jetstream = "https://jet.example"` in the hub's
409
  configuration points it at a standalone `jet server`; several hubs may
410
  share one fleet.
411
- **Other forges, later:** the forge contract (§4) and Git sources (§5)
412
  are what an adapter needs, such as one that runs GitLab or GitHub jobs
413
  on Jetstream runners. That would be a contained piece of work, not a
414
  rewrite; it is not planned yet.
415
416
## 13. Running a pipeline on your own machine
417
418
```sh
419
flow run                     # every job of .flow/pipeline.kdl, on this directory's files
420
flow run test                # one job, and what it needs
421
flow run --var X=1 --secret TOKEN=…   # what the hub would supply
422
```
423
424
`flow run` exists today: it runs the pipeline's jobs in the same sandboxes
425
CI uses, before anything is pushed. With Jetstream it becomes an
426
in-process Jetstream with one local runner, the same placement and the
427
same code path as CI, so passing locally means passing in CI. With images
428
served by Jetstream (§8), the first local run fetches the pinned image by
429
hash instead of needing it imported by hand.
430
431
## 14. Security
432
433
- **Isolation per job:** a microVM where KVM exists, the syscall tier
434
  (Landlock and seccomp) elsewhere, a platform sandbox where a cluster
435
  offers one. No job ever shares a Docker daemon or a host socket.
436
- **Secrets** are mounted as files in the job and masked in logs (spec
437
  §9), never written to a runner's disk outside the job.
438
- **Cloud VMs** have no cloud identity of their own, the metadata service
439
  is blocked from inside job microVMs, and job subnets accept nothing
440
  inbound.
441
- **Runner tokens** are per runner, and one-shot for ephemeral ones.
442
443
## 15. Order of work
444
445
1. **The boundary, inside the hub:** queue, placement and runners behind
446
   the Jetstream contract in a `jet` crate, embedded; no behaviour change,
447
   CI proves it.
448
2. **Images served by Jetstream**, fetched by hash; images built by Flow
449
   jobs.
450
3. **Facts on every claim, learned requirements, placement with its
451
   explanation**, on the job page and the queue.
452
4. **narvi-1**, the first runner brought up this way (riscv64, syscall).
453
5. **`jet server`**, OxygenHub pointing at it.
454
6. **The Kubernetes controller**, with a KEDA scaler on `/demand`.
455
7. **Cloud providers:** GCP, then AWS, then Azure.
456
457
## 16. Open questions
458
459
- Whether artifacts and report files travel through Jetstream or go
460
  straight from the runner to the forge (today: straight to the hub).
461
- Multi-tenancy in a shared `jet server`: pools per organisation, and
462
  who may submit to which.
463
- How long the best-fit hold (§7) should be, and whether it should learn.
464
- Receipts: the runner signs; whether Jetstream countersigns where it
465
  placed the job.