Skip to content

fix(fleet): per-provider circuit breaker + 5xx reclassifier (coordinator) + v0.6.16 #408 wedge & KV-pin recovery (provider) — DAR-335/336/337/338#413

Merged
Gajesh2007 merged 17 commits into
masterfrom
fix/dar-335-338-fleet-deroute
Jun 20, 2026
Merged

fix(fleet): per-provider circuit breaker + 5xx reclassifier (coordinator) + v0.6.16 #408 wedge & KV-pin recovery (provider) — DAR-335/336/337/338#413
Gajesh2007 merged 17 commits into
masterfrom
fix/dar-335-338-fleet-deroute

Conversation

@Gajesh2007

@Gajesh2007 Gajesh2007 commented Jun 20, 2026

Copy link
Copy Markdown
Member

Summary

Post-swap (coordinator 0d4e5a63, fleet v0.6.15), 9 nodes produced 83% of all gpt-oss errors while staying fully routable — one box failed 99/99 requests for 15+ min and kept receiving traffic — capping gpt-oss OpenRouter-formula uptime at ~88% ("degraded").

This PR bundles both layers of the fix:

  • Coordinator (DAR-335 + DAR-336) — deroutes wedged/failing nodes now, no fleet update required. This is what stops the OpenRouter de-prioritization immediately.
  • Provider v0.6.16 (DAR-338 + DAR-337) — the actual cure for the #408 GPU-sync admission wedge and the un-recoverable pinned KV pool, so we can stop relying on the breaker.

⚠️ Deploy note: this PR intentionally couples two different cadences (see Deploying). The coordinator changes can ship today; the provider changes are a v0.6.16 release that needs signing/notarization + fleet convergence.

Linear: DAR-335, DAR-336, DAR-337, DAR-338.


Architecture: before → after

Before — bad nodes stay routable and admission wedges

flowchart TD
    subgraph CB["COORDINATOR — before"]
        A["Consumer request"] --> B["Preflight + scheduler"]
        B --> C{"Pick provider<br/>warm-idle looks ideal"}
        C --> D["Bad node<br/>serial D6C3FH4PJ4"]
        D -->|"503 / hang, ttft=0"| E["admitted_but_failed = 5xx"]
        E --> F[("OpenRouter uptime ~88%<br/>DEGRADED")]
        D -. "still idle+warm — never derouted" .-> C
    end
    subgraph PB["PROVIDER — before · PR 408 / v0.6.15"]
        G["commit() on<br/>GlobalKVCacheBudget actor"] --> H["reclaimForShortfall()"]
        H --> I[["BLOCKING MLX synchronize()<br/>+ clearCache() ON the actor"]]
        I -->|"actor stalled,<br/>all reservations queue"| J["request timed out<br/>waiting for capacity"]
        J --> K["engine loaded, not crashed"]
    end
    K -. "heartbeat lies: slot_state=idle" .-> D
Loading

After — breaker deroutes faults, reclassifier protects uptime, admission never blocks

flowchart TD
    subgraph CA["COORDINATOR — after · DAR-335 + DAR-336"]
        A2["Consumer request"] --> P{"Per-provider<br/>breaker open?"}
        P -->|"yes"| X["Derouted<br/>re-probed after cooldown"]
        P -->|"no"| C2{"Pick provider"}
        P -. "ALL tripped → fail-open probe" .-> C2
        C2 --> H1["Healthy node serves ✅"]
        C2 --> D2["Node returns 503"]
        D2 --> R{"capacity-class?"}
        R -->|"yes"| Q["reclassify → 429<br/>uptime-neutral, OR fails over"]
        R -->|"no — crash / fault"| S["stays 5xx → trips breaker"]
        S --> P
        H1 --> U[("OpenRouter uptime ≥95%")]
        Q --> U
    end
    subgraph PA["PROVIDER — after · v0.6.16 · DAR-338 + DAR-337"]
        G2["commit() on actor"] --> N["decide on snapshot<br/>NEVER blocks on GPU"]
        N -. "fire-and-forget" .-> RC["off-actor KVPoolReclaimer<br/>coalesced GPU flush"]
        N --> ADM["admit / reject instantly ✅"]
        W["Liveness watchdog"] -->|"wedged or pinned KV"| HB["truthful heartbeat<br/>slot_state = crashed / reloading"]
        W --> RS["self-restart engine<br/>clears pinned pool"]
    end
    HB -. "coordinator deroutes via breaker" .-> P
Loading

Coordinator (ship-today mitigation)

DAR-335 — per-provider node-health circuit breaker

Every existing failure-derived deroute path ignores generic fault-503 (the inference-error breaker counts only 500/502/504; reputation skips 503; dispatch-load cooldown only fires on model-load failures; within-request retry has no cross-request memory). A node returning 503 for ~100% of requests advertises slot_state=idle+warm, so the scheduler treats it as an ideal instant-TTFT target and keeps feeding it.

  • New coordinator/registry/provider_breaker.go: breaker keyed by provider ID only (node crashes affect every model). Trips on 5 consecutive faults OR (≥20 reqs / 120s AND >80% fail); cooldown 60s × 2^trips capped at 5m; half-open re-probe; auto-closes on a success.
  • Ignores healthy sheds (429 / 4xx / 499-cancel and capacity-class 503 like token_budget_exhausted, kv headroom, insufficient memory, draining); counts genuine faults (500/502/504, and 503 with crash/opaque/request rejected). Local classifier in-package (registry can't import api).
  • Gate added in providerPassesRoutingGatesLocked (one chokepoint covering selection, admit re-check, preflight, queued + cold dispatch).
  • Fail-open safety valve in selectBestCandidateScanLocked / ReserveProviderEx: if every candidate is tripped, routing still serves (a bad fleet-wide rollout can never deroute everyone).
  • Hooks in noteInferenceError/noteInferenceSuccess (error string threaded through the dispatch terminals); telemetry routing.provider_breaker_open / _closed. Cleared on Disconnect.

DAR-336 — split provider-5xx reclassification

isUnservableProviderErrorisCapacityClassProviderError with two extendable tables:

  • Bucket A (capacity/lifecycle → 5xx reclassified to uptime-neutral 429): existing capacity strings + draining, slot-cap, overload/backpressure (request rejected, queue full, server busy, service temporarily unavailable, request timed out waiting for capacity), cold/not-loaded misses.
  • Bucket B (genuine fault → stays 5xx, feeds the breaker): crashes/panics, internal error, model load failed, the opaque Foundation string; default is fault so crashes stay visible.

Extra fix found in review — preflight fail-open (DAR-335)

The fail-open valve lived only at dispatch-selection, but the public preflight (QuickCapacityCheck) still excluded breaker-open nodes — so an all-breaker-open fleet reported 0 candidates / 0 capacity-rejections and the consumer hard-503'd no_provider before dispatch's fail-open could run. That re-introduced the model-wide outage the valve exists to prevent (and they just had a fleet-wide v0.6.15 regression). The preflight now fails open on the breaker; every other gate (incl. the shape-keyed inference-error cooldown) is still honored. Regression test added.


Provider v0.6.16 (the cure)

DAR-338 — #408 KV self-heal blocking GPU sync wedged admission

#408 put a blocking MLX...synchronize() inside the GlobalKVCacheBudget actor (commit()/token-gate → reclaimForShortfallclearCache). Under load every near-miss stalled the actor; all reservations serialized behind it; requests timed out (request timed out waiting for capacity) while the node kept advertising healthy → fleet-wide wedge on 6+ machines, ~38% of residual errors. The shipped 1/sec rate-limit was insufficient.

  • New off-actor KVPoolReclaimer (dedicated actor) owns the GPU flush; the admission path triggers a coalesced, rate-limited, fire-and-forget background reclaim and decides on the current snapshot.
  • Invariant: no reserve/commit/reclaimForShortfall/token-gate execution ever blocks/awaits a GPU sync on the budget actor. Proven by a test (reserve returns <250 ms against a 0.5 s flush).
  • Teardown clearCache (off the admission path) and the #408 engine-detach race fix are preserved.

DAR-337 — pinned/collapsed KV pool can't recover

A node's KV budget collapses to ~1024 tokens and 503s every request for 15+ min; only a reload clears it.

  • Shared BackendLivenessPolicy watchdog detects wedge (admitted request, 0 tokens past threshold) and pinned (budget collapsed + demand + 0 successes for a sustained window).
  • On a degraded verdict the heartbeat becomes truthful (slot_statecrashed/reloading, no longer a lying idle/warm) so the coordinator deroutes, and the engine self-restarts (cooldown-guarded) to clear the pinned pool.
  • Provider version bumped 0.6.13 → 0.6.16 (coordinator LatestProviderVersion left untouched — that's a deploy decision).

Testing

  • Coordinator: full go test ./... green, incl. registry + api with -race. New breaker suite (consecutive/rate trip, half-open recovery, capacity-503 ignored, fault-503 trips, fail-open via ReserveProviderEx, preflight fail-open, Disconnect cleanup, >1024 sweep) + DAR-336 bucket tests with real observed strings.
  • Provider: swift build green; swift test --skip Live --skip Performance --skip ThroughputSweep877 tests / 53 suites pass, incl. rewritten self-heal tests, new KVPoolReclaimer and BackendLiveness suites.
  • Built in parallel isolated worktrees, then merged; dispatch.go (touched by both coordinator tickets) auto-merged and the combined tree was re-verified green.

Deploying

  1. Coordinator (now): EigenCloud builds coordinator/Dockerfile and blue-green deploys; no fleet update needed. The breaker + reclassifier take effect immediately and restore OR-uptime toward ≥95% under the same fleet conditions.
  2. Provider v0.6.16 (separate): cut the signed/notarized release (release-swift.yml), upload the bundle, and bump LatestProviderVersion in a follow-up so the fleet converges. Because this is one PR, coordinator + provider land together on merge — if you need to decouple, we can split into two PRs instead.

Risks / follow-ups

  • Provider Live/Performance suites (need a GPU + downloaded model) were not run, so the watchdog→self-restart integration isn't exercised end-to-end here; the decision logic, reclaimer, and non-blocking budget contract are unit-tested.
  • Intentional DAR-338 tradeoff: a KV near-miss now rejects instead of inline-flush-and-admit (background reclaim keeps the pool healthy; mitigates the #408 false-429 it originally fixed).
  • Unknown/empty 503 defaults to fault for the breaker (per spec) — mitigated by the high thresholds, the real error string threaded everywhere available, and the fail-open valve.
  • Wedge detector targets the reported 0-token stall; a mid-decode stall (≥1 token then hang) is a natural per-decode-step follow-up.

🤖 Implemented via parallel sub-agents in isolated worktrees.

…ess watchdog & KV-pin recovery (DAR-338, DAR-337)
QuickCapacityCheck excluded breaker-open providers, so an all-breaker-open
fleet reported 0 candidates / 0 capacity-rejections and the consumer hard-503'd
"no_provider" BEFORE dispatch's fail-open valve could serve a probe — the exact
model-wide outage the valve exists to prevent during a bad fleet-wide rollout.
The preflight now ignores the provider breaker (every other gate, incl. the
shape-keyed inference-error cooldown, still honored). Adds a regression test.
@vercel

vercel Bot commented Jun 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
d-inference Ready Ready Preview Jun 20, 2026 3:03am
d-inference-console-ui-dev Ready Ready Preview Jun 20, 2026 3:03am
d-inference-landing Ready Ready Preview Jun 20, 2026 3:03am

Request Review

@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

This PR introduces a backend-liveness watchdog and a per-provider circuit breaker that are net-positive for availability and marginally improve security posture, with no regressions against the threat model and two new attack surfaces that require brief scrutiny.


Trust boundaries touched

  • TB-002 (Coordinator → Provider WebSocket) — registry.go circuit breaker state
  • TB-007 (Provider inference engine) — BatchScheduler liveness watchdog, GlobalKVCacheBudget, KVPoolReclaimer
  • TB-009 (Apple attestation chain) — registry.go touches Disconnect, which intersects provider identity lifecycle

Threat assessment

Threat File(s) Verdict Notes
T-034 — Provider runs modified code while advertising trusted identity registry/registry.go ℹ️ Neutral The new providerOutcomes / providerBreakerOpenUntil / providerBreakerTrips maps and their cleanup in Disconnect are scoped entirely to inference-error health tracking. The CodeAttested gate and APNs challenge path are not touched. Breaker state is keyed by per-session UUID and cleared on Disconnect (line ~3368), so there is no identity bleed across reconnects.
T-007 — Provider serves manipulated model outputs BackendLivenessProbe.swift, BatchScheduler+Liveness.swift, BatchScheduler+Telemetry.swift, BatchScheduler.swift, GlobalKVCacheBudget.swift, KVPoolReclaimer.swift ✅ Strengthens heartbeatSlotState now reports "crashed" / "reloading" instead of "idle" / "running" when the engine is wedged or the KV pool is pinned. A wedged provider advertising itself as healthy is a routing integrity problem (requests hit a nil/stalled engine → 500). The liveness watchdog corrects that. Coordinator-side derouting during degraded states reduces the window where a malicious or silently-broken provider stays in rotation.
T-028 — Residual inference data in GPU memory BatchScheduler.swift (proactive KV sweep) ℹ️ Neutral MLX.Memory.clearCache() is now called by KVPoolReclaimer off-actor as a pool-pressure response. This is a memory-management move, not a security wipe — clearCache reclaims the MLX reclaimable pool (unreferenced buffers), not active inference tensors. The existing open finding (no explicit Metal memset between requests) is unchanged.
T-041 — Cross-tenant prefix-cache TTFT timing oracle BatchScheduler.swift ℹ️ Neutral currentWeightHash storage (line ~65) and the recovery-reload path preserve the existing KV-cache prefix-sharing behavior exactly. No change to the cache-sharing policy or the opt-out path.

New attack surface not covered by existing threats

1. selfRestartForRecovery as a forced model-reload trigger (uncategorized)

BatchScheduler+Liveness.swiftselfRestartForRecovery() calls stopCurrentEngine() + loadModel() automatically when livenessPolicy.assess() returns .wedged or .pinned. The liveness inputs (longestAdmittedZeroTokenSeconds, budgetCollapsedForSeconds) are derived from actor-internal state that a consumer indirectly influences by sending requests. A consumer who can reliably exhaust the KV budget (e.g., sending very large prompts that consume tokens, then suddenly stopping) could trigger repeated selfRestartForRecovery() calls with a 120-second cooldown between them. Each reload causes a "reloading" / "crashed" heartbeat, transiently derouting the provider.

  • Classification: T-029 (idle timeout) is the closest analogue, but this is a consumer-controlled reload trigger, not an idle timeout. The 120-second livenessRestartCooldown limits thrash rate. The pinnedSeconds threshold (180 s) and the requirement for demand (via hasDemand || recentReject) means a pure idle box is not restarted. Risk is low but the adversary is ADV-002 (malicious consumer).
  • Recommendation: Confirm that a single consumer cannot drive lastAdmissionRejectAt continuously by submitting requests just large enough to fail the token-budget guard in a tight loop. The noteAdmissionReject() call in submitTokenized at lines ~1755 and ~1767 is the entry point.

2. recoveryReloadModelId state not cleared on load failure

BatchScheduler+Liveness.swiftselfRestartForRecovery sets isReloadingForRecovery = true and recoveryReloadModelId before calling loadModel. If loadModel fails (e.g., weight files missing or corrupt), it is not clear from the diff whether isReloadingForRecovery and recoveryReloadModelId are reset. If they are not, the heartbeat permanently advertises state: "reloading" with the captured model ID, effectively hiding the provider from routing indefinitely without a Disconnect. This is not a security regression against the threat model, but it could silence a legitimate provider and should be confirmed.


SEC-* findings resolved by this PR

None — no open SEC-* findings are directly closed by this change. The circuit-breaker (SEC-034 adjacent, pre-auth WebSocket flooding) is not addressed here; the providerBreakerOpenUntil is a post-registration, inference-error breaker, not a connection-rate limiter.


Minor notes

  • _enterRecoveryReloadWindowForTest and _setModelIdForTest in BatchScheduler+EngineBridge.swift are internal test seams. Confirm they are not reachable from any public API surface at runtime (Swift internal + @testable is correct; verify no public re-export in ProviderCore.swift).
  • livenessLogger is a module-level let (BatchScheduler+Liveness.swift line ~23). Log category is "backend-liveness" with privacy: .public on model IDs — consistent with existing telemetry conventions and not a new privacy exposure.

🔐 Threat model: docs/threat-model.yaml · Updates on each push to this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a1bc73a2b3

ℹ️ 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".

}

let longestStall = longestAdmittedZeroTokenStallSeconds(now: now)
let hasDemand = !activeBridges.isEmpty || pendingRequestCount > 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Count token-gate rejections as liveness demand

When the KV budget collapses to the floor, normal requests whose promptTokens + maxTokens exceed that floor fail at the early requestBudget <= budgetMax guards in submitTokenized/submit before any bridge is inserted, so activeBridges and pendingRequestCount remain zero on watchdog ticks. In that exact pinned-pool scenario (the one this watchdog is meant to recover from), hasDemand stays false and the policy never returns .pinned, leaving the provider to keep returning token_budget_exhausted without self-restarting; track recent admission rejections as demand or insert a demand marker before the early token-budget reject.

Useful? React with 👍 / 👎.

"request rejected",
"queue full",
"server busy",
"service temporarily unavailable",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reclassifying coordinator DB failures as capacity

Including the broad service temporarily unavailable marker makes coordinator-side failures match the provider-capacity classifier too: dispatchOneProvider returns "service temporarily unavailable — please retry" on reserveAdditionalForProvider DB errors, and the dispatch exhaustion path reclassifies any matching 5xx to a 429. In that billing/store outage scenario clients and uptime accounting see a capacity/rate-limit response instead of the real coordinator 503, so this marker needs to be narrowed to provider-originated overload text or the reclassification needs to be limited to actual provider errors.

Useful? React with 👍 / 👎.

Comment on lines +299 to +302
"request timed out waiting for capacity",
"queue full",
"server busy",
"service temporarily unavailable",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat request-rejected 503s consistently as capacity

The API failure classifier explicitly treats "request rejected" as overload/backpressure capacity, but this breaker’s capacity marker list omits that same string, so a provider returning 503 request rejected under transient backpressure is counted as a node fault and trips the provider-wide breaker after five such sheds. In that load-shedding scenario the change deroutes a healthy busy node instead of ignoring the shed; add the marker here or otherwise distinguish the genuine-fault variant before feeding the breaker.

Useful? React with 👍 / 👎.

Comment on lines +364 to +365
if w2, cc2, cr2, tl2, vr2, tr2, bt2, _ := r.selectBestCandidateScanLocked(model, pr, true, excludeIDs...); w2 != nil {
return w2, cc2, cr2, tl2, vr2, tr2, bt2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prefer queueing before breaker fail-open

When pass 1 finds healthy providers only rejected for capacity/TTFT and also sees a breaker-open provider, this unconditional second scan bypasses the breaker and can select the quarantined node instead of returning the capacity signal that would queue or 429. In a mixed fleet where good nodes are simply busy but one bad node is idle, requests are sent to the known-failing provider; only fail open when the breaker is the sole reason no route exists, not when healthy providers are available but temporarily full or slow.

Useful? React with 👍 / 👎.

@blacksmith-sh

blacksmith-sh Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Found 1 test failure on Blacksmith runners:

Failure

Test View Logs
github.com/eigeninference/d-inference/e2e/TestIntegration_ConcurrentRequests View Logs

Fix with Codesmith
Need help on this PR? Tag /codesmith with what you need.

- Fail-open valve now triggers ONLY when the node-health breaker is the sole
  reason a request has no route; if a healthy provider is merely busy (capacity)
  or too slow (TTFT), surface that signal so the request queues/429s instead of
  routing to a known-bad, breaker-open node.
- Breaker treats a 503 'request rejected' as a capacity/backpressure shed
  (consistent with the api reclassifier and the inference-error breaker), so a
  healthy-but-busy node is no longer quarantined.
- Drop 'service temporarily unavailable' from the api capacity table: the
  coordinator emits it on its own store/DB errors, which must stay 5xx rather
  than be hidden as an uptime-neutral 429.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e6f558dfc4

ℹ️ 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".

Comment on lines +133 to +135
// Bad weights / metallib: a real load fault, NOT a capacity miss. MUST stay
// here (see the "not loaded" caveat in capacityClassMarkers above).
"model load failed",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify prefixed load-capacity failures as capacity

When a provider accepts a request but ensureModelLoaded fails for transient capacity, ProviderLoop sends the coordinator "model load failed: ..." with a 503 (for example the InferenceError.modelLoadFailed memory/headroom paths and slot-cap invalidModelDirectory paths in ProviderLoop.swift). Because this marker is checked before "insufficient memory", "insufficient kv", and the slot-cap predicate, those capacity failures now stay as raw 503s instead of being reclassified to the uptime-neutral 429, so overloaded/cold-load providers can still penalize reliability rather than trigger normal failover.

Useful? React with 👍 / 👎.

Comment on lines +275 to +276
case 500, 502, 504:
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore capacity-shaped 5xx in the node breaker

When older/provider paths surface capacity rejects such as token_budget_exhausted, insufficient KV, or context overflow as 500/502/504, the dispatch path can still recognize them as capacity later via isCapacityClassProviderError(statusCode >= 500), but this branch records them as provider faults first. Five long-prompt or near-miss capacity rejects from an otherwise healthy node can therefore open the provider-wide breaker and deroute it, even though the final client response is meant to be an uptime-neutral 429; check the capacity markers before treating these status codes as unconditional faults.

Useful? React with 👍 / 👎.

// slot_state ("crashed"/"reloading" instead of a lying "idle"), and
// self-restarts the engine/model slot to recover. Wire-compatible: only
// existing slot_state string values are emitted; no protocol changes.
public static let version = "0.6.16"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the fallback provider version in sync

This bumps the Swift binary to 0.6.16, but coordinator/api/server.go still falls back to LatestProviderVersion = "0.6.11" when no release row is registered. In in-memory/dev coordinators or release-table bootstrap scenarios, update checks and provider metadata will advertise 0.6.11 even though the built provider is 0.6.16, so operators may not be prompted to install the wedge/liveness fix shipped here.

Useful? React with 👍 / 👎.

let hash = currentWeightHash
isReloadingForRecovery = true
livenessLogger.error("self-restarting model \(id, privacy: .public) to recover backend liveness")
await loadModel(container: container, modelId: id, weightHash: hash)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the model id during recovery reload

During a liveness self-restart, this loadModel call first runs stopCurrentEngine, which clears the scheduler's modelId while the ProviderLoop still keeps the old modelSlots[modelId] entry. Any heartbeat in that reload window reports a reloading slot for model:"" rather than for the actual model, so the coordinator can treat the provider as an eligible cold/unknown candidate for the real model; ensureModelLoaded then returns because the stale slot entry exists and the request reaches a scheduler with engine == nil, producing No model loaded 500s instead of being derouted until reload completes.

Useful? React with 👍 / 👎.

// fault-503ing ~all of its requests gets quarantined fleet-wide. errStr lets
// the breaker tell a capacity-503 (ignored) from a fault-503 (counted). Both
// breakers coexist.
if opened, _ := s.registry.RecordProviderOutcome(providerID, false, statusCode, errStr); opened {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t quarantine every model for one model’s faults

This feeds the node-health breaker with only providerID, so five genuine 500/502/504 failures from a single corrupt model or request shape open a provider-wide breaker and providerPassesRoutingGatesLockedEx then rejects the same node for unrelated healthy models too. The shape-keyed RecordInferenceError already quarantines the failing provider/model/shape; the node-wide breaker should require cross-model/all-request evidence (or otherwise include model scope) before derouting the whole provider.

Useful? React with 👍 / 👎.

…3-cap)

A request now keeps failing over to fresh healthy providers until one succeeds,
candidates are exhausted (each failed provider is excluded, so dispatchPrimary
returns outcomeFailFast once none remain), or the request's deadline/context
fires (run() checks r.Context() each attempt). maxDispatchAttempts becomes a
high safety ceiling (64) against a pathological hot loop rather than the normal
stopping point; retries never re-queue, so failover stays fast. Exhaustion
messages now report the actual attempt count. Adds a regression test proving
failover reaches a healthy provider sitting behind six failing ones (the old
3-attempt cap gave up before reaching it).
…breaker

- inference_failure_class: a provider 'model load failed: <capacity reason>'
  (insufficient memory/KV, slot-cap) now reclassifies to an uptime-neutral 429
  instead of staying 5xx — capacity markers are checked before the
  model-load-failed wrapper; bad-weights/opaque load failures still stay 5xx.
- provider_breaker: ignore a capacity-shaped 5xx regardless of status code, so a
  500/502/504 carrying a capacity/backpressure message no longer trips the
  node-health breaker (some/older provider paths surface capacity as a non-503
  5xx; the dispatch reclassifier already 429s them). Renames the breaker's
  capacity helper/markers to drop the 503-specific name.
@Gajesh2007
Gajesh2007 merged commit 2a037ae into master Jun 20, 2026
9 of 14 checks passed
Gajesh2007 added a commit that referenced this pull request Jun 20, 2026
- KV-quant + benchmark files (master == merged foundation): keep ours
  (foundation + gate changes) — verified no review-time divergence.
- BatchScheduler.swift: 3-way merge on the foundation base to combine the
  concurrency cap-fix (seed dynamicMaxConcurrentRequests to the ceiling) with
  master's #413 backend-liveness/wedge tracking.
- Submodule already at merged engine main (a9e0ee1).

Builds clean; cap/KVQuant/#413-liveness/config/budget suites green.

@ethenotethan ethenotethan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review — Layr-Labs/d-inference#

Verdict: REQUEST_CHANGES

Security — 1 finding(s)

  • 🔵 [INFO] coordinator/api/dispatch.go:1810-1812 — Context deadline check may leak timing information
    • Suggestion: Consider using a constant-time check or adding jitter to prevent timing-based inference about request processing state

Performance — 4 finding(s) (2 blocking)

  • 🟡 [MEDIUM] coordinator/api/dispatch.go:1800-1812 — Context cancellation check only after first attempt may allow unbounded retries
    • Suggestion: Move context check before the loop or add it inside the loop body to ensure timely cancellation
  • 🔵 [INFO] coordinator/registry/provider_breaker.go:135-155 — Opportunistic sweep runs on every RecordProviderOutcome call when maps exceed 1024 entries
    • Suggestion: Add probabilistic triggering (e.g., 1% chance) or use a separate periodic cleanup to avoid repeated expensive map iterations
  • 🔵 [INFO] provider-swift/Sources/ProviderCore/Inference/BatchScheduler+Liveness.swift:119-133 — longestAdmittedZeroTokenStallSeconds iterates all active bridges on every watchdog tick
    • Suggestion: Cache the longest stall time and update incrementally when bridges change state, or limit iteration frequency
  • 🟡 [MEDIUM] provider-swift/Sources/ProviderCore/Inference/GlobalKVCacheBudget.swift:172-174 — Fire-and-forget Task creation in reclaimForShortfall without bounds
    • Suggestion: Use a TaskGroup or limit concurrent reclaim tasks to prevent unbounded Task spawning under high load

Type_diligence — 2 finding(s)

  • 🔵 [INFO] coordinator/api/dispatch.go:1800 — Loop variable attempt relies on implicit zero initialization
    • Suggestion: Make initialization explicit: for attempt := 0; attempt < maxDispatchAttempts; attempt++
  • 🔵 [INFO] coordinator/registry/provider_breaker.go:358 — Function parameter uses bare any type
    • Suggestion: Define a specific type for the word parameter or use a more constrained interface

Additive_complexity — 6 finding(s) (2 blocking)

  • 🔵 [INFO] coordinator/api/dispatch.go:63-77 — maxDispatchAttempts increased from 3 to 64 with extensive comment justification
    • Suggestion: Consider if 64 is truly necessary or if a smaller value like 16 would suffice. The comment suggests this is a 'safety ceiling' but such a high value may mask underlying issues.
  • 🔵 [INFO] coordinator/registry/provider_breaker.go:1-366 — Entire new file adds per-provider circuit breaker with significant complexity
    • Suggestion: Consider if this functionality could be integrated into existing error handling rather than a separate subsystem. The overlap with inference error cooldown suggests potential consolidation opportunities.
  • 🔵 [INFO] coordinator/api/inference_failure_class.go:2-152 — Function renamed and logic duplicated between api and registry packages
    • Suggestion: Extract shared classification logic to a common package to avoid maintaining two similar implementations of capacity vs fault classification.
  • 🔵 [INFO] provider-swift/Sources/ProviderCore/Inference/BackendLivenessProbe.swift:1-128 — Pure policy struct for simple threshold checks
    • Suggestion: Consider if this level of abstraction is needed for what are essentially threshold comparisons. The policy could be simplified to direct threshold checks.
  • 🟡 [MEDIUM] provider-swift/Sources/ProviderCore/Inference/KVPoolReclaimer.swift:1-113 — New actor to move blocking operations off main actor
    • Suggestion: While solving a real problem, this adds significant architectural complexity. Consider if the blocking operations could be made non-blocking instead of adding another actor layer.
  • 🔴 [CRITICAL] coordinator/registry/scheduler.go:278-629 — Complex fail-open logic with two-pass selection and bypass mechanisms
    • Suggestion: The two-pass selection with breaker bypass adds significant complexity to the hot path. Consider if this could be simplified or if the fail-open behavior could be achieved with less intricate logic.

13 finding(s) total, 4 blocking. Verdict: REQUEST_CHANGES.

🤖 Automated review by Centaur · DAR-186

@ethenotethan ethenotethan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review — Layr-Labs/d-inference#

Verdict: REQUEST_CHANGES

Security — ✅ No issues found

Performance — 4 finding(s) (3 blocking)

  • 🟡 [MEDIUM] coordinator/api/dispatch.go:1800-1812 — Context cancellation check only after first attempt may allow unbounded retries
    • Suggestion: Move the context check before the attempt loop or add it inside the loop to ensure timely cancellation
  • 🟡 [MEDIUM] provider-swift/Sources/ProviderCore/Inference/BatchScheduler.swift:1712-1716 — Fire-and-forget KV reclaim may still block on actor boundary
    • Suggestion: Verify that kvBudget.reclaimForShortfall is truly non-blocking or consider using Task.detached for complete isolation
  • 🔵 [INFO] coordinator/registry/provider_breaker.go:358-375 — Linear search through capacity shed markers on every provider error
    • Suggestion: Convert capacityShedMarkers to a map or use a more efficient string matching approach for hot path performance
  • 🟡 [MEDIUM] provider-swift/Sources/ProviderCore/Inference/BatchScheduler+Liveness.swift:119-133 — Inefficient O(n) scan of activeBridges on every liveness assessment
    • Suggestion: Maintain a separate index of zero-token bridges or cache the longest stall to avoid repeated full scans

Type_diligence — 3 finding(s)

  • 🔵 [INFO] coordinator/api/dispatch.go:1800 — range over maxDispatchAttempts without checking bounds
    • Suggestion: Add explicit bounds check or use for attempt := 0; attempt < maxDispatchAttempts; attempt++ instead of range
  • 🔵 [INFO] coordinator/registry/provider_breaker.go:300 — Using map[string]any for capacityShedMarkers slice
    • Suggestion: Define a typed slice []string instead of relying on string matching patterns
  • 🔵 [INFO] coordinator/registry/provider_breaker.go:320 — Using map[string]any for faultClassMarkers slice
    • Suggestion: Define a typed slice []string instead of relying on string matching patterns

Additive_complexity — 4 finding(s) (1 blocking)

  • 🔵 [INFO] coordinator/api/dispatch.go:1800-1812 — deadline check on every retry attempt adds unnecessary complexity
    • Suggestion: Move context deadline check to the beginning of run() method only, or use a select with context.Done() in the main dispatch loop instead of checking on every iteration
  • 🟡 [MEDIUM] coordinator/registry/provider_breaker.go:1-366 — capacity classification logic duplicated between api and registry packages
    • Suggestion: Extract the capacity vs fault classification into a shared package to avoid maintaining two similar implementations of isCapacityClassProviderError and isCapacityShedError
  • 🔵 [INFO] coordinator/api/inference_failure_class.go:108-152 — complex string classification with overlapping marker arrays
    • Suggestion: Consider a single classification function with clear precedence rules instead of separate marker arrays and multiple classification paths
  • 🔵 [INFO] provider-swift/Sources/ProviderCore/Inference/BatchScheduler+Liveness.swift:1-182 — liveness watchdog logic mixed into scheduler instead of separate component
    • Suggestion: Extract liveness monitoring into a dedicated LivenessWatchdog class that observes scheduler state rather than embedding the logic directly in BatchScheduler

11 finding(s) total, 4 blocking. Verdict: REQUEST_CHANGES.

🤖 Automated review by Centaur · DAR-186

@ethenotethan ethenotethan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review — Layr-Labs/d-inference#

Verdict: REQUEST_CHANGES

Security — ✅ No issues found

Performance — 5 finding(s) (2 blocking)

  • 🟡 [MEDIUM] coordinator/api/dispatch.go:1800-1812 — Context cancellation check inside hot retry loop may cause unnecessary overhead
    • Suggestion: Move context check outside the loop or use a select statement to avoid repeated Context().Err() calls on every iteration
  • 🔵 [INFO] coordinator/registry/provider_breaker.go:95-112 — Nested loop over outcomes array for windowed stats calculation
    • Suggestion: Consider maintaining a separate time-ordered structure or using a more efficient sliding window algorithm to avoid O(n) scan on every assessment
  • 🟡 [MEDIUM] coordinator/registry/provider_breaker.go:140-156 — Opportunistic map cleanup triggers on >1024 entries with full iteration
    • Suggestion: Consider using a background cleanup goroutine or more efficient data structure to avoid blocking the hot path when maps grow large
  • 🔵 [INFO] provider-swift/Sources/ProviderCore/Inference/BatchScheduler+Liveness.swift:119-133 — Linear scan through all active bridges to find longest stall on every watchdog tick
    • Suggestion: Maintain a priority queue or sorted structure of bridges by admission time to avoid O(n) scan every 2 seconds
  • 🔵 [INFO] provider-swift/Sources/ProviderCore/Inference/GlobalKVCacheBudget.swift:172-178 — Fire-and-forget task spawning in nonisolated functions may accumulate under high load
    • Suggestion: Consider using a bounded task queue or coalescing mechanism to prevent unbounded task creation during admission pressure spikes

Type_diligence — 1 finding(s)

  • 🔵 [INFO] coordinator/api/dispatch.go:1800 — range over maxDispatchAttempts without checking bounds
    • Suggestion: Add explicit bounds check or use a typed constant instead of bare range

Additive_complexity — 6 finding(s) (4 blocking)

  • 🔵 [INFO] coordinator/api/dispatch.go:1800-1812 — Deadline check on every retry attempt adds unnecessary complexity
    • Suggestion: Move the deadline check inside dispatchPrimary() or use a simpler timeout mechanism. The current approach checks context cancellation on every loop iteration when maxDispatchAttempts is already a safety ceiling.
  • 🟡 [MEDIUM] coordinator/api/inference_failure_class.go:2-152 — Complex string classification with overlapping responsibilities
    • Suggestion: Split into separate classifiers for capacity vs fault detection, or use a simple map-based approach. The current implementation has complex precedence rules and duplicate logic between api and registry packages.
  • 🟡 [MEDIUM] coordinator/registry/provider_breaker.go:1-366 — Entire new circuit breaker system duplicates existing error handling
    • Suggestion: Consider extending the existing inference error cooldown system instead of adding a parallel breaker. The two systems have overlapping responsibilities and similar state management patterns.
  • 🟡 [MEDIUM] provider-swift/Sources/ProviderCore/Inference/BackendLivenessProbe.swift:1-128 — Pure policy class for simple threshold checks
    • Suggestion: Replace with direct threshold comparisons in the scheduler. The policy abstraction adds indirection for what are essentially simple numeric comparisons.
  • 🟡 [MEDIUM] provider-swift/Sources/ProviderCore/Inference/KVPoolReclaimer.swift:1-113 — Separate actor for rate-limited cache clearing
    • Suggestion: Use a simple debounced function or timer instead of a full actor. The reclaimer mainly wraps rate-limiting around a single operation.
  • 🔵 [INFO] coordinator/api/consumer.go:218-257 — Provider outcome recording duplicated across multiple functions
    • Suggestion: Create a single recordProviderOutcome helper that handles both breakers consistently, reducing the repeated pattern of calling both RecordInferenceError and RecordProviderOutcome.

12 finding(s) total, 6 blocking. Verdict: REQUEST_CHANGES.

🤖 Automated review by Centaur · DAR-186

@ethenotethan ethenotethan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review — Layr-Labs/d-inference#

Verdict: REQUEST_CHANGES

Security — ✅ No issues found

Performance — 4 finding(s) (3 blocking)

  • 🟡 [MEDIUM] coordinator/api/dispatch.go:1800-1812 — Context cancellation check only after first attempt may allow unbounded retries
    • Suggestion: Move context check before the loop or add it inside the loop body to ensure timely cancellation
  • 🟡 [MEDIUM] provider-swift/Sources/ProviderCore/Inference/BatchScheduler.swift:1712-1716 — Fire-and-forget KV reclaim may still block on actor boundary
    • Suggestion: Verify that kvBudget.reclaimForShortfall is truly non-blocking or use Task.detached to ensure no actor blocking
  • 🔵 [INFO] coordinator/registry/provider_breaker.go:315-366 — String processing repeated for each provider outcome classification
    • Suggestion: Pre-compute lowercased error strings or cache classification results to avoid repeated string operations
  • 🟡 [MEDIUM] provider-swift/Sources/ProviderCore/Inference/BatchScheduler+Liveness.swift:119-133 — O(n) scan of activeBridges on every liveness assessment
    • Suggestion: Maintain a separate index of zero-token bridges or use more efficient data structure for this lookup

Type_diligence — 3 finding(s)

  • 🔵 [INFO] coordinator/api/dispatch.go:1800 — range over maxDispatchAttempts without checking bounds
    • Suggestion: Add explicit bounds check or use for attempt := 0; attempt < maxDispatchAttempts; attempt++ to avoid potential range issues
  • 🔵 [INFO] coordinator/registry/provider_breaker.go:289 — map[string]any used in providerOutcomes map
    • Suggestion: Replace map[string]any with a typed struct for providerOutcomes entries to improve type safety
  • 🔵 [INFO] coordinator/registry/provider_breaker.go:290 — map[string]any used in providerBreakerOpenUntil map
    • Suggestion: Replace map[string]any with a typed struct for providerBreakerOpenUntil entries to improve type safety

Additive_complexity — 8 finding(s) (5 blocking)

  • 🔵 [INFO] coordinator/api/consumer.go:66-79 — maxDispatchAttempts increased from 3 to 64 with extensive comment justification
    • Suggestion: Consider if 64 is truly necessary or if a smaller value like 16 would suffice. The extensive comment suggests uncertainty about the right value.
  • 🟡 [MEDIUM] coordinator/api/dispatch.go:1800-1812 — Context cancellation check duplicates existing timeout handling
    • Suggestion: The request context timeout is already handled by the dispatch state machine. Consider if this additional check adds meaningful value or creates redundant timeout paths.
  • 🟡 [MEDIUM] coordinator/api/inference_failure_class.go:2-152 — Complex dual-bucket classification system with extensive marker arrays
    • Suggestion: Consider simplifying to a single classification function with clear rules rather than maintaining two separate marker arrays and complex precedence logic.
  • 🔵 [INFO] coordinator/registry/provider_breaker.go:1-366 — Reimplements circuit breaker pattern alongside existing error_cooldown.go
    • Suggestion: Consider if the existing inference error cooldown could be extended rather than implementing a parallel circuit breaker system.
  • 🔵 [INFO] provider-swift/Sources/ProviderCore/Inference/BackendLivenessProbe.swift:1-128 — Pure policy struct for simple boolean decision
    • Suggestion: The policy abstraction may be overkill for what appears to be straightforward threshold checks. Consider inlining the logic.
  • 🟡 [MEDIUM] provider-swift/Sources/ProviderCore/Inference/KVPoolReclaimer.swift:1-113 — Dedicated actor for rate-limited cache clearing
    • Suggestion: Consider if this complexity is justified over simpler rate-limiting within the existing budget actor, given the single responsibility.
  • 🔴 [CRITICAL] coordinator/registry/scheduler.go:341-400 — Two-pass selection with fail-open logic adds significant complexity
    • Suggestion: The dual-pass selection with breaker bypass creates complex control flow. Consider if a simpler approach like weighted selection could achieve the same fail-open behavior.
  • 🟡 [MEDIUM] provider-swift/Sources/ProviderCore/Inference/BatchScheduler+Liveness.swift:1-182 — Scheduler now responsible for self-restart and liveness detection
    • Suggestion: Consider if liveness monitoring belongs in a separate component rather than expanding the scheduler's responsibilities.

15 finding(s) total, 8 blocking. Verdict: REQUEST_CHANGES.

🤖 Automated review by Centaur · DAR-186

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants