feat(clm): add active-standby HA with leader election and reconciler (#1211) - #1363
feat(clm): add active-standby HA with leader election and reconciler (#1211)#1363shsaihdsaiudh wants to merge 3 commits into
Conversation
…encentCloud#1211) Introduce active-standby support for cube-lifecycle-manager so a crashed replica no longer blocks auto-pause/auto-resume. Replicas elect a leader through a Redis lease (SETNX + compare-and-expire/release Lua scripts); only the leader runs the stream consumer, stale-pending claim, last-active poller, idle sweeper, and periodic reconciler. Standbys keep the HTTP server up, report 503 on /readyz so the Service routes resume traffic to the leader, and can still serve /internal/resume via a meta-hash fallback when the registry misses. On failover the new leader bootstraps from the meta hash, replays the snapshot to every CubeProxy, claims the dead consumer's pending entries via XAUTOCLAIM, and the reconciler converges remaining drift between the meta hash, the in-memory registry, and proxy meta dicts. HA is opt-in via CUBE_LCM_HA_ENABLED (default off keeps single-replica behavior); the Helm chart enables it by default with replicas: 2. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: silasyyyang <silasyyyang@tencent.com> Assisted-by: Kimi Work:Kimi
…>= leaderTTL (TencentCloud#1211) - add leaderSupervisor: after 3 consecutive fast-failing leader stints (each shorter than the leader TTL) the process exits so the pod supervisor restarts it, instead of hot-looping elect -> fail -> step down forever; clean stints and stints surviving at least one TTL reset the counter - config: in HA mode validate ReconcileInterval >= LeaderTTL, since the reconcile interval doubles as the XAUTOCLAIM min-idle for taking over a dead leader's pending stream entries; a smaller value could steal entries from a merely partitioned old leader - docs: note both behaviors in README, values.yaml and stream.go Assisted-by: Kimi Work:Kimi Signed-off-by: silasyyyang <silasyyyang@tencent.com>
…1211) da7f2eb1 only updated docs/zh/. Mirror the leader-lock registration (redis-key-spec.md §5/§6) and the CLM high-availability section (lifecycle.md Operational Notes) into docs/, matching the i18n sync check in 73ea1f3. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Assisted-by: Kimi Work:Kimi Signed-off-by: silasyyyang <silasyyyang@tencent.com>
| r.o.Registry.Upsert(meta) | ||
| r.pushUpsert(ctx, sid, meta) | ||
| adopted++ | ||
| case !reflect.DeepEqual(cur.Meta, meta): |
There was a problem hiding this comment.
Behavioral divergence from the stream OpUpdate path — stale-meta refresh never resets LastActiveMs.
handleEvent (main.go, OpUpdate case) does reg.Upsert(*ev.Meta) and reg.ResetLastActive(ev.SandboxID) after an update. This reconciler branch refreshes a stale meta via Registry.Upsert + pushUpsert but never calls ResetLastActive.
Consequence: when an update (e.g. an operator extending TimeoutSeconds) is applied only through this reconciler path — because the corresponding stream event was lost during a failover, which is exactly the case this loop exists for — the registry keeps the old LastActiveMs baseline, so the sweeper can consider the sandbox idle and pause it on the next sweep, defeating the timeout extension. The same update arriving via the stream would have reset last-active and protected it. Recommend mirroring the stream path with reg.ResetLastActive(sid).
| // and Ack the returned events just like ReadGroup output. Requires Redis | ||
| // 6.2+ (XAUTOCLAIM). | ||
| func (c *Client) ClaimPending(ctx context.Context, group, consumer string, minIdle time.Duration, count int64) ([]Event, error) { | ||
| msgs, _, err := c.rdb.XAutoClaim(ctx, &redis.XAutoClaimArgs{ |
There was a problem hiding this comment.
Two issues in the claim loop:
-
Cursor ignored.
XAutoClaimreturns a cursor as its second result, and it's discarded here while every pass restarts at"0-0". For a failover backlog larger thancount(100), the takeover drains at only 100 entries per reconcile interval (60s) — a several-thousand-entry backlog takes many minutes to drain through this path. The reconciler eventually converges from the hash, so this is bounded, but it's slower than the "takes over pending entries" claim implies. Consider threading the cursor across passes. -
Trimmed entries are skipped but never acked on Redis 6.2. The comment says "nothing to ack (XAUTOCLAIM already dropped them from the PEL on Redis ≥ 7)" — true on 7.x, but the function explicitly documents "Requires Redis 6.2+". On 6.2, a value-less (trimmed) entry stays in the PEL, is returned again on every subsequent claim pass, and is never acked here — a slow, unbounded PEL leak. Acking the value-less entries would be harmless on 7.x (no-op) and correct on 6.2.
| } | ||
| if s.fleet != nil { | ||
| resp["fleet_size"] = s.fleet.Snapshot() | ||
| } |
There was a problem hiding this comment.
Standby resume fallback is unreachable via the Service during failover.
With HA enabled, standbys report 503 here and are therefore removed from the Kubernetes Service endpoints. During the failover window (leader death → new leader ready), the Service has no ready endpoints, so /internal/resume requests fail at the Service level and never reach a standby's meta-hash fallback. The fallback's real value is the freshly-promoted leader during its bootstrap (leader is ready per this gate but its registry is empty) — which is legitimate and worth keeping.
The README's claim that "a standby can still answer a resume that reaches it" is only true for direct pod-IP access, not through the Service. If resume-during-switch is a goal, standbys need to stay in the endpoint set (e.g. publishNotReadyAddresses, or a readyz criterion that doesn't gate the Service); otherwise the docs should state that resume requests fail for the switch window.
| if v := os.Getenv("CUBE_LCM_HA_ENABLED"); v != "" { | ||
| // Same truthy set as CUBE_LCM_USE_STATIC_FLEET. | ||
| switch v { | ||
| case "1", "true", "TRUE", "yes": |
There was a problem hiding this comment.
Truthy set is narrower than it looks. CUBE_LCM_HA_ENABLED=True (mixed case), YES, or on are all treated as false, silently disabling HA — a footgun for operators writing env files by hand (the Helm chart renders "true", so chart users are fine). HA-off degrades to the legacy single-replica behavior, so this fails safe, but a value that looks enabled while actually disabling the feature is surprising. Consider accepting any case / ParseBool-style values, or at least warning on unrecognized values instead of silently defaulting.
Review: feat(clm): add active-standby HA with leader election and reconciler (#1363)AI-generated review — reviewed the full Overall assessmentThe core design is sound and the failure-boundedness arguments hold up:
No blocking defect found. The issues below are behavioral divergences and robustness gaps rather than crashes. Findings1. [Medium] Reconciler update path omits 2. [Medium] 3. [Medium] Standby resume fallback is unreachable via the Service during failover — 4. [Low] Lower-severity notes
Test qualityThe new unit tests are strong: supervisor fast-fail/exit/reset matrix, elector acquire/renew/stolen-lease/step-down/shutdown, reconciler adopt/refresh/evict/grace-window/bootstrap-error, resumer meta-fallback (including the deliberate no-cache guarantee), and the readyz leader gate. Config tests cover the HA validation constraints including the |
Fixes #1211
What
Adds active-standby HA to
cube-lifecycle-managerso a crashed replica no longer blocks the auto-pause/auto-resume workflow:cube:v1:shared:lock:lifecycle_manager:leader, SETNX + token-matched renew/release Lua, default TTL 15s). Only the leader runs the stateful loops: stream consumer, stale-pending claim, last-active poller, idle sweeper, and the periodic reconciler./readyzso the Service routes/internal/resumeonly to the leader; a standby can still serve resume via the meta-hash fallback on a registry miss.XAUTOCLAIM(min-idle = reconcile interval), and the reconciler converges remaining drift between the meta hash, the in-memory registry, and proxy meta dicts.CUBE_LCM_RECONCILE_INTERVALmust be >=CUBE_LCM_LEADER_TTL(validated at startup) — the interval doubles as theXAUTOCLAIMmin-idle, and a smaller value could steal pending stream entries from a merely partitioned — still alive — old leader.HA is opt-in via
CUBE_LCM_HA_ENABLED(default off keeps single-replica behavior); the Helm chart enables it by default withreplicas: 2. One-click and terraform deployments remain single-replica.Upgradability note
HA deployments that set
reconcileInterval < leaderTTLwill now fail fast at startup — raisereconcileIntervalbefore upgrading. Helm defaults (60s/15s) satisfy the constraint.Testing
cube-lifecycle-managergo test ./...all pass (leaderelect, reconciler, resumer, httpapi, config, redisstream, and the new leader supervisor).🤖 Generated with Claude Code