···99 out: shuttle/src/gen
1010 opt:
1111 - bytes=.
1212+ # the fleet protocol is broker<->executor only (both Go); shuttle (Rust)
1313+ # only speaks the agent protocol, so keep fleet types out of its gen tree.
1414+ exclude_types:
1515+ - spindle.mill.v1
···5151 This writes the image directory under `out/localinfra-spindle-images`.
52525. `docker compose up`
53536. AppView will be running on `127.0.0.1:3000` with two test users: `alice.pds.tngl.boltless.dev` and `bob.pds.tngl.boltless.dev`. Both with password `password`.
5454+5555+## Mill mode
5656+5757+The default stack runs one standalone spindle. `docker-compose.mill.yml` is an overlay that splits that into the distributed arch: the primary spindle becomes the mill host (`role=mill`, it only places jobs) and a three-executor fleet (`role=executor`) runs the engines.
5858+5959+```bash
6060+docker compose -f docker-compose.yml -f docker-compose.mill.yml --profile linux up
6161+```
6262+6363+The executors differ on the two axes placement cares about, so you can watch candidates get filtered and ranked:
6464+6565+| executor | labels | images | runs |
6666+|------------|---------------|-------------|-----------------------------|
6767+| executor-a | `linux, fast` | full | `image/alpine, image/nixos` |
6868+| executor-b | `linux, slow` | full | `image/alpine, image/nixos` |
6969+| executor-c | `linux, gpu` | alpine only | `image/alpine` |
7070+7171+So `image: nixos` has two candidates, `image: alpine` has three, and `runs_on: [gpu]` pins to executor-c. The alpine-only image set is staged by `prepare-spindle-images.sh` (step 4) alongside the full one, no extra step.
7272+7373+Each executor needs its own identity (one live session per token), so the `mill-tokens` service registers a token per executor in the mill db and drops it into the shared volume for the executor to read.
···7979 );
80808181 create table if not exists spindle_members (
8282- -- identifiers for the record
8382 id integer primary key autoincrement,
8483 did text not null,
8584 rkey text not null,
8686-8787- -- data
8885 instance text not null,
8986 subject text not null,
9087 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
9191-9292- -- constraints
9388 unique (did, rkey)
9489 );
95909696- -- status event for a single workflow
9791 create table if not exists events (
9892 rkey text not null,
9993 nsid text not null,
100100- event text not null, -- json
101101- created integer not null -- unix nanos
9494+ event text not null,
9595+ created integer not null
10296 );
1039710498 create table if not exists nixos_toplevel_cache (
···132126 foreign key (pipeline_id) references pipelines(id) on delete cascade
133127 );
134128129129+ create table if not exists mill_executors (
130130+ name text primary key,
131131+ token_hash text not null unique,
132132+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
133133+ expires_at text,
134134+ labels text,
135135+ quarantine_reason text,
136136+ quarantined_at text
137137+ );
138138+139139+ create table if not exists mill_leases (
140140+ lease_id text primary key,
141141+ node_id text not null,
142142+ epoch text not null,
143143+ engine text not null,
144144+ knot text not null,
145145+ rkey text not null,
146146+ workflow text not null,
147147+ state text not null
148148+ );
149149+150150+ create table if not exists mill_executor_cursors (
151151+ node_id text not null,
152152+ epoch text not null,
153153+ acked_seqno integer not null,
154154+ primary key (node_id, epoch)
155155+ );
156156+157157+ create table if not exists mill_outbox_state (
158158+ epoch text not null,
159159+ next_seqno integer not null,
160160+ primary key (epoch)
161161+ );
162162+163163+ create table if not exists mill_outbox_rows (
164164+ epoch text not null,
165165+ seqno integer not null,
166166+ payload blob not null,
167167+ byte_size integer not null,
168168+ control integer not null,
169169+ primary key (epoch, seqno),
170170+ foreign key (epoch) references mill_outbox_state(epoch) on delete cascade
171171+ );
172172+173173+ create table if not exists mill_artifacts (
174174+ id integer primary key autoincrement,
175175+ lease_id text not null,
176176+ workflow text not null,
177177+ ref text not null,
178178+ hash text not null
179179+ );
180180+181181+ create table if not exists executor_pending_artifacts (
182182+ lease_id text primary key,
183183+ workflow text not null,
184184+ status text not null,
185185+ error text not null default '',
186186+ exit_code integer not null default 0,
187187+ ref text not null,
188188+ hash text not null
189189+ );
190190+135191 create table if not exists migrations (
136192 id integer primary key autoincrement,
137193 name text unique
138194 );
139195 `)
140140- if err != nil {
141141- return nil, err
142142- }
143143-144196 if err := runMigrations(ctx, conn, logger); err != nil {
145197 return nil, err
146198 }
···11+# spindle mill
22+33+This document describes the architecture of the mill, spindle's distributed
44+job placement layer. A mill is a spindle that doesn't run jobs itself: it
55+places them on remote executors, which run the real engines (microvm, nixery,
66+dummy) exactly as they would standalone.
77+88+## The engine mirroring model
99+1010+Neither side's execution path changes between local and remote runs.
1111+1212+On the mill host, engines are registered under the real engine names
1313+("microvm", "nixery", "dummy"), but each is a stand-in that places jobs
1414+remotely instead of running them. `InitWorkflow` on the stand-in parses
1515+nothing, it stashes the raw pipeline and workflow and builds a synthetic
1616+one-step workflow, so everything upstream (trigger handling, pending status,
1717+`TANGLED_*` env) behaves exactly like a local run. The real `InitWorkflow`
1818+runs exactly once, on the executor.
1919+2020+On the executor, the reserve handler wraps the real engine in a
2121+`reservedEngine`: the slot acquired when the reservation is accepted is the
2222+same slot `StartWorkflows` later runs on. Nothing acquires twice, and from the
2323+engine's point of view the job is indistinguishable from a local one.
2424+2525+The same mirroring applies to output: the executor's jobs write status rows
2626+and log lines exactly like a standalone spindle, and the mill re-authors them
2727+into its own event stream and log store, so appview and the log endpoints see
2828+no difference between a local and a remote job.
2929+3030+The only real divergence: secrets are withheld from untrusted pipelines at the
3131+mill, and they cross the wire exactly once, inside `CommitLease`, to the one
3232+node that won the bid. Losing bidders never see them.
3333+3434+## Placement: labels, seats, and rejection
3535+3636+Placement decides which executors can run a workflow right now, using one
3737+kind of fact:
3838+3939+- **labels** are operator-defined strings on the executor's token, checked
4040+ against the workflow's `runs_on`. they're for coarse fleet partitioning
4141+ ("this pool is for trusted jobs", "this box has a gpu"), nothing more
4242+4343+Matching is exact intersection: candidate sessions whose snapshot has the
4444+engine available, a free seat, and labels covering `runs_on`. Among the
4545+eligible candidates the mill ranks least-loaded first and bids the top-K
4646+concurrently with `ReserveSeat`. The best-ranked accept wins, losers get
4747+`ReleaseLease` so they free their held seats immediately. If nobody
4848+accepts, the job waits for a change: a new executor connecting, a snapshot
4949+flipping availability, or a lease finishing somewhere.
5050+5151+Image and arch compatibility is deliberately *not* a mill concern. The mill
5252+never parses an image name or an arch string; the executor re-validates
5353+everything at reserve time against its own disk (engine exists, workflow
5454+parses, the image spec validates and is natively runnable, the runner is
5555+usable) and rejects what it can't run with `incompatible`. A reject rotates
5656+the bid to the next candidate, and if every candidate rejects, the user
5757+gets the collected reasons as the placement error. Multi-arch falls out of
5858+this: an arm64 box simply cannot accept an x86_64 image, so it never holds
5959+one. If you *want* to pin an arch or an image explicitly, label the nodes
6060+and use `runs_on`.
6161+6262+### Example: an alpine microvm job
6363+6464+Say a workflow asks for the microvm engine with `image: alpine`, no
6565+`runs_on`, and the fleet looks like this:
6666+6767+| node | arch | labels | engines | load |
6868+|---------|---------|---------|-------------------|--------|
6969+| ci-1 | x86_64 | [linux] | microvm | busy |
7070+| ci-2 | aarch64 | [linux] | microvm | idle |
7171+| ci-3 | x86_64 | [linux] | microvm, nixery | idle |
7272+| ci-4 | x86_64 | [gpu] | microvm | idle |
7373+7474+The walkthrough:
7575+7676+```mermaid
7777+flowchart TD
7878+ W["workflow<br/><small>engine: microvm, image: alpine</small>"] --> F{"candidate filter"}
7979+ F -->|"all pass runs_on (empty)"| L{"has microvm<br/>available?"}
8080+ L -->|all four| R{"rank by load"}
8181+ R -->|"ci-1 busy"| X2["ci-1 ranked last"]
8282+ R --> B["bid top-K: ci-2, ci-4, ci-1"]
8383+```
8484+8585+Say ci-2's alpine image is actually x86_64-only: the mill offers the bid
8686+anyway, ci-2's reserve-time validation rejects it as incompatible, and the
8787+bid rotates to ci-4. The mill learns "ci-2 can't run alpine" from the
8888+rejection, not from any advertisement, and the reason reaches the user if
8989+every candidate fails the same way.
9090+9191+The bid then runs concurrently:
9292+9393+```mermaid
9494+sequenceDiagram
9595+ participant M as mill
9696+ participant C2 as ci-2
9797+ participant C4 as ci-4
9898+ participant C1 as ci-1
9999+100100+ par bids
101101+ M->>C2: ReserveSeat (raw pipeline+workflow)
102102+ M->>C4: ReserveSeat
103103+ M->>C1: ReserveSeat
104104+ end
105105+ C2-->>M: accept (idle)
106106+ C4-->>M: accept
107107+ C1-->>M: reject (transient, seats full)
108108+ Note over M: ci-2 ranked above ci-4,<br/>ci-4 gets ReleaseLease
109109+ M->>C2: CommitLease (secrets)
110110+ C2-->>M: Committed
111111+ M->>C4: ReleaseLease
112112+```
113113+114114+Both idle nodes accepted, so rank breaks the tie: ci-2 keeps the seat, ci-4
115115+frees its immediately, and only ci-2 ever sees the secrets. From here ci-2
116116+runs the job exactly like a standalone spindle would, booting the alpine
117117+image under QEMU, while the mill blocks on the terminal event.
118118+119119+## The protocol's durability model
120120+121121+Executor to mill is a single websocket per node, and everything the executor
122122+reports (status, logs, terminal results) travels as sequenced `Event`s. Two
123123+identifiers keep it all consistent:
124124+125125+- the **epoch** names one lifetime of the executor process. a restarted
126126+ executor connects with a fresh epoch, and anything arriving for an old one
127127+ is invalid. leases are bound to node+epoch, so a zombie from a previous
128128+ process can't act on them
129129+- the **seqno** is a dense per-epoch counter on events. the executor persists
130130+ events in a local outbox before sending and trims it only when the mill
131131+ acks. on reconnect it resumes from the mill's acked position and replays.
132132+ the mill applies idempotently: replays drop, gaps kill the session
133133+134134+The mill is equally restartable: leases, acked positions and the canonical
135135+log all live in its db. Restored leases start as orphans and must be
136136+reclaimed by the executor's first snapshot, or a sweep fails them after one
137137+grace window. The invariant throughout: at any moment, for any lease, exactly
138138+one of {executor outbox, mill db} holds the newest state, and the seqno/epoch
139139+pair says who.
140140+141141+## Leases
142142+143143+A lease is the mill-side handle for one placed job:
144144+145145+```mermaid
146146+stateDiagram-v2
147147+ [*] --> reserved: bid won
148148+ reserved --> committing: CommitLease sent
149149+ committing --> running: Committed
150150+ running --> done: terminal arrived
151151+ reserved --> done: released / expired
152152+ committing --> done: released / expired
153153+```
154154+155155+Commit retries ride reconnects: a reservation outlives one disconnect, so a
156156+lost session means wait and retry, not a failed job. What ends a job is the
157157+job timeout, a terminal event, or the executor being declared dead after
158158+reconnect grace expires, at which point every lease the node held fails
159159+(preserving a pending cancellation as the reason).