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