| 1 |
# Roles: one binary, several processes |
| 2 |
|
| 3 |
Status: design, 2026-10-19 (overnight loop 17). H2 (`--role` and the |
| 4 |
leader lock, #272) is built; the event bus (H3) is next. |
| 5 |
|
| 6 |
OxygenHub is one process today: pages, API, the `oxy` wire protocol, Flow's |
| 7 |
scheduler and every background task share one runtime and one data |
| 8 |
directory. That is the single-node edition, and it stays one command. This |
| 9 |
document says how the same binary runs as several processes, each taking |
| 10 |
some of the work (its **roles**), so the web tier can scale out and the |
| 11 |
background work runs exactly once, without a second product to maintain. |
| 12 |
|
| 13 |
## Principles |
| 14 |
|
| 15 |
1. **One binary, many roles.** `oxygenhub serve --role web,worker` (all |
| 16 |
roles when none is given). A cluster runs the roles apart; a small site |
| 17 |
runs them together. |
| 18 |
2. **Seams before services.** Each piece of shared state goes behind a |
| 19 |
trait inside the process first, with today's behaviour as one |
| 20 |
implementation. Splitting into processes is then a deployment choice. |
| 21 |
3. **Postgres before new infrastructure.** Leader election (advisory |
| 22 |
locks), the job queue (`FOR UPDATE SKIP LOCKED`) and the event bus |
| 23 |
(`LISTEN/NOTIFY`) run on the Postgres the hub already uses (#211). NATS |
| 24 |
or JetStream come only when a measurement asks for them. |
| 25 |
4. **Lean on Oxygen's model.** Changes are immutable and content |
| 26 |
addressed; only bookmark and tag moves are mutable, and they move by |
| 27 |
compare-and-swap of the operation head. Two processes writing one |
| 28 |
repository are already safe at the storage layer: that is what makes |
| 29 |
web nodes cheap to add. |
| 30 |
|
| 31 |
## The roles |
| 32 |
|
| 33 |
|
| 34 |
|
| 35 |
| **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 | |
| 36 |
| **worker** | the background tasks below; one instance of each singleton at a time, chosen by a leader lock | one or more | |
| 37 |
| **edge** (later) | the wire protocol and large uploads, routed to the node that holds a repository | many | |
| 38 |
| **store** (later) | repositories on its disks, behind a service interface | several, by repository | |
| 39 |
|
| 40 |
Tonight's slices are **web** and **worker**. Edge and store are the |
| 41 |
repository service (a later phase): until then every node reaches every |
| 42 |
repository either through one shared data directory (one machine) or |
| 43 |
through bucket-backed repositories (oxygenhub#96), which any node can open. |
| 44 |
|
| 45 |
## What runs where today, and what each needs |
| 46 |
|
| 47 |
Background tasks started in `lib.rs::open`: |
| 48 |
|
| 49 |
|
| 50 |
|
| 51 |
| `hooks::worker` | delivers webhooks from the `deliveries` table, nudged by `events.deliveries` | worker | yes today; many once deliveries are claimed with SKIP LOCKED | the nudge over the event bus | |
| 52 |
| `tlog::checkpointer` | signs checkpoints, gathers witness cosignatures | web, beside the log's appends, until #274 | **yes** | the log's single writer (below) | |
| 53 |
| `flow::reaper` | ends jobs whose runner stopped reporting | worker | yes | leader lock | |
| 54 |
| `flow::scheduler` | starts `schedule` runs on their cron lines | worker | **yes** (two would start each run twice) | leader lock | |
| 55 |
| `flow::placement` | the placement pass: why each job waits | worker | yes | leader lock | |
| 56 |
| `maintenance::nightly` | nightly upkeep (gc, prunes) | worker | yes | leader lock | |
| 57 |
| `backups::scheduler` | scheduled backups | worker | **yes** | leader lock | |
| 58 |
| `diffusion::backfill`, `diffusion::reconciler` | the pin index and the bumps it drives | worker | yes | leader lock | |
| 59 |
| `images::learn_archs` | reads kept images' architectures once | worker | no (idempotent) | none | |
| 60 |
|
| 61 |
Work spawned by requests (runs on whichever web node took the request): |
| 62 |
|
| 63 |
- `autoland::on_check_task` after a check is recorded: serialised today by |
| 64 |
a process-wide `TURN` mutex, so two nodes could land one proposal twice. |
| 65 |
Needs a cluster-wide lock (an advisory lock per proposal). |
| 66 |
- `diffusion::propagate` / `try_land` / `catch_up` / `land_ready` after a |
| 67 |
push: idempotent by design, but racing; the same per-proposal lock. |
| 68 |
- The transparency log's appends in `after_push` ("logged before the push |
| 69 |
is answered"): **one writer only**. The log's files live in the data |
| 70 |
directory; two processes appending would corrupt them. Needs either an |
| 71 |
advisory lock around each append (one machine, shared directory), or a |
| 72 |
log role that the web nodes call (a cluster). |
| 73 |
|
| 74 |
State kept in the process: |
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
| state cache, summaries | per process, content-addressed | stays per node (safe: keys are hashes) | |
| 79 |
| 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 | |
| 80 |
| Flow placement hints (`CACHES`, `HELD`) | per process | per node is acceptable (a hint); the runner's own claim facts are the truth | |
| 81 |
| live pages (`events`, `rooms`) | `tokio::sync::broadcast` | **the event bus** | |
| 82 |
| rate limits (`throttle`) | per process | per node (limits per node), or Postgres later | |
| 83 |
| sessions, tokens, accounts | the database | unchanged | |
| 84 |
| keys (`data/keys/`) | files | shared directory or configuration | |
| 85 |
|
| 86 |
## Leader lock |
| 87 |
|
| 88 |
`leader::run(app, name, task)` (`crates/hub/src/leader.rs`): a process |
| 89 |
runs a singleton task only while it holds a lock named for it. On |
| 90 |
Postgres, `pg_try_advisory_lock(key)`, the key a stable 64-bit hash |
| 91 |
(BLAKE3) of the hub's schema and the task's name, so two hubs in two |
| 92 |
schemas of one database never share a lock. Every lock of one process is |
| 93 |
held on one connection of its own, outside the pool, for the process's |
| 94 |
life. A process that does not hold a lock asks again every 2 seconds. One |
| 95 |
that holds it checks the connection every 2 seconds (one `SELECT 1` |
| 96 |
answers for every task); if it has gone, the server has freed its locks, |
| 97 |
so each task it led is stopped (its future dropped) and the process |
| 98 |
contends again on a new connection. A process that dies closes its |
| 99 |
connection and frees its locks; another takes over within seconds (2 s in |
| 100 |
the test). A holder whose connection drops can overlap its successor for |
| 101 |
at most those 2 seconds, which the tasks tolerate. On SQLite (one machine |
| 102 |
by definition) the lock is always held. Each task is safe to run again |
| 103 |
after a failover: they read their work from the database. A task that |
| 104 |
ends by itself (`diffusion.backfill`) gives its lock back; the next |
| 105 |
worker to take it runs it again, harmlessly. |
| 106 |
|
| 107 |
The locks: `hooks.worker`, `flow.reaper`, `flow.scheduler`, |
| 108 |
`flow.placement`, `maintenance.nightly`, `backups.scheduler`, |
| 109 |
`diffusion.backfill`, `diffusion.reconciler` (worker role) and |
| 110 |
`tlog.checkpointer` (web role, below). `images.learn_archs` runs in every |
| 111 |
worker, without a lock. |
| 112 |
|
| 113 |
`/health` says what the process does and which locks it holds: |
| 114 |
`"roles": ["web", "worker"], "leading": ["backups.scheduler", …]`. |
| 115 |
|
| 116 |
## Running the roles apart (H2) |
| 117 |
|
| 118 |
`oxygenhub serve --role web,worker` (or `[server] roles` in `hub.toml`, |
| 119 |
`OXYHUB_SERVER_ROLES`); every role when none is given. An unknown role is |
| 120 |
an error. |
| 121 |
|
| 122 |
- **web** serves the whole router. It also owns the transparency log: it |
| 123 |
appends to it (`after_push`, kept receipts and approvals) and runs its |
| 124 |
checkpointer, under the `tlog.checkpointer` lock. The checkpointer moved |
| 125 |
from the worker's list above because the log's state is in the process |
| 126 |
that appends: a checkpointer elsewhere would sign a log it never sees |
| 127 |
grow, and the web process would have no checkpoint to serve. |
| 128 |
- **worker** starts the background tasks, and serves only `/health` and |
| 129 |
`/metrics` (when `metrics_token` is set) on its bind address, so it can |
| 130 |
be watched; with no bind address it serves nothing. It opens the |
| 131 |
transparency log to read only: it never cuts short an append the web |
| 132 |
process is making. |
| 133 |
- **Until the log has a cluster-wide writer (#274), only one process per |
| 134 |
data directory may have the web role.** Any number may be workers. |
| 135 |
Webhook nudges from a push reach only the process that took it, so a |
| 136 |
separate worker delivers on its once-a-minute pass until the event bus |
| 137 |
(H3) carries the nudge. |
| 138 |
|
| 139 |
## Event bus: publish and subscribe |
| 140 |
|
| 141 |
A trait with two operations, `publish(topic, payload)` and |
| 142 |
`subscribe(topic) -> Stream`, for the events live pages and workers react |
| 143 |
to: a bookmark moved, a proposal changed, a check recorded, a run or job |
| 144 |
changed state, a delivery is waiting, a live room's choice. |
| 145 |
|
| 146 |
- **In process:** `tokio::sync::broadcast` per topic, what the hub does |
| 147 |
today. The single-node edition keeps it. |
| 148 |
- **Postgres:** `LISTEN`/`NOTIFY` on one channel per topic. A payload is at |
| 149 |
most 8000 bytes, so events carry ids and kinds, and subscribers read the |
| 150 |
rows they need. A node publishes once; every node's subscriber relays |
| 151 |
the event to its local broadcast, so live pages on any node see it. |
| 152 |
- **Later, NATS JetStream** (oxygenhub#208, #209): durable streams, replay |
| 153 |
from a position, and subscribers outside the hub (a product feature: |
| 154 |
"subscribe to this repository's changes" for integrations, beyond |
| 155 |
webhooks). The same trait. |
| 156 |
|
| 157 |
Delivery is at most once; anything that must not be lost is a row in a |
| 158 |
table (the job queue), and the event only says "look now". |
| 159 |
|
| 160 |
## The job queue |
| 161 |
|
| 162 |
A `jobs` table (kind, payload, run after, attempts, claimed by, claimed |
| 163 |
until). Workers claim with `SELECT … FOR UPDATE SKIP LOCKED` on Postgres, |
| 164 |
and with the same table polled under the write lock on SQLite. First |
| 165 |
tenants: webhook deliveries (they already have a table), mail, the Git |
| 166 |
mirror export, the apt rebuild, Diffusion's bumps, `oxy gc`. Runner claims |
| 167 |
stay on their own path (oxygenflow#34 may later move them to the queue). |
| 168 |
|
| 169 |
## The single-node edition |
| 170 |
|
| 171 |
`oxygenhub serve` with no `--role` runs every role, with the in-process |
| 172 |
bus, the SQLite or Postgres database and the local leader: exactly today's |
| 173 |
behaviour. Every slice must keep it working and its tests green. |
| 174 |
|
| 175 |
## Order of work |
| 176 |
|
| 177 |
1. **H2: `--role web,worker`** and the leader lock (Postgres advisory |
| 178 |
locks; always held on SQLite). **Built (#272):** two hubs on one |
| 179 |
Postgres run each singleton on exactly one, and the other takes over |
| 180 |
within seconds when it stops or loses its connection. One web process |
| 181 |
per data directory until step 3. |
| 182 |
2. **H3: the event bus trait**, in process and Postgres `LISTEN/NOTIFY`; |
| 183 |
live pages on one node see a push made through the other. |
| 184 |
3. The transparency log's appends under a cluster-wide lock (#274), and |
| 185 |
its checkpointer back with the workers. |
| 186 |
4. Autoland and Diffusion under per-proposal advisory locks. |
| 187 |
5. The job queue, webhooks and mail first. |
| 188 |
6. Later: edge and store (the repository service), NATS when measured. |