| 1 |
# Roles: one binary, several processes |
| 2 |
|
| 3 |
Status: design, 2026-10-19 (overnight loop 17). First slices: `--role` |
| 4 |
with a leader lock (H2) and the event bus (H3). |
| 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 | worker | **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(name, task)`: a worker runs a singleton task only while it |
| 89 |
holds a lock named for it. On Postgres, `pg_try_advisory_lock(hash(name))` |
| 90 |
on a dedicated connection, retried every few seconds; if the holder dies, |
| 91 |
its connection closes and the lock frees itself, so another worker takes |
| 92 |
over within seconds. On SQLite (one machine by definition) the lock is |
| 93 |
always held. Each task is written to be safe to run again after a |
| 94 |
failover (they already are: they read their work from the database). |
| 95 |
|
| 96 |
## Event bus: publish and subscribe |
| 97 |
|
| 98 |
A trait with two operations, `publish(topic, payload)` and |
| 99 |
`subscribe(topic) -> Stream`, for the events live pages and workers react |
| 100 |
to: a bookmark moved, a proposal changed, a check recorded, a run or job |
| 101 |
changed state, a delivery is waiting, a live room's choice. |
| 102 |
|
| 103 |
- **In process:** `tokio::sync::broadcast` per topic, what the hub does |
| 104 |
today. The single-node edition keeps it. |
| 105 |
- **Postgres:** `LISTEN`/`NOTIFY` on one channel per topic. A payload is at |
| 106 |
most 8000 bytes, so events carry ids and kinds, and subscribers read the |
| 107 |
rows they need. A node publishes once; every node's subscriber relays |
| 108 |
the event to its local broadcast, so live pages on any node see it. |
| 109 |
- **Later, NATS JetStream** (oxygenhub#208, #209): durable streams, replay |
| 110 |
from a position, and subscribers outside the hub (a product feature: |
| 111 |
"subscribe to this repository's changes" for integrations, beyond |
| 112 |
webhooks). The same trait. |
| 113 |
|
| 114 |
Delivery is at most once; anything that must not be lost is a row in a |
| 115 |
table (the job queue), and the event only says "look now". |
| 116 |
|
| 117 |
## The job queue |
| 118 |
|
| 119 |
A `jobs` table (kind, payload, run after, attempts, claimed by, claimed |
| 120 |
until). Workers claim with `SELECT … FOR UPDATE SKIP LOCKED` on Postgres, |
| 121 |
and with the same table polled under the write lock on SQLite. First |
| 122 |
tenants: webhook deliveries (they already have a table), mail, the Git |
| 123 |
mirror export, the apt rebuild, Diffusion's bumps, `oxy gc`. Runner claims |
| 124 |
stay on their own path (oxygenflow#34 may later move them to the queue). |
| 125 |
|
| 126 |
## The single-node edition |
| 127 |
|
| 128 |
`oxygenhub serve` with no `--role` runs every role, with the in-process |
| 129 |
bus, the SQLite or Postgres database and the local leader: exactly today's |
| 130 |
behaviour. Every slice must keep it working and its tests green. |
| 131 |
|
| 132 |
## Order of work |
| 133 |
|
| 134 |
1. **H2: `--role web,worker`** and the leader lock (Postgres advisory |
| 135 |
locks; always held on SQLite). Two hubs on one Postgres and one data |
| 136 |
directory: pushes through either, the scheduler and the placement pass |
| 137 |
run on exactly one, failover when it stops. |
| 138 |
2. **H3: the event bus trait**, in process and Postgres `LISTEN/NOTIFY`; |
| 139 |
live pages on one node see a push made through the other. |
| 140 |
3. The transparency log's appends under a cluster-wide lock. |
| 141 |
4. Autoland and Diffusion under per-proposal advisory locks. |
| 142 |
5. The job queue, webhooks and mail first. |
| 143 |
6. Later: edge and store (the repository service), NATS when measured. |