Skip to content

fix(two-node): the arbiter's decision thread never blocks on a node (c7, F16) - #1482

Merged
schmidt-scaled merged 1 commit into
mainfrom
fix/two-node-f16-decision-loop
Oct 15, 2026
Merged

schmidt-scaled merged 1 commit into
mainfrom
fix/two-node-f16-decision-loop

Conversation

@schmidt-scaled

Copy link
Copy Markdown
Contributor

Campaign c7, 02b (F16): the arbiter's leadership reconciliation (B6) probed a node with get_lvstore (5 s timeout, 1 retry) on the decision thread just as that node froze. The grant to the healthy peer on the frozen node's expired lease never came, the peer fenced itself at its hold deadline, and the client got 160 EIO. Evidence: s3://tn2-mschmidt-xfer-565979732541/runs/c7/RESULTS.md.

Rule

The decision thread makes no blocking RPC to a node.

Changes (arbiter only, gated on two_node_arbitration)

  • New bounded work pool (TWO_NODE_WORK_WORKERS=4), separate from the renewal/status pool so calls blocked on a frozen node cannot starve the healthy peer's renewals.
  • On the work pool, one call per node (per cluster) in flight, applied on the decision thread when finished (_drain_work), never waited for:
    • jc_set_dual_node (configure_due)
    • device check + jc_unfence of a heal and of a self-fence re-admission
    • the leadership reconciliation pass (B6)
  • B6 pass skipped while a node is unreachable, lost, stopped by the CP, or holding; the arbiter probes with 1 s, no retry. reconcile_lvs_leadership / _lvs_leader_probe get parameters whose defaults keep today's behaviour for any other caller.
  • Best-effort fences (planned loss, operator override) use a 1 s timeout; the grant does not wait on them.
  • Design doc 7.3/7.7 and defaults updated.

Audit: node RPCs reachable from the decision thread

Call Before Now
get_lvstore (B6 probe, via reconcile_lvs_leadership) decision thread, 5 s + 1 retry work pool, 1 s, no retry, skipped while a node is gone/holding
jc_set_dual_node (configure) decision thread, 3 s work pool
devices check bdev_get + jc_unfence (heal) decision thread, 3 s + 5 s work pool
devices check + jc_unfence (self-fence re-admit) decision thread, 3 s + 5 s work pool
jc_fence best effort (planned loss) in send decision thread, 5 s decision thread, 1 s, grant not withheld
jc_fence (operator override) decision thread, 5 s decision thread, 1 s
jc_fence fence_all (partition) decision thread, 5 s unchanged: both nodes answer; nobody is granted
jc_grant_solo to the survivor decision thread, 5 s unchanged: the survivor answers
degraded verdict for a frozen peer no fence sent unchanged
jc_lease_renew, jc_ha_status, capability probe, returning-node fence rpc pool (#1472) unchanged
leadership restore (7.13), survivor promote (7.12), partition recovery (7.9) off-loop thread unchanged
rebuild progress / schedule / readmit / recover_devices FDB only unchanged

Tests

tests/unit/test_two_node_f16_decision_loop.py (13): real thread pools, a node whose every RPC blocks 2 s; no tick exceeds 0.5 s, and the grant to the peer lands on the first tick after lease TTL + margin for: a B6 probe in flight, configure, heal unfence, device check, rebuild progress. The four blocking cases fail without the fix (tick blocked 2.00 s). Two-node suites: 235 passed. Full unit run: only the 36 known Windows-only failures. ruff, mypy clean.

🤖 Generated with Claude Code

…c7, F16)

Campaign c7, 02b: the leadership reconciliation (B6) probed a node with
get_lvstore (5 s timeout, 1 retry) on the decision thread just as that node
froze. The call waited out its timeout, the grant to the healthy peer on the
frozen node's expired lease never came, the peer fenced itself at its hold
deadline and the client got 160 EIO.

Rule: the decision thread makes no blocking RPC to a node. A second bounded
pool (TWO_NODE_WORK_WORKERS) now runs jc_set_dual_node (configure_due), the
device check + jc_unfence of a heal or self-fence re-admission, and the
leadership reconciliation pass; one call per node (per cluster for the
reconciliation) in flight, applied on the decision thread when finished
(_drain_work), never waited for. A separate pool keeps calls blocked on a
frozen node from starving the healthy peer's renewals.

The reconciliation pass is skipped while a node is unreachable, lost,
stopped by the control plane or holding, and the arbiter's probe uses 1 s,
no retry (reconcile_lvs_leadership/_lvs_leader_probe gain parameters whose
defaults keep the old behaviour). Best-effort fences (planned loss, operator
override) use a 1 s timeout and never hold the grant up.

Arbiter-only, gated on two_node_arbitration; regular clusters unchanged.

Tests: test_two_node_f16_decision_loop.py (13): with real pools and a node
whose every RPC blocks 2 s, no tick exceeds 0.5 s and the grant to the peer
lands on the first tick after lease TTL + margin for a B6 probe in flight,
configure, heal unfence, device check and rebuild progress; the four
blocking cases fail without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the target/main Targets the development branch label Oct 15, 2026
@schmidt-scaled
schmidt-scaled merged commit 12c48e6 into main Oct 15, 2026
19 checks passed
@schmidt-scaled
schmidt-scaled deleted the fix/two-node-f16-decision-loop branch October 15, 2026 09:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

target/main Targets the development branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants