engine: fix provider-attach race dropping a provider's capabilities at node up#251
Conversation
…race Two providers attaching to one node concurrently (e.g. the broker and a fleet serveNode at `node up`) could non-deterministically drop one provider's entire capability set from the node roster, silently breaking placement/dispatch for the lost capabilities. Root cause: registration (duplicate-provider check + node_providers upsert + registry bind + node-aggregate recompute) and connection teardown both read and write shared node state but weren't serialized per node, and they interleave at await points: - Two same-name registrations both pass `providerAttachConflict` (which reads the in-memory registry) before either calls `attachProvider`, then the second `upsertProvider` overwrites the first's row — one cap set vanishes. - A teardown's `recomputeNodeAggregate` reads the provider set before a concurrent register commits and writes it after, clobbering the new provider out of the aggregate (the row survives; the roster goes stale). - On better-sqlite3, the teardown's write on the main handle deadlocks a register's isolated transaction (5s busy_timeout, then a swallowed error). Fix: run all control operations for a node one-at-a-time through a per-node queue keyed by workspace:node. `handleNodeControlMessage`, the socket-close path (`handleProviderDisconnect`), the offline sweep, and the provider DELETE route all serialize through it. Same-name duplicates now resolve deterministically (the second is rejected loudly with `provider_instance_conflict`, no silent overwrite); distinct providers coexist; and there is no cross-operation clobber or deadlock. No-op for callers that already serialize (a per-node DO). A file-backed conformance suite reproduces all three failure modes and asserts a stable aggregate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughThe changes serialize node-scoped control and provider lifecycle operations, add race-focused provider attachment conformance tests, and simplify two Rust UUID formatting call sites. ChangesProvider attach race handling
Rust fallback name formatting
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant ProviderSocket
participant NodeEngine
participant serializeNodeOp
participant NodeDatabase
ProviderSocket->>NodeEngine: send provider registration or disconnect
NodeEngine->>serializeNodeOp: enqueue workspaceId/nodeId operation
serializeNodeOp->>NodeEngine: execute lifecycle update
NodeEngine->>NodeDatabase: update provider state and node aggregate
Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 271be843cf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ueue Resolving `boundProviderName` inside the serialized callback let a queued heartbeat/deregister/agent.register fall back to `default` when the socket closed first (onNodeConnectionClose clears the connection's provider binding synchronously), acting on the wrong provider. Capture the binding at frame arrival so it reflects the provider that sent the frame; the SDK gates heartbeats on registration completion so the binding is present at arrival. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
… lock key - sweepOfflineNodes re-reads the node inside the serialized callback and skips it if a queued heartbeat/register refreshed it, so a now-live node isn't taken offline on a stale snapshot. - serializeNodeOp keys on an encoded (workspaceId, nodeId) tuple so ids containing the separator can't cross-serialize unrelated nodes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/engine/src/engine/node.ts`:
- Around line 512-531: The direct node-offline branch in onNodeConnectionClose
is not serialized and can race with node.register. Wrap the else-if
(!hasRemaining) markNodeOffline operation in serializeNodeOp using the same
workspaceId and nodeId keys, while preserving the existing provider-disconnect
path and early-return behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: fc21b1da-a628-42f5-96cf-2df2793b77e6
📒 Files selected for processing (4)
packages/engine/src/__tests__/conformance/providerAttachRace.test.tspackages/engine/src/engine/node.tspackages/engine/src/engine/nodeLock.tspackages/engine/src/routes/node.ts
…on main) Unblocks the Rust SDK CI job, red repo-wide since the CI clippy toolchain moved to 1.97. Behavior-neutral; not part of the provider-attach fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
onNodeConnectionClose discarded any error from handleProviderDisconnect /
markNodeOffline with `.catch(() => {})`. A failure there (e.g. SQLITE_BUSY)
would silently drop the provider-offline / aggregate recompute, leaving the
node roster with stale capabilities and no signal — a lock timeout turning into
invisible data loss. Log the failure with context; a close still must not throw.
Hardens the register-vs-teardown path fixed in #251. The conformance test's
register-vs-disconnect loop now asserts zero `[node.teardown]` errors are
logged, so a regression that reintroduced the swallow is caught.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* engine: log node teardown failures instead of swallowing them
onNodeConnectionClose discarded any error from handleProviderDisconnect /
markNodeOffline with `.catch(() => {})`. A failure there (e.g. SQLITE_BUSY)
would silently drop the provider-offline / aggregate recompute, leaving the
node roster with stale capabilities and no signal — a lock timeout turning into
invisible data loss. Log the failure with context; a close still must not throw.
Hardens the register-vs-teardown path fixed in #251. The conformance test's
register-vs-disconnect loop now asserts zero `[node.teardown]` errors are
logged, so a regression that reintroduced the swallow is caught.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address review: preserve stack in teardown logs; don't swallow unrelated errors
- Pass the error object to console.error at both teardown sites so the runtime
logs its stack trace, not just the message.
- The register-vs-disconnect test spy forwards non-teardown console.error calls
to the original, so unrelated errors during the run aren't hidden.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The bug (P0)
Two providers attaching to one node concurrently — the broker plus a fleet
serveNodeatnode up— non-deterministically drop one provider's entire capability set from the node roster (~50% of boots, flips which). This silently breaks placement/dispatch for the lost capabilities on every multi-providernode up.Root cause
Registration (duplicate-provider check →
node_providersupsert → registry bind → node-aggregate recompute) and connection teardown both read and write shared node state, but node-control operations aren't serialized per node — they interleave atawaitpoints. Three concrete failure modes, all reproduced:providerAttachConflict(which reads the in-memory registry) before either callsattachProvider, then the secondupsertProvider— keyed on(workspace, node, name)— overwrites the first's row. One capability set vanishes; the two providers collapse to one row. (This is why a per-node mutex around only the DB functions didn't help: serializing the calls doesn't stop the overwrite, and the conflict check is in the wrong layer.)recomputeNodeAggregatereads the provider set before a concurrent register commits and writes it after — clobbering the newly registered provider out ofnodes.capabilities. The provider row survives; the roster goes stale. This is the production (async D1) manifestation with distinct provider names.busy_timeout(5s), then swallowsSQLITE_BUSY.Fix
All control operations for a node run one-at-a-time through a per-node queue keyed by
workspace:node(serializeNodeOp).handleNodeControlMessage, the socket-close path (handleProviderDisconnect), the offline sweep, and the provider DELETE route serialize through it. Consequences:provider_instance_conflictinstead of silently overwriting the first.Tests
packages/engine/src/__tests__/conformance/providerAttachRace.test.ts— a file-backed suite (isolated transactions, like production) that reproduces all three modes and asserts a stable aggregate. Verified it fails without the fix (the register-vs-disconnect case deadlocks/times out; the same-name case asserts the silent overwrite) and passes with it. Full engine suite (423) and types suite (161) green; lint clean.Coordination note
For the broker and a fleet
serveNodeto coexist on one node they must register under distinct provider names — the engine now rejects a same-name duplicate deterministically rather than losing a cap set. Ifnode upcurrently points both at the same provider name (e.g.default), that needs a matching CLI change in the relay repo so serveNode uses a distinct name. Flagging for relay-impl.Rides the v6.0.1 patch.
🤖 Generated with Claude Code