Multi-instance job control: route reconnects to the owner, cancel across machines - #4715
Merged
Merged
Conversation
…oss machines The job-run resilience shipped in #4713 is per-process: a run's replay buffer and control hooks live in the one server executing it. Behind a load balancer a reconnecting client lands on a random instance, so resume silently degrades and cancel misses. Two mechanisms fix that, both inert when no instance id is configured (NODETOOL_INSTANCE_ID, falling back to Fly's FLY_MACHINE_ID): - Owner-aware routing. The executing instance stamps its id on the Job row (new nullable runner_instance column, both dialects + migration). A reconnecting client appends resume_job=<id> to the handshake URL; an instance that does not own that still-running job answers the upgrade with `fly-replay: instance=<owner>` so Fly's proxy re-issues the handshake at the owner. A replayed request (fly-replay-src) is never replayed again, the stored owner id is validated before touching response bytes, the refused socket is destroyed on a timer if the peer never closes, and the client retires the hint after two consecutive failed connects so a dead owner cannot lock the shared socket out. - Cross-instance cancel. cancel_job/stop for a run stamped by another instance marks the row cancelled with a conditional update (only while non-terminal — it cannot clobber the owner's completed outcome) and publishes on a control bus: Postgres LISTEN/NOTIFY on nodetool_job_control, in-process emitter under SQLite. LISTEN needs a session-pooled/direct connection, so the listener opens its own client from NODETOOL_JOB_CONTROL_DATABASE_URL / DIRECT_URL / DATABASE_DIRECT_URL; and because a pooler can eat notifications silently, each instance also polls its own running sessions' rows (NODETOOL_JOB_CANCEL_POLL_MS, default 15s) and cancels any that read cancelled — the bus makes cancels immediate, the poll makes them certain. Rows with no runner_instance (HTTP/trigger/MCP runs, which hold no session anywhere) keep the old not-found answer. The web WebSocketManager gains a per-connect urlProvider (with a generation guard so teardown during URL resolution cannot orphan a socket); GlobalWebSocketManager builds the resume hint from the running workflow stores. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P6LtxKGYqd7tnK9MdopkHb
Cross-instance cancel now travels through the job row alone: the requesting instance flips a foreign-stamped, non-terminal row with the conditional update, and the owner's poller (NODETOOL_JOB_CANCEL_POLL_MS, default 15s) picks it up and cancels the local session. That trades up to one poll interval of latency for having exactly one signal — the durable one — and removes the direct-connection requirement the LISTEN path dragged in (Supabase's transaction pooler never delivers notifications). Same-machine cancels are unaffected: they reach the session hooks directly and stay immediate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P6LtxKGYqd7tnK9MdopkHb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Makes the job-run resilience from #4713 work across multiple server instances. Today the replay buffer, cancel hooks, and adoption all live in the one process executing a run — behind Fly's load balancer with 2+ machines, a reconnecting client lands on a random instance, resume silently degrades to the persisted-row fallback, and cancel misses a live run entirely.
Both mechanisms are inert when no instance id is configured: identity comes from
NODETOOL_INSTANCE_ID, falling back to Fly'sFLY_MACHINE_ID, else null (single-machine mode — current prod — is behaviorally unchanged, confirmed by the full Ring 1 suite).Owner-aware reconnect routing (fly-replay)
runner_instancecolumn in both schemas plus a guarded additive migration.resume_job=<job_id>to the WebSocket handshake URL (via a new per-connecturlProvideronWebSocketManager;GlobalWebSocketManagercomputes the hint from the running workflow stores).409+fly-replay: instance=<owner>(hijack + write +end, per thews-upgrade.tsdiscipline; a 5s unref'd timer destroys the socket if the peer never closes), and Fly's proxy re-issues the whole handshake at the owner.fly-replay-src) is never replayed again; the stored owner id is validated (/^[A-Za-z0-9_-]+$/) before touching response bytes; job lookups are user-scoped (no cross-user existence oracle); and the client retires the hint after two consecutive failed connects, so a row stamped with a machine a deploy retired can't lock the tab's shared socket out — the third attempt connects bare and falls back toreconnect_job's persisted-row answer.Cross-instance cancel (row + poll)
cancel_job/stopfor a run stamped by a different instance marks the row cancelled with a conditional update (Job.markCancelledIfActive— only while non-terminal, so it can't clobber the owner'scompletedoutcome and cost accounting in a race).NODETOOL_JOB_CANCEL_POLL_MS, default 15s,0disables — one indexedINquery per tick, skipped when idle) and cancels any that readcancelled. Worst-case cross-instance cancel latency is one poll interval; a cancel on the machine that holds the run reaches the session hooks directly and stays immediate. (An earlier revision also carried a PostgresLISTEN/NOTIFYbus for immediacy; it was dropped — it required a direct/session-pooled connection that Supabase's transaction pooler can't provide, and the row poll alone is sufficient and unconditionally reliable.)runner_instance(HTTPPOST /run, trigger, MCP runs — nothing holds a session for those anywhere) keep the old "Job not found or already completed" answer instead of a false "cancellation requested".Also: the
urlProviderawait is guarded by a connect-generation counter so a teardown during URL resolution can't orphan a live socket.Testing
job-cancel.test.ts(conditional cancel incl. losing the race to acompletedwrite),job-multi-instance.test.ts(11 cases — stamping, foreign-owned cancel via the row, poller pickup, unstamped/terminal rows keep the not-found answer),fly-replay-upgrade.test.ts(8 cases incl. auth ordering,fly-replay-src, terminal/same-instance fallthrough),GlobalWebSocketManager.resumeJob.test.ts, plusWebSocketManagerteardown-during-resolve cases.npm run reliability:ring1all 15 green, typecheck (models/websocket/web) and lint clean.Deployment notes
Scaling to 2+ machines needs only
fly scale count 2+— no database configuration changes. Docs:docs/websocket-api.md§ Multi-Instance Deployments.🤖 Generated with Claude Code
https://claude.ai/code/session_01P6LtxKGYqd7tnK9MdopkHb