OXYGENHUB

Roles: how one binary runs as several processes (design) 6b195ed3b637

docs/roles.md: the web and worker roles (edge and store later), an
inventory of every background task and in-process state with what each
needs in a cluster, the leader lock (Postgres advisory locks), the event
bus (in-process and LISTEN/NOTIFY, later JetStream), the job queue, and
the order of work. Refs #272 #273 #274 #275 #276
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 46935af6bff8
When
· 2026-10-19 20:24 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
6b195ed3b63757ceec1100b307cb83c3d991fac806aa6d2cfaad7d4efd4b8725
Depends on
93be9ce4653e
Depended on by
439ecd8708a1 dd327d76062d 356183b71fab ec84907043c2 cb7b3cd45dc0 33d98147511b e57765dee61a
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
1 atom, 1 tree operation, 1 file

Browse the tree at this change

added docs/roles.md +143 −0
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 +
| Role | Runs | Instances |
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 +
| Task | What it does | Role | Singleton? | Needs for several processes |
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 +
| State | Today | In a cluster |
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.