OXYGENHUB
# e57765dee61a oxygenhub / docs / roles.md
10.4 KiB · Markdown · last changed Run the hub as web and worker processes, singletons under a leader lock 16 days ago renderedsource blame history raw
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
| 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 | 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
| 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(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.