mirror of
https://github.com/kvcache-ai/AgentENV.git
synced 2026-11-05 01:17:36 +00:00
[GH-ISSUE #259] Add scheduler HA using Kubernetes Lease leader election #71
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#71
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?
Originally created by @yingdi-shan on GitHub (Sep 7, 2026).
Original GitHub issue: https://github.com/kvcache-ai/AgentENV/issues/259
Affected component
Gateway / scheduler
Problem statement
AgentENV currently requires one read/write scheduler. Redis-backed query-only replicas preserve existing sandbox routing, but scheduling, node inspection, assignment writes, and P2P APIs remain unavailable if the primary fails.
Running multiple read/write schedulers is unsafe because heartbeat state is replica-local. This causes resource limits to be applied inconsistently, as reported in #191.
Use case
Production Kubernetes deployments need scheduler redundancy for runtime failures, upgrades, and node maintenance without operating multiple active schedulers or introducing a custom replication protocol.
Current behavior and workarounds
The Kubernetes deployment runs one scheduler replica. Operators can add query-only replicas backed by Redis, but these provide data-plane lookup availability only. The practical workaround is restarting the single primary scheduler and waiting for heartbeats to rebuild its state.
Desired behavior
Proposed approach
Use
client-goleader election with acoordination.k8s.io/Lease.Add scheduler configuration for:
On leadership acquisition:
Unavailablerather than falseNotFoundresults while in-memory bindings are rebuilding.On leadership loss:
Update Kubernetes manifests to use three replicas, grant Lease RBAC permissions, and use separate liveness and leader-readiness checks. Leader election and
--query-onlyshould be mutually exclusive.Compatibility and operational impact
API/config changes: Add optional scheduler leader-election configuration. No protobuf API change is expected.
Snapshot or storage format changes: None.
New host/runtime requirements: Kubernetes Lease API access and corresponding RBAC permissions.
Upgrade and rollback considerations: Leader election should remain disabled by default. Existing single-replica and query-only deployments continue to work. Rollback requires scaling the scheduler back to one replica.
Alternatives considered
Pre-submission checklist
@NickNYU commented on GitHub (Sep 22, 2026):
Hi @yingdi-shan,
I'd love to take this one if nobody has started yet.
The Lease-based plan looks solid to me — standbys staying out of the serving path, the promoted leader rebuilding from heartbeats, and returning Unavailable instead of false NotFound during recovery are exactly the details that make or break a failover.
A bit of background on me: I used to work on Redis itself (source-level changes), and before that on SOFA-Registry's meta server, where I spent a lot of time on leader election and slot-table balancing. So leader election over a Redis-backed binding store is familiar territory.
I noticed #232 implemented a very similar design but was closed because it was meant for the tonbo-io fork. Is the intent here to bring that work upstream, or would you prefer a fresh implementation? Either works for me.
If it helps, while doing this I'd also be happy to look at the Redis side of the failover story — e.g. how bindings behave while the old leader is gone (TTL windows, what query-only replicas serve during the gap), and any small wins in the current store usage.
If the scope sounds right, I can start with a short design note here (config surface, failover sequence, RBAC/manifest changes) before writing code — that's the rhythm that worked well for me on #37.
@yingdi-shan commented on GitHub (Sep 23, 2026):
Hi! Thanks a lot for the detailed write-up, and we'd be happy for you to take this one.
One change: we'd like leader election to use Kubernetes Leases (e.g. client-go's leaderelection) instead of Redis, so election stays without relying on Redis.
A short design note first sounds great. Looking forward to it!
@NickNYU commented on GitHub (Sep 23, 2026):
Hi @yingdi-shan,
Great, thank you!
Understood on the election mechanism: client-go leaderelection over a coordination.k8s.io/Lease, with no Redis in the election path. Redis stays where it is today — bindings storage for the query-only path.
I'll put together a short design note covering the config surface, the failover sequence, the liveness/readiness split, and the RBAC/manifest changes, and post it here before writing any code.
@NickNYU commented on GitHub (Sep 27, 2026):
Hi @yingdi-shan, I did another pass over the design and reworked it. The approach is the same Lease-based one, with two additions from reviewing the code more carefully: an explicit query-only topology for HA deployments (so gateway reads stay off the leader lifecycle), and a spelled-out compatibility story (disabled = today's behavior). Here's the updated design note:
@NickNYU commented on GitHub (Sep 27, 2026):
Design note: scheduler HA via Kubernetes Lease leader election (#259)
Goal
Non-Goal
High Level Design
coordination.k8s.io/Leasevia client-go leaderelection. One winner; the rest stand by.Unavailable. Standbys stay liveness-healthy.renew_deadline.Failover budget:
lease_duration(detect) + informer sync + one reconnect backoff (rebuild observations). Within this window, reads keep working through the query-only path; new scheduling waits for fresh observations instead of guessing.Compatibility
With election disabled (default), behavior is identical to today for every existing deployment shape. All changes sit behind the switch.
Low Level Design
1. Config surface
Startup validation (fail fast):
leader_election.enabledrequires kubernetes discovery and a Redis binding store. In-memory bindings do not survive failover, so existing-sandbox routing would break. Election itself never touches Redis.--query-only.lease_duration > renew_deadline > retry_period * 2.2. Leader gate and health probes
Serve, so the gate is an interceptor, not late registration. A unary interceptor chained with the existing metrics interceptor returnsUnavailable("not the leader")for every Scheduler RPC until the pod holds the lease.SERVINGfrom process start. Unchanged.agentenv.scheduler.Scheduler/leader:SERVINGonly while holding the lease. The readiness probe switches togrpc_health_probe -service=agentenv.scheduler.Scheduler/leader, so Service endpoints contain only the leader.SERVINGfrom start, identical to today.3. Leader lifecycle
OnStartedLeading: start (or resume) kubernetes discovery; accept heartbeats; readinessSERVING; enter the recovery window.OnStoppedLeading: readinessNOT_SERVING, then exit. Exit drops all connections; clients reconnect through the Service to the new leader; the pod restarts as a standby. This is the fence.OnNewLeader: log the new leader id.4. Recovery window
Right after winning, the heartbeat-rebuilt in-memory state is empty. Redis bindings are intact. The window lasts until the leader has heard from every live node once (bounded by lease acquisition + one
report_ttl).Heartbeat: accept immediately. Heartbeats are how the window ends.Schedule: only nodes observed after acquisition are candidates. None available: returnUnavailable. Never fall back to unobserved nodes. This is the #191 fix: no snapshot means "unknown", not "unlimited".LookupNode/GetNode: serve from Redis as usual. A missing binding returnsUnavailablewhile the window is open,NotFoundafter it closes, so "not yet rebuilt" is not reported as "does not exist".5. HA topology: query-only as an explicit component
6. Upgrade and rollback ordering
7. Manifests and RBAC
role.yaml: grantcoordination.k8s.ioleasesget/create/update.scheduler-deployment.yaml:replicas: 3; readiness probe adds-service=.../leader; liveness unchanged.query-onlyDeployment and Service manifests for HA deployments.deploy/k8s/base/config/scheduler.json: enableleader_election. Code default stays disabled, so static and docker-compose deployments are unaffected.8. Testing
UnavailablevsNotFound.SchedulewithUnavailablewhile staying liveness-healthy; assert existing-sandbox routing survives via the query-only path.9. Future work: warm standbys via sharded collection
This design keeps standby state cold, which is exactly why the recovery window exists. A later evolution: shard node observations across replicas (consistent hashing, not plain modulo), guard each shard with a Redis lease + fencing token, and write observations to a shared store readable on the scheduling path. Standbys become warm, failover needs no rebuild, and the recovery window goes away.
That change moves scheduling's correctness argument from "one decider" to "fresh enough shared state", so it needs its own design discussion and stays out of scope here.
Open Questions
agentenv.scheduler.Scheduler/leader— any preference?@NickNYU commented on GitHub (Sep 27, 2026):
Test plan for the design above
Unit cases are the CI floor; Kind integration cases assert end-to-end invariants. F1 is the signature case: it turns "failover preserves existing-sandbox routing" into an automated assertion.
A. Config validation (startup fail-fast, unit)
leader_election.enabled=true, noredis_addrdiscovery.mode=static, LE on--query-onlylease_duration=10s, renew_deadline=15sB. Leader gate and probes (unit + Kind)
Scheduleagainst a standbyUnavailable("not the leader"), no side effectsHeartbeatagainst a standbySERVING, readiness=NOT_SERVING, absent from Service endpointsSERVING, present in endpointsC. Election lifecycle (Kind)
OnNewLeaderloggedkill -9the leader podlease_duration(15s), endpoints convergerenew_deadline(10s); new leader takes over; no dual-primary windowD. Recovery window (unit + Kind)
Scheduleonly picks nodes observed after acquisitionSchedulereturnsUnavailable; never schedules onto unobserved nodes (the #191 assertion)Unavailable, notNotFound("not yet rebuilt" ≠ "does not exist")NotFoundE. #191 scenario (Kind)
max_sandbox_count=10, create 200 sandboxes at full speedSchedulereturnsUnavailableonce at ceiling; zero overshoot (vs. the measured 3.0x in #191)F. Failover end-to-end (Kind)
LookupNodefor old sandboxes via query-onlyScheduleworks again once observations rebuild, lands on healthy nodesLayering. Unit (A/B/D) is the CI floor. Kind (C/E/F) asserts one end-to-end invariant per case. F1 is deliberately designed to pass without the TTL floor (nodes re-heartbeat quickly in a normal failover); F2 is the floor's dedicated assertion, so a floor regression is pinpointed. If only three cases survive, keep F1, E1, C3.
@yingdi-shan commented on GitHub (Sep 28, 2026):
Thanks! I'd suggest keeping Redis optional and running the scheduler as a single Deployment with multiple replicas, rather than requiring a separate query-only Deployment.
Only the leader handles scheduling, assignment writes, and heartbeats. When Redis is configured, every standby replica acts as a query-only node and can serve LookupNode from the shared bindings. Without Redis, lookups are routed to the leader. After a failover, the new leader rebuilds the sandbox-to-node mappings by proactively sending Heartbeat RPCs to every node.
The readiness health service name agentenv.scheduler.Scheduler/leader works for me.
@NickNYU commented on GitHub (Sep 28, 2026):
Hi @yingdi-shan
Thanks for reply, this works for me. Adjusted design, in short:
Single Deployment, standbys double as readers.
One scheduler Deployment with N replicas behind one Service.
The leader gate becomes a read/write matrix instead of a blanket reject:
UnavailableUnavailableUnavailable(retries land on leader)No separate query-only Deployment, no gateway changes.
Redis stays optional.
Startup validation keeps only the
--query-onlyflag conflict and the client-go timing check.Active rebuild on leadership acquisition.
OnStartedLeading, the new leader walks its node registry/nodessnapshot, which the agentenv server already exposes with the same fields as a heartbeat.One confirmation:
in the no-Redis degraded mode, is "standby rejects reads with
Unavailable, client retries land on the leader" the behavior you have in mind for lookups?@yingdi-shan
Thanks
@yingdi-shan commented on GitHub (Sep 29, 2026):
Thanks! Your design looks good to me!
@NickNYU commented on GitHub (Oct 5, 2026):
Hi @yingdi-shan,
The consolidated design after the discussion above — this supersedes my earlier note. Implemented in #341.
coordination.k8s.io/Leasevia client-go leaderelection. The leader gate is a read/write matrix: writes (Schedule, RecordAssignment, Heartbeat, sandbox events, P2P writes) returnUnavailableon any non-leader; reads (LookupNode / GetNode) are served by standbys from the shared Redis bindings. The RPC table classifies every method explicitly and panics on unclassified ones, so proto upgrades fail loudly instead of silently gating.--query-onlyconflict and the client-go timing check.OnStartedLeadingthe new leader walks its warm node registry (the EndpointSlice informer runs on every replica from process start) and pulls each node's admin/nodes+/sandboxes(HTTP,x-api-keyfromSCHEDULER_NODE_ADMIN_API_KEY, mounted from the sharedagentenv-authSecret). Responses go through the exact same ingest path asHeartbeat, so observations and bindings rebuild in one RTT per node instead of one reporter backoff. Fan-out is bounded byleader_election.snapshot_pull_concurrency(default 4).report_ttl: Schedule only considers nodes observed at or after acquisition — zero fresh observations returnsUnavailable, never a fallback to unobserved nodes (the #191 fix: no snapshot means "unknown", not "unlimited"). LookupNode/GetNode returnUnavailable(notNotFound) for missing entries while the window is open.scheduler.v1.Scheduler/leader, SERVING only while holding the lease, so Service endpoints contain only the leader and gateway needs no changes. Losing the lease flips readiness, then exits the process — a partitioned ex-leader stops serving withinrenew_deadline.max(binding_ttl, lease_duration + 90s)so bindings outlive the failover budget; authoritative cleanup stays withReconcileNode, so raising it is harmless.leader_electionis off by default; disabled behavior is identical to today for every deployment shape (unit-tested). No protobuf, gateway, or node-side changes. Upgrade: binary first, then manifests (RBAC, probe, replicas, config). Rollback: disable and scale back to 1.Tests: unit + fake-clientset suites cover the config surface, gate matrix, election lifecycle (exactly one leader, failover, fencing with callback ordering), recovery-window semantics, and the admin pull path. End-to-end scenarios (including the #191 resource-limit reproduction on 3 replicas) are spec'd in
services/scheduler/kindfor CI.On #191: this PR intentionally does not auto-close it. The failure mode is fixed when election is enabled; with it off (the default), behavior is unchanged. Whether to close #191 or keep it for the doc note is your call.
Thanks!