mirror of
https://github.com/kvcache-ai/AgentENV.git
synced 2026-11-05 01:17:36 +00:00
[PR #341] feat(scheduler): add HA via Kubernetes Lease leader election #364
Labels
No labels
bug
documentation
enhancement
enhancement
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
kvcache-ai/AgentENV#364
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/kvcache-ai/AgentENV/pull/341
Author: @NickNYU
Created: 10/5/2026
Status: 🔄 Open
Base:
main← Head:feat/scheduler-ha-leader-election📝 Commits (10+)
d83ec8bfeat(scheduler): add leader-election config surface and HA designfa7ebeafeat(api,scheduler): sync-node-snapshots rebuild and admin heartbeat parity99ffe29feat(scheduler): leadership domain with Lease election and recovery windowa9df2eefix(scheduler): takeover correctness in main wiring, discovery, and binding TTL9f89d54test(scheduler): Kind HA failover e2e harness with four scenarios6274c90docs(scheduler): HA deployment overlay and failover-gap notes0f9aab9fix(scheduler): hand discovery a ready channel only when startup waits4b4a0d8fix(scheduler): atomic freshness check for pulled node snapshots112008ftest(scheduler-e2e): probe T4's live demoted leader via API isolation1dff931test(scheduler-e2e): compare restartCount against a per-test baseline📊 Changes
37 files changed (+4300 additions, -88 deletions)
View changed files
➕
.github/workflows/scheduler-ha-e2e.yml(+96 -0)📝
deploy/k8s/base/role.yaml(+10 -0)➕
deploy/k8s/overlays/ha/README.md(+65 -0)➕
deploy/k8s/overlays/ha/config/scheduler.json(+25 -0)➕
deploy/k8s/overlays/ha/kustomization.yaml(+65 -0)📝
services/README.md(+29 -1)📝
services/go.mod(+5 -1)📝
services/go.sum(+12 -6)➕
services/scheduler/cmd/leadership_loss.go(+21 -0)➕
services/scheduler/cmd/leadership_loss_test.go(+46 -0)📝
services/scheduler/cmd/main.go(+123 -30)➕
services/scheduler/e2e/Dockerfile.stubnode(+12 -0)➕
services/scheduler/e2e/harness_test.go(+542 -0)➕
services/scheduler/e2e/k8s/redis.yaml(+30 -0)➕
services/scheduler/e2e/k8s/scheduler.yaml(+140 -0)➕
services/scheduler/e2e/k8s/stubnodes.yaml(+50 -0)➕
services/scheduler/e2e/stubnode/main.go(+309 -0)➕
services/scheduler/internal/discovery_ready_test.go(+89 -0)📝
services/scheduler/internal/kubernetes_discovery.go(+35 -9)➕
services/scheduler/internal/leadership.go(+438 -0)...and 17 more files
📄 Description
What
Add optional high availability to the scheduler via Kubernetes Lease leader election. N scheduler replicas compete for a single
coordination.k8s.io/Lease; exactly one leader schedules and processes node reports, standbys stay liveness-healthy and serveLookupNode/GetNodefrom the shared Redis bindings. On failover, the new leader rebuilds state by pulling every node's admin snapshot (sync-node-snapshots) instead of waiting out a reporter backoff, and applies recovery-window semantics until the rebuild completes. Off by default; with it off, behavior is identical to today.Why
The scheduler is a single point of failure today (
replicas: 1): scheduling, assignment writes, node inspection, and P2P APIs all go down with it, and query-only replicas only preserve data-plane lookups. Running multiple read/write replicas directly is unsafe — heartbeat state is replica-local, which silently breaksnode_resource_limit(#191, measured 3.0x overshoot on 8 nodes).Related issue
Closes #259
Relates to #191 — the #191 failure mode is fixed when leader election is enabled: exactly one scheduler processes heartbeats and schedules, and the recovery window never treats missing observations as free capacity. With election disabled (the default), behavior is unchanged, so this PR intentionally does not auto-close #191; whether to close it (or keep it for the doc note, direction 1 in the issue) is a maintainer call. The full #191 reproduction scenario is spec'd for Kind/CI (
services/scheduler/kind), not run locally.Scope and non-goals
In scope:
scheduler.leader_electionconfig surface + startup validation (fail fast; mutually exclusive with--query-only; client-go timing rules). Redis stays optional; without it the documented degraded mode applies.Leadershipcontract + factory + null object: gate interceptor (read/write matrix), leader readiness health servicescheduler.v1.Scheduler/leader, client-go Lease elector with fencing-by-exit./nodesthrough the shared heartbeat ingest path (observations only, no binding reconcile — see Design); concurrency vialeader_election.snapshot_pull_concurrency.max(binding_ttl, lease_duration + 90s)) so bindings outlive the failover budget.deploy/k8s/overlays/ha(3 replicas, readiness probe on the leader health service, election config, secret-mounted admin key); base manifests untouched except additiveleasesRBAC (inert when off).Non-goals:
Design and behavior changes
Key behaviors:
LookupNode/GetNode, reject the rest withUnavailable("not the leader"); without Redis reads retry onto the leader. The RPC table classifies every method explicitly and panics on unclassified ones, so proto upgrades fail loudly.scheduler.v1.Scheduler/leader(SERVING only while holding the lease), so Service endpoints contain only the leader and gateway needs no changes.OnStoppedLeadingflips readiness, then cancels the process root context — the ex-leader exits through the same graceful shutdown path as SIGTERM and restarts as a standby. A partitioned ex-leader stops serving withinrenew_deadline.report_ttl): heartbeats accepted immediately;Scheduleconsiders only nodes observed at or after acquisition (viaNodeRegistry.LastReportAt), zero fresh observations returnsUnavailable— never a fallback to unobserved nodes;LookupNode/GetNodereturnUnavailable(notNotFound) for missing entries while the window is open./nodessnapshot (HTTP,x-api-keyfromSCHEDULER_NODE_ADMIN_API_KEYmounted from the sharedagentenv-authSecret) and feeds it through the exact same ingest path asHeartbeat, so observations rebuild in seconds rather than a reporter backoff (~60s worst case). Bindings are deliberately not reconciled from a pull: a pull cannot see paused sandboxes or in-flight template builds, so reconciling would delete live bindings on failover (#341 review). Binding refresh stays with heartbeats, and the TTL floor covers the gap.Leadership) + one factory + a null object for the disabled path; implementations (manager, snapshot, elector) are package-private. State is an immutable snapshot swapped atomically by the manager; the Service sees leadership only through a 2-method read-only seam.Compatibility and operations
leader_electionis off by default; disabled behavior is identical to today for every deployment shape (validated in unit tests). New optional keys:leader_election.*(durations as strings, e.g."15s"),scheduler.node_admin_api_key(credential — preferSCHEDULER_NODE_ADMIN_API_KEYenv/Secret).coordination.k8s.io/leasesget/create/update (added todeploy/k8s/base/role.yaml). Redis optional but recommended; without it failover loses routing for pre-failover sandboxes (documented). No new Go module dependencies beyond client-go's existing testing packages.Validation
make fmtmake clippymake test-unitmake -C services test(required whenservices/changes)maketargetCommands and results:
New tests cover: config validation (A), gate matrix (B), election lifecycle on a fake clientset (C1 exactly-one-leader, C2 failover, C3 fencing on renew failure + callback ordering), recovery window semantics (D: fresh-only, zero-fresh Unavailable, ingest path sharing, pull failure, lookup window semantics), binding TTL floor, and admin snapshot fetcher mapping/auth/ingest compatibility.
Skipped checks and reasons:
make fmt,clippy,test-unit, integration): no Rust changes in this PR.services/scheduler/kindwith a build tag; the repo has no Kind harness and the dev machine is macOS. Left for CI; the fake-clientset suite covers election semantics offline.Risks and reviewer notes
services/scheduler/internal/leadership.go(facade + gate + elector) andservice.go(recovery window). Suggested read order:92b2089(config) →f92133c(leadership domain) →f1a67c3(sync-node-snapshots) →aac26dd(main wiring + TTL floor) →8a36b6c(Kind specs) →2ac0f4c(manifests/docs).Unavailableover optimistic scheduling right after failover — a deliberate availability-vs-correctness trade, matching the issue's "missing observations are not unlimited capacity".ListNodesis currently gated on standbys (reads the informer-backed registry, semantically standby-serveable); kept gated for now, flagged for a follow-up decision.Checklist
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.