M7-17: Multi-node operation (level 2)¶
Status: ready | Depends on: M1-72, M2-10, M2-47, M3-23 | Milestone: M7
Goal¶
After this ticket an instance runs with several api nodes behind a proxy and a separate worker (02 16 level 2, OQ-07): with cluster.mode = multi the channel NOTIFY ephemeral carries the ephemeral events of 02 11.6 between nodes (typing, run.updated by ID and version, agent presence, the replacement of a device connection, notification.created and command.autocomplete_requested by ID), the agent consumers are fenced through agent_consumer_cursors.holder_session_id so that a second identify of the same consumer on another node closes the older connection with 4015 and no run is executed twice, a second identify of the same device on another node closes the older connection with 4017, and all nodes share one signing key store (a shared volume or a writable driver per target, ADR 0066). The worker gets what M3-23 left for this ticket: the shared presence state in the table gateway_presence (R-DM-51), which every api process in role api writes and the consumer push reads through presence.View, and the path of notification.created from the worker to the api nodes, so desktop hints work with separate roles and several api nodes (13 12 point 4, RK-37, D-W-108). Typing and the live box reach clients on another node within perf.fanout_cross_node_p99. Why: exit criterion 5 of M7; a rolling update without downtime and a node that reaches perf.ws_connections_per_node need more than one api node, and every per-process state that M1 to M6 kept in memory must work across nodes or fail safe.
Read first¶
docs/spec/02-architecture.md: 3.2 (roles), 11.5, 11.6 (ephemeral events table,NOTIFYonly as a wake-up without content), 15.3, 16 (levels, "Multiple nodes in detail"), 17 (perf.fanout_cross_node_p99,perf.ws_connections_per_node).docs/spec/03-data-model.md(core contract, read only):agent_consumer_cursors(holder_session_id, the guarded update),gateway_presence(R-DM-51), 6.6 (NOTIFY).docs/spec/04-sync-protocol.md(core contract, read only): 6.1 (4017), 6.4 (close codes4015,4017), 15 (agent connections, 15.1 fencing, 15.3).docs/spec/13-push-notifications.md4.4, 4.6, 12 point 4;docs/spec/16-operations.md5.2 (secrets.signing_key_dir), 13, 14, R-OP-19;docs/adr/0066-signing-keys-in-the-writable-key-store.md;docs/spec/24-terminal.md9 (relay across nodes, already built by M2-47).docs/RISKS.mdRK-37,docs/OPEN-QUESTIONS.mdOQ-07,docs/operations/configuration.md(cluster.mode).docs/DECISIONS.md: D-W-100 and its extension to M7 (D-W-125), R-DM-51 (D-W-128), D-W-108; section 10 (format ofD-M7-17-<n>rows).- Briefs (merged names win, D-W-100):
M1-65-gateway-delivery.md(log tail andNOTIFY),M1-66-gateway-focus-typing.md,M2-07-agent-consumers.md(consumer sessions),M2-10-run-document-live-box.md(run.updatedcoalescing),M2-47-terminal-multi-node.md(hash routing, the two-node test harness),M3-23-desktop-notices.md(presence.View,LiveRoutes, the role wiring and "A shared presence state ... belong to M7-17"),M1-32-signing-keys.md,M1-72-performance-gate.md(make perf). - Code on the base branch:
services/server/internal/gateway,internal/runs,internal/agents,internal/notify,internal/kernel/presence,internal/instance(signing keys),internal/wire,perf/.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names. - Intervals and limits only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the value. - Specification first: a change to behaviour or configuration updates
docs/spec/02-architecture.md,13-push-notifications.mdor16-operations.mdin the same change. The core contract is not edited by this ticket;gateway_presenceis built exactly as 03 has it. Everything this ticket adds is English. - No cost (D-W-125).
Product requirements implemented here:
NOTIFYcarries only IDs and versions, never content (02 11.6); the channelephemeralis active only withcluster.mode = multi.- Fencing: a node that loses the cursor row (the guarded
UPDATE ... WHERE holder_session_id = $own_sessionaffects no row) closes its consumer connection with4015before it delivers another event; run starts stay compare-and-swap (04 15.3); a run is never executed twice. - A device holds one connection across all nodes: a newer
identifyorresumecloses the older one with4017on whichever node holds it. gateway_presenceholds only counted connections of humans in phaselive(M3-23 Design note 2), is written by processes in roleapi(anycluster.mode) and by none in roleallwithcluster.mode = single, and rows of a node that stopped refreshing are ignored aftercluster.presence_ttl. Roleallwithcluster.mode = multiwrites the table like roleapi.- The worker's
presence.Viewreadsgateway_presencethrough a reader ofgateway, the owner of the table (03 principle 12, ADR 0050); with it the decision yieldsgatewayandpush_delayedexactly as in roleall;notification.createdreaches the node that holds the connection throughNOTIFY ephemeralwith IDs, and that node sends the hint after loading it. - All nodes use one signing key store; a key rotated on one node is accepted by every node within
signing_key.cache_ttl.
Scope¶
Server module (paths under services/server/ unless they start with docs/; every new Go file starts with // SPDX-License-Identifier: Apache-2.0):
- Migrations:
agent_consumer_cursors.holder_session_idif M2-07 did not create it, and the tablegateway_presenceexactly as 03 (R-DM-51), with the grants of 16 7.1 rule 3. internal/gateway: publisher and listener ofephemeralfor every row of 02 11.6;4017across nodes; the writer ofgateway_presence(Design note 2); the receiver ofnotification.createdby ID.internal/agentsandinternal/runs: fencing withholder_session_idand the4015close.internal/gateway: the reader ofgateway_presencebehind a port;gateway_presencehas the retention classtransient: where the retention coverage test of M7-05 exists on the base branch, register a participant that deletes rows older thancluster.presence_ttl.internal/notify: the table-backedpresence.Viewfor the worker uses the port ofgateway;LiveRoutesin roleworkersends throughNOTIFY ephemeralinstead of the fail-closed default of M3-23.internal/wire: role and mode wiring (Design note 3).- Signing keys:
secrets.signing_key_dirper target documented (shared volume in Compose); a test with two processes on one key store. - Configuration:
cluster.modebecomes active; new keyscluster.presence_flush_intervalandcluster.presence_ttl(02 and the reference). - Tests: a two-node harness (two server processes with role
api, oneworker, one database) and the perf testperf/gateway_fanout_multi. - Docs:
docs/spec/02-architecture.md11.6 and 16 as built,docs/spec/13-push-notifications.md12 point 4,docs/spec/16-operations.md(several api nodes: proxy, sticky sessions not needed, the key store, rate limits per node),docs/RISKS.mdRK-37 (mitigation as built),docs/operations/guide for several nodes, READMEs,services/server/CHANGELOG.mdunder[Unreleased],docs/DECISIONS.mdsection 10 rowsD-M7-17-<n>.
Out of scope¶
- Level 3 and 4 of 02 16 (several workers already work through River; read replicas); shared rate limit counters (02 16: per node by design).
- The terminal relay across nodes (M2-47).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M7-17-multi-node; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(gateway): ...,feat(notify): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag, never publish an image or a release, never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules). Tests bind only
127.0.0.1and port 0; PostgreSQL only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets.
- Time only through
kernel/clockin unit tests; the two-node tests use real time only for the latency measurement againstperf.fanout_cross_node_p99. - Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M7 by D-W-125). Where the merged code of an earlier ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Ephemeral channel (02 11.6). One
LISTENconnection per node; payloads are<kind>:<id>[:<version>]; the receiver loads whatever it needs from the database (run document, notification, device session) and hands it to its hub; its own publications are ignored by a node ID in the payload. Payload size and rate stay within theNOTIFYlimits; a test sends the maximum burst of typing events ofsync.typing_min_interval. -
Presence writer (R-DM-51, 13 4.4; Ruling for docs/DECISIONS.md). On a connection entering
live, on focus or idle changes and on close the node upserts or deletes its row in batches at most everycluster.presence_flush_interval, and at the same interval upserts the rows of all its live connections with a freshupdated_at(an upsert, not an update, so rows lost with theUNLOGGEDtable after a database crash come back within one interval; a test clears the table and expects the rows back); on a clean shutdown it deletes its rows; the reader ignores rows older thancluster.presence_ttl. A failed write is logged and counted and never blocks a connection. -
Wiring (M3-23 Design note 1). Role
allwithsingle: in-memorypresence.View, no table writes, noNOTIFY; roleapi: in-memory view for its own decisions plus the table writer; roleworker: table-backed view andNOTIFY-based live routes;multiturns on the ephemeral channel for every role. -
Rulings for
docs/DECISIONS.md(D-M7-17-<n>): payload format (note 1), presence writes and TTL (note 2), wiring (note 3), the shared key store per target.
Tests (write them first)¶
Unit:
gateway: payload encode and decode; own publications ignored.notify: the table-backedpresence.Viewreturns the same entries as the in-memory one for the same connections (table test).
Integration (two api processes and one worker on one pgtest database):
TestTypingAndLiveBoxCrossNode: typing and a run patch from a client on node A reach a client on node B; latency recorded.TestConsumerFencing4015: a secondidentifyof the same consumer on node B closes node A's connection with4015; no event is delivered twice and no run starts twice.TestDeviceReplacement4017: a secondidentifyof the same device on node B closes the older connection on node A with4017.TestDesktopHintWithSeparateWorker: a person with a live desktop connection on node B gets the hint through the gateway, and the mobile push is delayed exactly as in roleall; with the desktop idle the push goes at once.TestPresenceTTL: rows of a killed node are ignored aftercluster.presence_ttl.TestSharedKeyStore: a key rotated by node A is accepted by node B withinsigning_key.cache_ttl.
Performance:
perf/gateway_fanout_multiinmake perfagainstperf.fanout_cross_node_p99.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/... ./cmd/...make perf(the multi-node target; record the profile used)make migrations-lint,make migrations-lock,make codegen-check,make arch,make register-check,make config-doc-check,make docs,make linkcheckmake check
Acceptance criteria¶
- Two api nodes: typing and the live box reach clients on the other node within
perf.fanout_cross_node_p99. - A second
identifyof the same consumer closes with4015; a secondidentifyof the same device on another node closes the older connection with4017; no duplicate run execution. - Desktop hints work with a separate worker and several api nodes through
gateway_presenceandNOTIFY ephemeral. - One shared key store for all nodes is documented per target and tested.
- 02, 13, 16, RK-37, the guide, READMEs, CHANGELOG and the rulings are updated.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
gateway,notify,agentsupdated in English. - Rulings of Design note 4 recorded in
docs/DECISIONS.mdsection 10. services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make checkand ofmake perfwith the measured cross-node p99. - Review and red team verdicts and the findings fixed.
- The write rate of
gateway_presenceunder the perf load. - What is not proven (behaviour behind a real load balancer, more than two api nodes).
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.