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. |