Skip to content

feat(clm): add active-standby failover - #1516

Open
chenhengqi wants to merge 4 commits into
masterfrom
clm-active-standby-v3
Open

feat(clm): add active-standby failover#1516
chenhengqi wants to merge 4 commits into
masterfrom
clm-active-standby-v3

Conversation

@chenhengqi

Copy link
Copy Markdown
Collaborator

Run two warm CLM replicas in Kubernetes and use a Redis lease to gate singleton sweep and prune work while both replicas serve resume requests.

Use broadcast XREAD consumption, promotion catch-up, fencing epochs, and versioned state CAS to prevent stale replicas from overwriting newer state. Add Helm configuration, readiness observability, Redis transaction tests, and bilingual documentation. Keep one-click deployment single-replica.

Comment thread cube-lifecycle-manager/cmd/cube-lifecycle-manager/main.go Outdated
Comment thread cube-lifecycle-manager/cmd/cube-lifecycle-manager/main.go
Comment thread cube-lifecycle-manager/internal/redisstream/stream.go
@cubesandboxbot

cubesandboxbot Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review: feat(clm) — add active-standby failover (#1516)

AI-generated review. This is an automated code review; it has not been approved by a human reviewer. Findings are ranked most-severe first; each includes a concrete trigger scenario.

Summary

The PR runs two warm CLM replicas behind the chart Service and uses a Redis lease (cube:v1:shared:lock:lifecycle-manager:leader) to gate singleton work (sweep, prune, proxy-push) while both replicas serve resume requests. It adds broadcast XREAD consumption with independent per-replica cursors, promotion catch-up with a drain window, fencing generations, and versioned state CAS to keep a stale replica from overwriting newer state. One-click deployment stays single-replica (election disabled).

The overall design is sound: the WATCH/MULTI lease renew/release avoids Lua, the local deadline sits safely inside the Redis TTL, ShouldApply monotonic gating prevents double-apply between consumeStream and catchUpGeneration (both serialize on eventApplyMu), and standby fleets don't grow unboundedly because the in-memory next map drops expired heartbeats on every replica. The two items I'd address before merge are F1 (resumer availability regression) and F2 (empty-stream rebuild loop).

Findings

F1 — Resumer transport-error retention regresses single-instance availability (Medium)

cube-lifecycle-manager/internal/resumer/resumer.go:259

The new default branch in callCubeMasterResume retains the resuming state key (TTL = StateLockTTL, default 60s) whenever CubeMaster.Resume fails with a transport/timeout error instead of a structured API error. This is unconditional — it applies with leader election disabled too.

Trigger: a single dropped HTTP connection to CubeMaster during a resume. The lock is never cleared (nothing else changes the key), so every subsequent resume request for that sandbox enters AcquireResume → sees resumingwaitForRunning → blocks until its timeout, and the sandbox is effectively un-resumable for up to ~60s. Previously the lock was cleared immediately and the next request retried — and CubeMaster.Resume is idempotent (a duplicate maps to "already running" → success), so the old clear-and-retry was both safe and far more available. The PR explicitly promises to keep single-replica behavior unchanged; this violates that.

Recommendation: gate the retain-ownership branch on LeaderElectionEnabled (single-instance keeps the old behavior), and/or bound the hold below the full StateLockTTL.

F2 — CursorValid("0-0") can drive a permanent registry-rebuild loop (Medium)

cube-lifecycle-manager/internal/redisstream/stream.go:141

For a 0-0 cursor on a stream key that exists but is empty, CursorValid returns EntriesAdded <= Length. When the last entries are trimmed/deleted (Length == 0, EntriesAdded > 0) this is false forever. In consumeStream that makes Read return ErrCursorTrimmed on every poll; each iteration rebuilds the whole registry from the metadata Hash (HGETALL + rebuild) and loops: rebuild → XREAD blocks StreamReadBlockCursorValid false → rebuild → … The cursor never advances (there is nothing to read), so the loop never terminates.

Trigger: any external trim removing the final entry (XTRIM/XDEL) — exactly the operational event the trim-recovery machinery exists for. Suggest treating an empty stream as valid for a 0-0 cursor (Length == 0 → true regardless of EntriesAdded); the Hash-snapshot rebuild is authoritative either way.

F3 — New Validate() compares a configurable value against a non-configurable one (Low–Medium)

cube-lifecycle-manager/internal/config/config.go:330

Validate now returns an error when StateLockTTL <= HTTPTimeout. HTTPTimeout is hard-coded at 10s with no env override, while StateLockTTL is set via CUBE_LCM_STATE_LOCK_TTL. Any deployment that tuned the lock TTL below 10s will fail to start after upgrade, and the error message references a value the operator cannot change without a code edit. Expose HTTPTimeout via env (and wire it into the chart), or compare against a documented constant.

F4 — eventApplyMu held across outbound HTTP pushes stalls promotion catch-up (Low)

cube-lifecycle-manager/cmd/cube-lifecycle-manager/main.go:620

consumeStream holds eventApplyMu across handleEvent, which on the leader performs fleet-wide HTTP pushes with an up-to-HTTPTimeout per-proxy budget; catchUpGeneration takes the same mutex for its whole pass. A slow CubeProxy therefore delays the promotion catch-up and markReconciled (and thus leader readiness) by as long as the push loop takes. This is a liveness coupling rather than a correctness bug — it only bites when a proxy is slow during failover — but serializing network I/O under a promotion-critical lock is worth a per-event timeout or a comment.

F5 — Promotion hydration is O(entries × proxies) with a Redis GET per entry (Low)

cube-lifecycle-manager/cmd/cube-lifecycle-manager/main.go:370

replayRegistryTo does a resolvePromotionState Redis GET per registry entry, per proxy, during promotion/fleet-join hydration — a burst of serial round-trips in a goroutine, over a registry snapshot that may be stale relative to concurrently-consumed events. It converges, but when the replica is already reconciled the state can be read from the in-memory registry (kept current by the consumer) instead of Redis.

Minor notes

  • stream.Read performs an extra XRANGE (via CursorValid) on every poll, doubling Redis round-trips per read cycle; consider validating only after a gap or on a slower cadence.
  • Same-state state events now re-write the Redis key and re-broadcast to the fleet on every occurrence (deliberate, tested change); this adds write/network amplification if CubeMaster emits duplicate state events.
  • resolvePromotionState prefers the Redis state key over the registry's RuntimeState. On failover, a stale paused written by a failed old leader can be re-pushed to proxies even though the registry already knows the sandbox is running; the resume path self-heals it (one spurious 503), so it's a comment-worthy judgment call rather than a bug.
  • The chart's validate.yaml guard (replicas ≥ 2 when election is enabled) and the anti-affinity are good footgun-preventers; worth a line in the chart README that single-node installs now run two CLM replicas by default.

Positive observations

  • WATCH/MULTI lease renew/release with a local deadline ~7s inside a 10s Redis TTL gives clean stale-leader fencing without Lua.
  • Versioned state CAS (v1|<streamID>|state) with legacy-value decode is a minimal, correct way to enforce monotonic state ordering.
  • The promotion sequence (catch up → drain one HTTPTimeout → catch up → markReconciled → hydrate) correctly bounds the stale-writer window.
  • Standby replicas consume the stream and serve resumes while only the reconciled leader sweeps/prunes/pushes.

Run two warm CLM replicas in Kubernetes and use a Redis lease to gate
singleton sweep and prune work while both replicas serve resume requests.

Use broadcast XREAD consumption, promotion catch-up, fencing epochs, and
versioned state CAS to prevent stale replicas from overwriting newer state.
Add Helm configuration, readiness observability, Redis transaction tests,
and bilingual documentation. Keep one-click deployment single-replica.

Signed-off-by: Hengqi Chen <hengqi.chen@gmail.com>
Signed-off-by: Hengqi Chen <hengqi.chen@gmail.com>
@chenhengqi
chenhengqi force-pushed the clm-active-standby-v3 branch from b4c4b96 to e116ea9 Compare August 26, 2026 01:55
Signed-off-by: Hengqi Chen <hengqi.chen@gmail.com>
Comment thread cube-lifecycle-manager/cmd/cube-lifecycle-manager/main.go
Comment thread cube-lifecycle-manager/cmd/cube-lifecycle-manager/main.go Outdated
Comment thread cube-lifecycle-manager/internal/redisstream/stream.go
Signed-off-by: Hengqi Chen <hengqi.chen@gmail.com>
// A transport or timeout error has an unknown server-side result.
// Preserve ownership until its TTL so another replica cannot issue a
// duplicate resume while CubeMaster may already be completing this one.
if !errors.As(resumeErr, &apiErr) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AI-generated review finding (F1, Medium). Retaining the resuming lock on a transport/timeout error regresses availability, and unconditionally so. The key is set with StateLockTTL (60s default) and, on this path, is never cleared — nothing else changes it, so AcquireResume returns resuming and every subsequent request for this sandbox goes through waitForRunning until its timeout. A single dropped HTTP connection to CubeMaster makes the sandbox un-resumable for up to ~60s.

This is a regression for single-instance deployments too (election disabled), which the PR explicitly promises to keep unchanged: the previous ClearStateNotify let the next request retry immediately, and CubeMaster.Resume is idempotent (a duplicate maps to "already running" → success), so the old clear-and-retry was both safe and more available. The cross-replica dedup rationale only applies when a second replica could actually race.

Suggest gating the retain branch on LeaderElectionEnabled and/or bounding the hold well below StateLockTTL.

return true, nil
}
info, err := c.rdb.XInfoStream(ctx, lifecycle.EventStreamKey).Result()
if err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AI-generated review finding (F2, Medium). For a 0-0 cursor on a stream key that exists but is empty, this returns EntriesAdded <= Length, which is false forever once all entries have been trimmed/deleted (Length == 0, EntriesAdded > 0). In consumeStream, Read then returns ErrCursorTrimmed on every poll, so the loop is: rebuild registry from the Hash → XREAD blocks StreamReadBlockCursorValid still false → rebuild again. The cursor never advances (nothing to read), so it never terminates — a silent, perpetual rebuild loop.

Trigger: any external trim that removes the last entry (XTRIM/XDEL), which is exactly the operational event the trim-recovery machinery exists for. Consider returning true for Length == 0 regardless of EntriesAdded — the Hash-snapshot rebuild is authoritative either way, and there is nothing to skip.

if c.HTTPTimeout <= 0 {
return errors.New("http timeout must be > 0")
}
if c.StateLockTTL <= c.HTTPTimeout {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AI-generated review finding (F3, Low–Medium). This validation compares a configurable value against a non-configurable one: HTTPTimeout is hard-coded at 10s (no env var, not in the chart), while StateLockTTL is set via CUBE_LCM_STATE_LOCK_TTL. Any existing deployment that tuned the lock TTL to ≤10s now fails to start after upgrade, and the error message points at a value the operator cannot change without a code edit. Expose HTTPTimeout via env (and wire it into the chart env block), or compare against a documented constant.

// rebuildRegistryAfterTrim reloads the Hash snapshot after MAXLEN has dropped
// events. LatestID is captured before HGETALL so the consumer does not skip
// events CubeMaster wrote between the two reads (Hash then Stream, not a
// transaction). Local LastActiveMs / RuntimeState are preserved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AI-generated review finding (F4, Low). eventApplyMu is held across handleEvent, which on the leader issues fleet-wide HTTP pushes (meta upserts/deletes, state pushes) with an up-to-HTTPTimeout per-proxy budget. catchUpGeneration takes the same mutex for its entire catch-up pass, so a slow CubeProxy delays the promotion catch-up and markReconciled (and thus the new leader becoming ready) by as long as the push loop takes. Liveness coupling rather than a correctness bug, but worth a per-event timeout or a comment explaining why the lock deliberately spans network I/O.

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.

1 participant