| 1 |
# Roles: one binary, several processes |
| 2 |
|
| 3 |
Status: design, 2026-10-19 (overnight loop 17). H2 (`--role` and the |
| 4 |
leader lock, #272) and H3 (the event bus, #273) are built; the |
| 5 |
transparency log takes appends from every web process sharing its directory (#274). |
| 6 |
|
| 7 |
OxygenHub is one process today: pages, API, the `oxy` wire protocol, Flow's |
| 8 |
scheduler and every background task share one runtime and one data |
| 9 |
directory. That is the single-node edition, and it stays one command. This |
| 10 |
document says how the same binary runs as several processes, each taking |
| 11 |
some of the work (its **roles**), so the web tier can scale out and the |
| 12 |
background work runs exactly once, without a second product to maintain. |
| 13 |
|
| 14 |
## Principles |
| 15 |
|
| 16 |
1. **One binary, many roles.** `oxygenhub serve --role web,worker` (all |
| 17 |
roles when none is given). A cluster runs the roles apart; a small site |
| 18 |
runs them together. |
| 19 |
2. **Seams before services.** Each piece of shared state goes behind a |
| 20 |
trait inside the process first, with today's behaviour as one |
| 21 |
implementation. Splitting into processes is then a deployment choice. |
| 22 |
3. **Postgres before new infrastructure.** Leader election (advisory |
| 23 |
locks), the job queue (`FOR UPDATE SKIP LOCKED`) and the event bus |
| 24 |
(`LISTEN/NOTIFY`) run on the Postgres the hub already uses (#211). NATS |
| 25 |
or JetStream come only when a measurement asks for them. |
| 26 |
4. **Lean on Oxygen's model.** Changes are immutable and content |
| 27 |
addressed; only bookmark and tag moves are mutable, and they move by |
| 28 |
compare-and-swap of the operation head. Two processes writing one |
| 29 |
repository are already safe at the storage layer: that is what makes |
| 30 |
web nodes cheap to add. |
| 31 |
|
| 32 |
## The roles |
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
| **web** | pages, the JSON API, the `oxy` wire protocol (fetch and push), live pages (SSE), the runner API (claims, logs, finish) | many, behind a load balancer | |
| 37 |
| **worker** | the background tasks below; one instance of each singleton at a time, chosen by a leader lock | one or more | |
| 38 |
| **edge** (later) | the wire protocol and large uploads, routed to the node that holds a repository | many | |
| 39 |
| **store** (later) | repositories on its disks, behind a service interface | several, by repository | |
| 40 |
|
| 41 |
Tonight's slices are **web** and **worker**. Edge and store are the |
| 42 |
repository service (a later phase): until then every node reaches every |
| 43 |
repository either through one shared data directory (one machine) or |
| 44 |
through bucket-backed repositories (oxygenhub#96), which any node can open. |
| 45 |
|
| 46 |
## What runs where today, and what each needs |
| 47 |
|
| 48 |
Background tasks started in `lib.rs::open`: |
| 49 |
|
| 50 |
|
| 51 |
|
| 52 |
| `hooks::worker` | delivers webhooks from the `deliveries` table, nudged by the bus's `deliveries` topic | worker | yes today; many once deliveries are claimed with SKIP LOCKED | the nudge over the event bus (built, #273) | |
| 53 |
| `tlog::checkpointer` | signs checkpoints, gathers witness cosignatures | web, beside the log's appends, until #274 | **yes** | the log's single writer (below) | |
| 54 |
| `flow::reaper` | ends jobs whose runner stopped reporting | worker | yes | leader lock | |
| 55 |
| `flow::scheduler` | starts `schedule` runs on their cron lines | worker | **yes** (two would start each run twice) | leader lock | |
| 56 |
| `flow::placement` | the placement pass: why each job waits | worker | yes | leader lock | |
| 57 |
| `maintenance::nightly` | nightly upkeep (gc, prunes) | worker | yes | leader lock | |
| 58 |
| `backups::scheduler` | scheduled backups | worker | **yes** | leader lock | |
| 59 |
| `diffusion::backfill`, `diffusion::reconciler` | the pin index and the bumps it drives | worker | yes | leader lock | |
| 60 |
| `images::learn_archs` | reads kept images' architectures once | worker | no (idempotent) | none | |
| 61 |
|
| 62 |
Work spawned by requests (runs on whichever web node took the request): |
| 63 |
|
| 64 |
- `autoland::on_check_task` after a check is recorded: serialised today by |
| 65 |
a process-wide `TURN` mutex, so two nodes could land one proposal twice. |
| 66 |
Built (#275): `leader::exclusive("autoland:ID")`, a transaction-scoped |
| 67 |
advisory lock per proposal, held for each evaluation, beside the TURN. |
| 68 |
- `diffusion::propagate` / `try_land` / `catch_up` / `land_ready` after a |
| 69 |
push: idempotent by design, but racing; built (#275): each bump and |
| 70 |
landing of a consumer's pin holds `diffusion:CONSUMER:PATH`. |
| 71 |
- The transparency log's appends in `after_push` ("logged before the push |
| 72 |
is answered"): **one writer only**. The log's files live in the data |
| 73 |
directory; two processes appending would corrupt them. Needs either an |
| 74 |
advisory lock around each append (one machine, shared directory), or a |
| 75 |
log role that the web nodes call (a cluster). |
| 76 |
|
| 77 |
State kept in the process: |
| 78 |
|
| 79 |
|
| 80 |
|
| 81 |
| state cache, summaries | per process, content-addressed | stays per node (safe: keys are hashes) | |
| 82 |
| a repository's writer lock (`repos::Locks`, #249) | per process | the operation head's compare-and-swap already arbitrates; the lock only saves retries, so per node is correct | |
| 83 |
| Flow placement hints (`CACHES`, `HELD`) | per process | per node is acceptable (a hint); the runner's own claim facts are the truth | |
| 84 |
| live pages (`events`, `rooms`) | `tokio::sync::broadcast` | **the event bus** (built, #273); a resolver room's presence stays per node | |
| 85 |
| rate limits (`throttle`) | per process | per node (limits per node), or Postgres later | |
| 86 |
| sessions, tokens, accounts | the database | unchanged | |
| 87 |
| keys (`data/keys/`) | files | shared directory or configuration | |
| 88 |
|
| 89 |
## Leader lock |
| 90 |
|
| 91 |
`leader::run(app, name, task)` (`crates/hub/src/leader.rs`): a process |
| 92 |
runs a singleton task only while it holds a lock named for it. On |
| 93 |
Postgres, `pg_try_advisory_lock(key)`, the key a stable 64-bit hash |
| 94 |
(BLAKE3) of the hub's schema and the task's name, so two hubs in two |
| 95 |
schemas of one database never share a lock. Every lock of one process is |
| 96 |
held on one connection of its own, outside the pool, for the process's |
| 97 |
life. A process that does not hold a lock asks again every 2 seconds. One |
| 98 |
that holds it checks the connection every 2 seconds (one `SELECT 1` |
| 99 |
answers for every task); if it has gone, the server has freed its locks, |
| 100 |
so each task it led is stopped (its future dropped) and the process |
| 101 |
contends again on a new connection. A process that dies closes its |
| 102 |
connection and frees its locks; another takes over within seconds (2 s in |
| 103 |
the test). A holder whose connection drops can overlap its successor for |
| 104 |
at most those 2 seconds, which the tasks tolerate. On SQLite (one machine |
| 105 |
by definition) the lock is always held. Each task is safe to run again |
| 106 |
after a failover: they read their work from the database. A task that |
| 107 |
ends by itself (`diffusion.backfill`) gives its lock back; the next |
| 108 |
worker to take it runs it again, harmlessly. |
| 109 |
|
| 110 |
The locks: `hooks.worker`, `flow.reaper`, `flow.scheduler`, |
| 111 |
`flow.placement`, `maintenance.nightly`, `backups.scheduler`, |
| 112 |
`diffusion.backfill`, `diffusion.reconciler`, `secrets.history_scan` |
| 113 |
(worker role) and `tlog.checkpointer` (web role, below). |
| 114 |
`images.learn_archs` runs in every worker, without a lock. |
| 115 |
|
| 116 |
`/health` says what the process does and which locks it holds: |
| 117 |
`"roles": ["web", "worker"], "leading": ["backups.scheduler", …]`. |
| 118 |
|
| 119 |
## Running the roles apart (H2) |
| 120 |
|
| 121 |
`oxygenhub serve --role web,worker` (or `[server] roles` in `hub.toml`, |
| 122 |
`OXYHUB_SERVER_ROLES`); every role when none is given. An unknown role is |
| 123 |
an error. |
| 124 |
|
| 125 |
- **web** serves the whole router. It also owns the transparency log: it |
| 126 |
appends to it (`after_push`, kept receipts and approvals) and runs its |
| 127 |
checkpointer, under the `tlog.checkpointer` lock. The checkpointer moved |
| 128 |
from the worker's list above because the log's state is in the process |
| 129 |
that appends: a checkpointer elsewhere would sign a log it never sees |
| 130 |
grow, and the web process would have no checkpoint to serve. |
| 131 |
- **worker** starts the background tasks, and serves only `/health` and |
| 132 |
`/metrics` (when `metrics_token` is set) on its bind address, so it can |
| 133 |
be watched; with no bind address it serves nothing. It opens the |
| 134 |
transparency log to read only: it never cuts short an append the web |
| 135 |
process is making. |
| 136 |
- **Several web processes may share a data directory (#274).** Each |
| 137 |
append to the transparency log holds `append.lock` in the log's |
| 138 |
directory exclusively, and starts from the log as the files hold it (it |
| 139 |
finishes what a crashed append left, then appends); readers catch up |
| 140 |
with every writer's entries, and the checkpoint is read from its file, |
| 141 |
signed by whichever process leads `tlog.checkpointer`. Across machines |
| 142 |
that needs the data directory on one shared filesystem with working |
| 143 |
`flock`; a log role the web nodes call is the answer for that, later. |
| 144 |
Any number may be workers. |
| 145 |
|
| 146 |
## Event bus: publish and subscribe |
| 147 |
|
| 148 |
A trait with two operations, `publish(topic, payload)` and |
| 149 |
`subscribe(topic) -> Stream`, for the events live pages and workers react |
| 150 |
to: a bookmark moved, a proposal changed, a check recorded, a run or job |
| 151 |
changed state, a delivery is waiting, a live room's choice. |
| 152 |
|
| 153 |
- **In process:** `tokio::sync::broadcast` per topic, what the hub does |
| 154 |
today. The single-node edition keeps it. |
| 155 |
- **Postgres:** `LISTEN`/`NOTIFY` on one channel per topic. A payload is at |
| 156 |
most 8000 bytes, so events carry ids and kinds, and subscribers read the |
| 157 |
rows they need. A node publishes once; every node's subscriber relays |
| 158 |
the event to its local broadcast, so live pages on any node see it. |
| 159 |
- **Later, NATS JetStream** (oxygenhub#208, #209): durable streams, replay |
| 160 |
from a position, and subscribers outside the hub (a product feature: |
| 161 |
"subscribe to this repository's changes" for integrations, beyond |
| 162 |
webhooks). The same trait. |
| 163 |
|
| 164 |
Delivery is at most once; anything that must not be lost is a row in a |
| 165 |
table (the job queue), and the event only says "look now". |
| 166 |
|
| 167 |
### Built (H3, #273) |
| 168 |
|
| 169 |
`crates/hub/src/bus.rs`: `app.bus.publish(event)` and |
| 170 |
`app.bus.subscribe(topic)`, a `tokio::sync::broadcast::Receiver<Event>`. |
| 171 |
Nothing to configure: a hub built for PostgreSQL carries events between |
| 172 |
its processes over `LISTEN/NOTIFY`; on SQLite (one process) the bus is |
| 173 |
in process only. |
| 174 |
|
| 175 |
|
| 176 |
|
| 177 |
| `deliveries` | `delivery_waiting` | `hooks::emit` after queueing deliveries, a ping, a redelivery | the webhook worker (`hooks.worker`), wherever it leads | |
| 178 |
| `proposals` | `proposal {repo, event}` (a check recorded on a state, with the check) | the checks API, Flow's checks | the proposal page's check stream (`…/proposal/{n}/events`) | |
| 179 |
| `live` | `live {topic, kind}` (`repo:1` `push`, `run:5` `jobs`, …) | `hooks::emit` (every webhook event: pushes, proposals, issues, checks, reviews), notifications, Flow job changes, Diffusion bumps, assistants, comments | `/live` streams, through each process's replay buffer (`bus.live`) | |
| 180 |
| `rooms` | `room {room, event}` (a choice, or the landing) | the resolver's live view | the same room's viewers in every process | |
| 181 |
|
| 182 |
- **Payloads** are JSON, `{"o": origin, "e": event}`, well under |
| 183 |
PostgreSQL's 8000 bytes. A check's summary too long for it is cut on |
| 184 |
the way to other processes (the page reads the check for the rest); a |
| 185 |
room choice's own text too long for it stays in its process (logged). |
| 186 |
- **Channels:** one per topic, `oxyhub_<hash>_<topic>`, the hash a |
| 187 |
BLAKE3 of the hub's schema, so two hubs in two schemas of one database |
| 188 |
never hear each other (as the leader locks' keys). |
| 189 |
- **Publishing** delivers to this process's subscribers at once, then |
| 190 |
queues the event for a sender task that calls `pg_notify` through the |
| 191 |
pool, in order. A full queue (4096) drops the event, with a warning. |
| 192 |
- **Listening:** one connection per process, outside the pool, `LISTEN` |
| 193 |
on every topic's channel. What it hears from another process it |
| 194 |
delivers to the local subscribers (and applies to the local resolver |
| 195 |
rooms), never publishing it again. |
| 196 |
- **No loops, no doubles:** every process has a random origin id, sent |
| 197 |
with each event; a process skips events with its own origin, which it |
| 198 |
delivered when it published them. Each subscriber hears an event once. |
| 199 |
- **Reconnecting:** a listening connection that fails, or does not |
| 200 |
answer a `SELECT 1` within 5 s after 5 s of quiet, is dropped and a new |
| 201 |
one listens (every 2 s until one does). The process logs that events |
| 202 |
published meanwhile were missed and nudges its own webhook worker to |
| 203 |
look now. Live pages are not told: the next event on their topics |
| 204 |
refreshes them, and a reload shows everything. |
| 205 |
- **Live pages' numbering** (`Last-Event-ID`) is each process's own: |
| 206 |
every process numbers the events it hears, its own and others', in its |
| 207 |
replay buffer. A page that reconnects through another web node, or after |
| 208 |
a restart, with an id that node never gave is told to reload. |
| 209 |
- **Resolver rooms:** choices and landings reach the room's viewers in |
| 210 |
every process; who is present, and a newcomer's snapshot of earlier |
| 211 |
choices, are each process's own. A choice posted to a process where |
| 212 |
nobody is in the room is still sent to the others but answers `410`, |
| 213 |
so the live view's stream and its posts should reach the same node |
| 214 |
(sticky sessions), as one web process per data directory does today. |
| 215 |
|
| 216 |
## The job queue |
| 217 |
|
| 218 |
A `jobs` table (kind, payload, run after, attempts, claimed by, claimed |
| 219 |
until). Workers claim with `SELECT … FOR UPDATE SKIP LOCKED` on Postgres, |
| 220 |
and with the same table polled under the write lock on SQLite. First |
| 221 |
tenants: webhook deliveries (they already have a table), mail, the Git |
| 222 |
mirror export, the apt rebuild, Diffusion's bumps, `oxy gc`. Runner claims |
| 223 |
stay on their own path (oxygenflow#34 may later move them to the queue). |
| 224 |
|
| 225 |
**Built, first slice (#276):** the `work_queue` table (migration 0091: |
| 226 |
kind, payload, a dedupe key, run after, attempts, last error, claimed by, |
| 227 |
claimed until, dead) and `db::queue` (enqueue, claim, done, failed, |
| 228 |
counts). A claim leases a batch for five minutes (`UPDATE … WHERE id IN |
| 229 |
(SELECT … FOR UPDATE SKIP LOCKED) RETURNING *` on Postgres; under the write |
| 230 |
lock on SQLite), so any number of worker processes take items, and an item |
| 231 |
whose worker died is due again when its lease ends. A failure backs off |
| 232 |
(30 s, doubling, at most an hour); the eighth attempt leaves the item dead, |
| 233 |
kept for a person to read; a kind no worker knows is dead at once. Every |
| 234 |
worker runs `queue.worker` (no leader lock), polling every 2 s. Tenants so |
| 235 |
far: a notification's **email** that failed to send (`mail`), and the |
| 236 |
**Git mirror** export after a push that failed (`mirror.refresh`, one per |
| 237 |
repository); both still try at once and queue only on failure, so the |
| 238 |
usual path is as fast as before. Next: webhook deliveries onto it, the |
| 239 |
apt rebuild, Diffusion's bumps, and a nudge on the event bus. |
| 240 |
|
| 241 |
## The single-node edition |
| 242 |
|
| 243 |
`oxygenhub serve` with no `--role` runs every role, with the in-process |
| 244 |
bus, the SQLite or Postgres database and the local leader: exactly today's |
| 245 |
behaviour. Every slice must keep it working and its tests green. |
| 246 |
|
| 247 |
## Order of work |
| 248 |
|
| 249 |
1. **H2: `--role web,worker`** and the leader lock (Postgres advisory |
| 250 |
locks; always held on SQLite). **Built (#272):** two hubs on one |
| 251 |
Postgres run each singleton on exactly one, and the other takes over |
| 252 |
within seconds when it stops or loses its connection. One web process |
| 253 |
per data directory until step 3. |
| 254 |
2. **H3: the event bus trait**, in process and Postgres `LISTEN/NOTIFY`; |
| 255 |
live pages on one node see a push made through the other. **Built |
| 256 |
(#273):** a push through a web process reaches live pages and the |
| 257 |
webhook worker in another process at once; a process whose listening |
| 258 |
connection is lost listens again on a new one. |
| 259 |
3. The transparency log's appends under a cluster-wide lock (#274), and |
| 260 |
its checkpointer back with the workers. |
| 261 |
4. Autoland and Diffusion under per-proposal advisory locks. |
| 262 |
5. The job queue, webhooks and mail first. |
| 263 |
6. Later: edge and store (the repository service), NATS when measured. |