OXYGENHUB
# 4c77a1c8aad8 oxygenhub / docs / roles.md
14.1 KiB · Markdown · last changed Secret scanning: scan existing history, remove an allowed fingerprint 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) and H3 (the event bus, #273) are built; the
5
transparency log's cluster-wide writer (#274) is next.
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
| Role | Runs | Instances |
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
| Task | What it does | Role | Singleton? | Needs for several processes |
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
  Needs a cluster-wide lock (an advisory lock per proposal).
67
- `diffusion::propagate` / `try_land` / `catch_up` / `land_ready` after a
68
  push: idempotent by design, but racing; the same per-proposal lock.
69
- The transparency log's appends in `after_push` ("logged before the push
70
  is answered"): **one writer only**. The log's files live in the data
71
  directory; two processes appending would corrupt them. Needs either an
72
  advisory lock around each append (one machine, shared directory), or a
73
  log role that the web nodes call (a cluster).
74
75
State kept in the process:
76
77
| State | Today | In a cluster |
78
|---|---|---|
79
| state cache, summaries | per process, content-addressed | stays per node (safe: keys are hashes) |
80
| 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 |
81
| Flow placement hints (`CACHES`, `HELD`) | per process | per node is acceptable (a hint); the runner's own claim facts are the truth |
82
| live pages (`events`, `rooms`) | `tokio::sync::broadcast` | **the event bus** (built, #273); a resolver room's presence stays per node |
83
| rate limits (`throttle`) | per process | per node (limits per node), or Postgres later |
84
| sessions, tokens, accounts | the database | unchanged |
85
| keys (`data/keys/`) | files | shared directory or configuration |
86
87
## Leader lock
88
89
`leader::run(app, name, task)` (`crates/hub/src/leader.rs`): a process
90
runs a singleton task only while it holds a lock named for it. On
91
Postgres, `pg_try_advisory_lock(key)`, the key a stable 64-bit hash
92
(BLAKE3) of the hub's schema and the task's name, so two hubs in two
93
schemas of one database never share a lock. Every lock of one process is
94
held on one connection of its own, outside the pool, for the process's
95
life. A process that does not hold a lock asks again every 2 seconds. One
96
that holds it checks the connection every 2 seconds (one `SELECT 1`
97
answers for every task); if it has gone, the server has freed its locks,
98
so each task it led is stopped (its future dropped) and the process
99
contends again on a new connection. A process that dies closes its
100
connection and frees its locks; another takes over within seconds (2 s in
101
the test). A holder whose connection drops can overlap its successor for
102
at most those 2 seconds, which the tasks tolerate. On SQLite (one machine
103
by definition) the lock is always held. Each task is safe to run again
104
after a failover: they read their work from the database. A task that
105
ends by itself (`diffusion.backfill`) gives its lock back; the next
106
worker to take it runs it again, harmlessly.
107
108
The locks: `hooks.worker`, `flow.reaper`, `flow.scheduler`,
109
`flow.placement`, `maintenance.nightly`, `backups.scheduler`,
110
`diffusion.backfill`, `diffusion.reconciler`, `secrets.history_scan`
111
(worker role) and `tlog.checkpointer` (web role, below).
112
`images.learn_archs` runs in every worker, without a lock.
113
114
`/health` says what the process does and which locks it holds:
115
`"roles": ["web", "worker"], "leading": ["backups.scheduler", …]`.
116
117
## Running the roles apart (H2)
118
119
`oxygenhub serve --role web,worker` (or `[server] roles` in `hub.toml`,
120
`OXYHUB_SERVER_ROLES`); every role when none is given. An unknown role is
121
an error.
122
123
- **web** serves the whole router. It also owns the transparency log: it
124
  appends to it (`after_push`, kept receipts and approvals) and runs its
125
  checkpointer, under the `tlog.checkpointer` lock. The checkpointer moved
126
  from the worker's list above because the log's state is in the process
127
  that appends: a checkpointer elsewhere would sign a log it never sees
128
  grow, and the web process would have no checkpoint to serve.
129
- **worker** starts the background tasks, and serves only `/health` and
130
  `/metrics` (when `metrics_token` is set) on its bind address, so it can
131
  be watched; with no bind address it serves nothing. It opens the
132
  transparency log to read only: it never cuts short an append the web
133
  process is making.
134
- **Until the log has a cluster-wide writer (#274), only one process per
135
  data directory may have the web role.** Any number may be workers.
136
  Webhook nudges travel on the event bus (below, #273), so a separate
137
  worker delivers a push's webhooks at once, not on its once-a-minute
138
  pass.
139
140
## Event bus: publish and subscribe
141
142
A trait with two operations, `publish(topic, payload)` and
143
`subscribe(topic) -> Stream`, for the events live pages and workers react
144
to: a bookmark moved, a proposal changed, a check recorded, a run or job
145
changed state, a delivery is waiting, a live room's choice.
146
147
- **In process:** `tokio::sync::broadcast` per topic, what the hub does
148
  today. The single-node edition keeps it.
149
- **Postgres:** `LISTEN`/`NOTIFY` on one channel per topic. A payload is at
150
  most 8000 bytes, so events carry ids and kinds, and subscribers read the
151
  rows they need. A node publishes once; every node's subscriber relays
152
  the event to its local broadcast, so live pages on any node see it.
153
- **Later, NATS JetStream** (oxygenhub#208, #209): durable streams, replay
154
  from a position, and subscribers outside the hub (a product feature:
155
  "subscribe to this repository's changes" for integrations, beyond
156
  webhooks). The same trait.
157
158
Delivery is at most once; anything that must not be lost is a row in a
159
table (the job queue), and the event only says "look now".
160
161
### Built (H3, #273)
162
163
`crates/hub/src/bus.rs`: `app.bus.publish(event)` and
164
`app.bus.subscribe(topic)`, a `tokio::sync::broadcast::Receiver<Event>`.
165
Nothing to configure: a hub built for PostgreSQL carries events between
166
its processes over `LISTEN/NOTIFY`; on SQLite (one process) the bus is
167
in process only.
168
169
| Topic | Event | Published by | Heard by |
170
|---|---|---|---|
171
| `deliveries` | `delivery_waiting` | `hooks::emit` after queueing deliveries, a ping, a redelivery | the webhook worker (`hooks.worker`), wherever it leads |
172
| `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`) |
173
| `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`) |
174
| `rooms` | `room {room, event}` (a choice, or the landing) | the resolver's live view | the same room's viewers in every process |
175
176
- **Payloads** are JSON, `{"o": origin, "e": event}`, well under
177
  PostgreSQL's 8000 bytes. A check's summary too long for it is cut on
178
  the way to other processes (the page reads the check for the rest); a
179
  room choice's own text too long for it stays in its process (logged).
180
- **Channels:** one per topic, `oxyhub_<hash>_<topic>`, the hash a
181
  BLAKE3 of the hub's schema, so two hubs in two schemas of one database
182
  never hear each other (as the leader locks' keys).
183
- **Publishing** delivers to this process's subscribers at once, then
184
  queues the event for a sender task that calls `pg_notify` through the
185
  pool, in order. A full queue (4096) drops the event, with a warning.
186
- **Listening:** one connection per process, outside the pool, `LISTEN`
187
  on every topic's channel. What it hears from another process it
188
  delivers to the local subscribers (and applies to the local resolver
189
  rooms), never publishing it again.
190
- **No loops, no doubles:** every process has a random origin id, sent
191
  with each event; a process skips events with its own origin, which it
192
  delivered when it published them. Each subscriber hears an event once.
193
- **Reconnecting:** a listening connection that fails, or does not
194
  answer a `SELECT 1` within 5 s after 5 s of quiet, is dropped and a new
195
  one listens (every 2 s until one does). The process logs that events
196
  published meanwhile were missed and nudges its own webhook worker to
197
  look now. Live pages are not told: the next event on their topics
198
  refreshes them, and a reload shows everything.
199
- **Live pages' numbering** (`Last-Event-ID`) is each process's own:
200
  every process numbers the events it hears, its own and others', in its
201
  replay buffer. A page that reconnects through another web node, or after
202
  a restart, with an id that node never gave is told to reload.
203
- **Resolver rooms:** choices and landings reach the room's viewers in
204
  every process; who is present, and a newcomer's snapshot of earlier
205
  choices, are each process's own. A choice posted to a process where
206
  nobody is in the room is still sent to the others but answers `410`,
207
  so the live view's stream and its posts should reach the same node
208
  (sticky sessions), as one web process per data directory does today.
209
210
## The job queue
211
212
A `jobs` table (kind, payload, run after, attempts, claimed by, claimed
213
until). Workers claim with `SELECT … FOR UPDATE SKIP LOCKED` on Postgres,
214
and with the same table polled under the write lock on SQLite. First
215
tenants: webhook deliveries (they already have a table), mail, the Git
216
mirror export, the apt rebuild, Diffusion's bumps, `oxy gc`. Runner claims
217
stay on their own path (oxygenflow#34 may later move them to the queue).
218
219
## The single-node edition
220
221
`oxygenhub serve` with no `--role` runs every role, with the in-process
222
bus, the SQLite or Postgres database and the local leader: exactly today's
223
behaviour. Every slice must keep it working and its tests green.
224
225
## Order of work
226
227
1. **H2: `--role web,worker`** and the leader lock (Postgres advisory
228
   locks; always held on SQLite). **Built (#272):** two hubs on one
229
   Postgres run each singleton on exactly one, and the other takes over
230
   within seconds when it stops or loses its connection. One web process
231
   per data directory until step 3.
232
2. **H3: the event bus trait**, in process and Postgres `LISTEN/NOTIFY`;
233
   live pages on one node see a push made through the other. **Built
234
   (#273):** a push through a web process reaches live pages and the
235
   webhook worker in another process at once; a process whose listening
236
   connection is lost listens again on a new one.
237
3. The transparency log's appends under a cluster-wide lock (#274), and
238
   its checkpointer back with the workers.
239
4. Autoland and Diffusion under per-proposal advisory locks.
240
5. The job queue, webhooks and mail first.
241
6. Later: edge and store (the repository service), NATS when measured.