# Secondary Channel (commander ↔ worker) **Version**: 1.0.0 **Status**: SOURCE OF TRUTH **Last Updated**: 2026-07-10 --- ## Overview The multi-worker model has two rails: - **Primary channel**: browser → server → commander → worker. The request path. Plain HTTP: the commander forwards with an httpx client (`WorkerForwardClient`) and relays the response untouched. - **Secondary channel**: commander ↔ worker. The pool socket (`executors/pool/`: wsx envelopes over UDS or TCP, FIFO per connection, EOF = death, `tytx` typing available). It carries the WHOLE commander/worker conversation: page lifecycle, datachanges that leave the worker, the global store, occupancy metrics, user moves. The single-worker deployment has no channel: `SpaSingleWorkerApplication` is its own commander in-process, same events, no transport. ## Ascending rail (worker → commander) The worker pushes spontaneously, fire-and-forget. What goes up is only what concerns a page that might live elsewhere, or the global view only the commander has. Everything page-local stays on the worker. | Path | Payload | Purpose | |---|---|---| | `/events` | batch of shaped events (`op` taxonomy: `LIFECYCLE_OPS`, `STORE_OPS`, `POST_OPS`, `EXCHANGE_OPS`) | lifecycle (`new_*`/`drop_*`/`change_connection_user` with `xgroup`), `subscribeTable`, `store_set/del`, `notifyDbEvents`, datachanges for pages owned by other workers, the user package of a move | | `/occupancy` | `occupancy_report()` — RAW sensor readings, fixed shape (see [11-elastic-pool.md](11-elastic-pool.md)) | periodic timer push; the commander archives it per worker | | `/announce_http` | host/port | routable = REGISTER + announce | Events are born in executor threads; the `Outbox` buffers thread→loop and a single sender task on the worker loop ships batches immediately (FIFO: one sender, one connection). ## Descending rail (commander → worker) | Path | Purpose | |---|---| | `/update_global` | push a global-store write to every replica | | `/store_snapshot` | seed a late worker's replica atomically at announce | | `/datachange_in` | deliver forwarded datachanges/dbevents to the owner worker (deposited in the target page's pending list) | | `/evict_user` | commanded move: the source snapshots the user, drops the slice, ships the package up | | `/install_user` | move destination: install the package, confirm | ## The switch model (datachanges) The commander is an **event switch**, not a warehouse: it receives, resolves the owner from the surface, forwards immediately. The only queue is the **pending list inside the page item** on the owner worker (local deposits are the common case; commander forwards are the rest). At render the page drains its pending and merges it with its `local_datachanges`, ordered by `change_ts` (the producer's stamp). DB events are two-tier: the committing worker fans out LOCALLY to its own subscribed pages (zero hops), then sends `notifyDbEvents` up; the commander fans out to the OTHER workers' subscribed pages only (never back to the sender). Table subscriptions live in the page item — they die with the page and travel with the move package. ## Login and user move The move is a message flow on the channel, with the user package inside: 1. At login the source worker compares the avatar's `xgroup` with its own group: different → it deletes the user from its registers and ships the package (user + connections + pages + pending) on the channel; same or absent → it keeps the user. 2. The response carries one **lean login header** (scalars only: user, xgroup). It is a synchronization barrier, not an event transport: the commander picks the destination BEFORE releasing the response and writes `user → destination` with a **not-ready flag**. The browser cannot send the next request before receiving this response — by then the flag is set. 3. The package travels source → commander (held only in the in-flight per-user state machine) → destination. 4. The destination installs and confirms; the flag drops. 5. A request for a not-ready user WAITS on the flag (`await_ready`), then goes to the destination. No locks: the gate is data. 6. A commanded move (rebalance, tomorrow consolidation) is the same flow with a different trigger: the commander sets not-ready and sends `/evict_user`. The **sticky cookie** (`sticky_cid`) is minted by the WORKER in its own response; the commander is a pure relay and learns the connection from the channel event moments later. An unknown cid lands on the welcome worker — coherent, guests live there. ## What is NOT here - No piggyback event transport, no ping-drain, no acks: on the channel, loss ⟺ death (EOF → sweep). Liveness is the channel's native ws ping/pong. - No reconciliation (anti-entropy): fail-loud. `full_state` survives as a diagnostic endpoint only. - No commander-side datachange store: the pull RPC and the mailbox do not exist; a page reads its pending locally on its worker. - HTTP survives only as: the primary channel (forward), `/_commander/*` service endpoints (`ping`, `population`). ## Design history The full design discussion (decisions, dissolved mechanisms and why each could go) is preserved in `temp/secondary_channel_design.md` (ratified 2026-07-10).