feat(clm): add active-standby failover - #1516
chenhengqi wants to merge 1 commit into
Conversation
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. SummaryThe PR runs two warm CLM replicas behind the chart Service and uses a Redis lease ( The overall design is sound: the WATCH/MULTI lease renew/release avoids Lua, the local deadline sits safely inside the Redis TTL, FindingsF1 — Resumer transport-error retention regresses single-instance availability (Medium)
The new 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 Recommendation: gate the retain-ownership branch on F2 —
|
b4c4b96 to
e116ea9
Compare
| // 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) { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 StreamReadBlock → CursorValid 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 { |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
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>
97bc242 to
75eb1fc
Compare
| if canWrite() { | ||
| if err := push.DeleteMeta(ctx, ev.SandboxID); err != nil { | ||
| log.Warn("delete event push failed", | ||
| zap.String("sandbox_id", ev.SandboxID), zap.Error(err)) | ||
| } |
There was a problem hiding this comment.
There is a potential data leak issue here during active-standby failover:
- Delete notifications are dropped: During failover promotion catch-up and the subsequent 10s
HTTPTimeoutdrain window, the new leader has not completed reconciliation yet, soactiveLeader.IsLeader()remainsfalse, makingcanWrite()evaluate tofalse. If anOpDeleteevent is consumed during this window,reg.Delete()removes the sandbox from the local in-memory registry, butpush.DeleteMetais skipped. - Subsequent hydration cannot compensate: When the replica is finally promoted and triggers
hydrateFleet,replayRegistryToonly iterates over entries currently present inreg.Snapshot(). Since the deleted sandbox was already purged fromregin step 1, the hydrator has no tombstone record and will never push a delete request to CubeProxy. - Impact: Any sandbox destroyed while the old leader was crashing or during the promotion window will permanently leak in CubeProxy's 16MB
cube_sandbox_metashared dict, which can eventually exhaust shared memory or route traffic to ghost sandboxes.
Suggestion:
Deleting a metadata entry in CubeProxy is idempotent. If the replica already holds the Redis lease (lease.IsLeader() == true), we should consider allowing push.DeleteMeta to pass through immediately when consuming OpDelete. Alternatively, retain a short-lived tombstone in reg or perform a diff against CubeProxy during fleet hydration.
| if !l.IsLeader() { | ||
| return errLeaseExpired | ||
| } | ||
| ok, err := l.renew(ctx) | ||
| if err != nil { |
There was a problem hiding this comment.
A single renewal failure or transient WATCH conflict here causes immediate demotion, leading to frequent leader flapping and a prolonged "no-leader" vacuum:
- Immediate demotion on transient glitches:
hold()exits on the firsterr != nil(e.g. transient network timeout) or!ok(singleTxFailedErrconflict inredisLeaseStore.Renew). Oncehold()exits,Run()immediately invokesl.demote(). - Prolonged dead-lock vacuum (~7s + 10s drain): Although the leader locally stepped down, the lease key in Redis was written with a 10s TTL and remains valid for ~7 more seconds. During this window, neither the old leader nor standbys can acquire the lease via
SET NX. The cluster enters a 7-second leader vacuum, followed by a 10-secondHTTPTimeoutdrain upon new promotion—resulting in ~17 seconds of interrupted background maintenance. - Lease deadline ignored: The lease already computes a safe local deadline (
started + TTL - margin), which represents the true mathematical validity of the held lease. Demoting before this deadline arrives violates standard lease semantics (e.g. k8sleaderelection).
Suggestions:
- Retry until local deadline expires (Primary fix): In
hold(), ifrenew()fails due to transient network errors orTxFailedErr, do not immediately return and demote. As long astime.Now().Before(*deadline)holds true, keep the leader status active and retry renewal more frequently (e.g. everyRetryInterval = 1s). Only demote when the local deadline has actually expired. Also consider adding a small retry loop (e.g.maxAttempts = 3) insideredisLeaseStore.RenewforTxFailedErr. - Self-reclaim on acquire (Optimization): If an instance does demote but the key in Redis still holds its own token,
acquire()could atomically renew the lease and resume leadership without waiting for the full 10s HTTP drain, since no peer could have become leader in the interim.
| // 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) { | ||
| return errors.New("cubemaster resume result unknown: " + resumeErr.Error()) | ||
| } | ||
| // A structured non-success response is definitive; release ownership | ||
| // with an independent context so request cancellation cannot strand it. | ||
| r.clearState(sandboxID) |
There was a problem hiding this comment.
Retaining ownership here on unknown transport errors makes sense to prevent duplicate RPCs and protect against the sweeper pausing an in-flight resume. However, using the default StateLockTTL (60s) turns a transient timeout into a 1-minute DoS for that sandbox:
- The 60s lock-out issue: When an ambiguous transport error occurs, leaving
"resuming"with the full 60s TTL causes all subsequent client retries to enterwaitForRunninginacquireResumeOwnership. Since no in-flight goroutine is actually working on the resume anymore, retries will stall until client context timeout, rendering the sandbox unresponsive for a whole minute. - Why we shouldn't simply
clearState: Unconditionally deleting the key immediately is also dangerous:resumererrors out before updatingMergeLastActive, soLastActiveMsremains stale. If the key is deleted,sweeper.go'stryPausewill no longer be blocked bySETNX, and the leader's next sweep tick (within 5s) could pause the sandbox that CubeMaster just resumed or is currently resuming, causing state flapping. - The ambiguity window is bounded by
HTTPTimeout: The server-side execution window cannot exceedHTTPTimeout(10s). The problem is not the guard itself, but the parameter choice.
Suggestion:
Instead of leaving the key with StateLockTTL (60s), downgrade the marker to a short TTL corresponding to the RPC ambiguity window upon unknown error:
default:
if !errors.As(resumeErr, &apiErr) {
// Retain ownership only for the ambiguity window (HTTPTimeout),
// preventing sweeper races without locking the sandbox for 60s.
_ = r.o.Redis.SetState(ctx, sandboxID, "resuming", r.o.HTTPTimeout)
return errors.New("cubemaster resume result unknown: " + resumeErr.Error())
}
r.clearState(sandboxID)
return errors.New("cubemaster resume: " + resumeErr.Error())This preserves the guard against sweeper races and deduplicates in-flight RPCs, while allowing subsequent client retries to recover cleanly in ~10s instead of ~60s.
| default: | ||
| // Real failure. Roll back: clear the pausing state so a future | ||
| // sweep can retry, and tell CubeProxy the sandbox is back to | ||
| // running (it never actually paused). | ||
| _ = s.o.Redis.ClearStateNotify(ctx, sid) | ||
| _ = s.o.ProxyPush.SetState(ctx, sid, "running") | ||
| return errors.New("cubemaster pause: " + pauseErr.Error()) | ||
| } |
There was a problem hiding this comment.
There is a critical timing discrepancy and ambiguous error-handling flaw here that contradicts the drain window guarantees stated in the README:
-
The 1x
HTTPTimeoutdrain window is mathematically insufficient:- The README states that a newly promoted leader waits one
HTTPTimeoutdrain window so in-flight writes from the previous leader can finish. - However,
sweeper.tryPauseonly checksIsLeader()once before entry (sweeper.go:180). Its internal execution chain runs on the unboundedrootCtxwith multiple serial network operations:
ProxyPush.SetState("pausing")(up to 10s)$\to$ CubeMaster.Pause(up to 10s)$\to$ rollbackProxyPush.SetState("running")(up to 10s). - A critical section entered right before the old leader's local demotion can easily take up to 30s before dropping its final write—landing ~16s after the new leader has already completed its 10s drain and started active maintenance.
- The README states that a newly promoted leader waits one
-
Ambiguous pause errors cause ghost "running" sandboxes (traffic black hole):
- When
CubeMaster.Pausetimes out or encounters a transport reset, the server-side result is unknown (note that CubeMaster's sandbox lock TTL is 180s, confirming pause can be slow). - Treating unknown transport errors as definitive failures in
default:by callingClearStateNotifyand broadcasting"running"to CubeProxy is dangerous: if the microVM was actually paused on the host, CubeProxy will route incoming traffic directly to a frozen VM without triggering auto-resume, resulting in a persistent traffic black hole. - Note that
resumer.go:256-265already handles this exact ambiguity correctly by retaining ownership on non-APIErrortransport failures.
- When
Suggestions:
- Align
tryPausewithresumer.go: On timeout or transport failure, do not immediately clear state and broadcast"running". Treat it as ambiguous to prevent misleading the proxy when a VM might actually be frozen. - Bound
tryPause/tryKillexecution: Bound each sweep decision with a timeout not exceeding a singleHTTPTimeout. - Clarify documentation: Update the README to reflect that per-sandbox state locks (
SET NXwith TTL), rather than the HTTP drain, provide the actual hard fencing boundary for singleton actions.
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.