Skip to content

feat(control-plane): Expose node isolation in CubeOps/WebUI and converge multi-replica CubeMaster state near-real-time via Redis Stream - #1309

Draft
fslongjin wants to merge 1 commit into
TencentCloud:masterfrom
fslongjin:feat/node-isolation-control-stream
Draft

feat(control-plane): Expose node isolation in CubeOps/WebUI and converge multi-replica CubeMaster state near-real-time via Redis Stream#1309
fslongjin wants to merge 1 commit into
TencentCloud:masterfrom
fslongjin:feat/node-isolation-control-stream

Conversation

@fslongjin

Copy link
Copy Markdown
Member

feat(control-plane): Expose node isolation in CubeOps/WebUI and converge multi-replica CubeMaster state near-real-time via Redis Stream

TL;DR

Dimension Before After
Isolation entry points Only cubemastercli + CubeMaster HTTP (port 8089) 4 entry points: WebUI node detail · CubeOps PUT/DELETE /api/v1/nodes/{id}/isolation · CubeMaster HTTP · cubemastercli
API response fields Isolation state not exposed Every nodeView carries an extra schedulingDisabled: boolean
Multi-replica consistency Relies on nodemeta periodic DB reload (typical 60s wait window) DB remains the source of truth; a new Redis Stream cube:v1:shared:master:control:events broadcasts events → every replica's in-memory view converges within seconds; DB reload is demoted to a safety net
Unisolate Same as above — CLI / HTTP only WebUI row menu + CubeOps DELETE also supported
Documented entry points Docs cover only 2 Bilingual docs cover all 4; the 60s wait window is clarified as the "in-flight create-RPC window", not cache lag

Type: 🚀 Feature (new functionality)
Components touched: CubeMaster control plane · CubeOps backend · Web frontend · bilingual docs


🎯 Background & Motivation

Node isolation (scheduling cordon / uncordon) is a routine operations action:

  • Block scheduling before upgrades: prevent new sandboxes from landing on a node that is about to go under maintenance.
  • Stop the bleed on a sick node: when a node has hardware / network issues, drive its scheduling traffic to zero quickly for diagnosis.
  • Batch maintenance (e.g., K8s node-pool upgrade): keep the node draining existing sandboxes but stop accepting new ones.

Previously this action was only available via cubemastercli or CubeMaster HTTP, which made the ops mental load heavier. Worse, in multi-CubeMaster-replica deployments, the scheduling view between a new replica or different replicas was kept in sync only by nodemeta.loopReload (a periodic refresh, by default every tens of seconds) — meaning operators had to wait a whole window before another replica "saw" an isolation take effect.

This PR closes both gaps in one go:

  1. Lift isolation into WebUI / CubeOps so zero-CLI operations can complete it.
  2. Add a Redis Stream control-plane channel so multi-replica in-memory scheduling views converge within seconds after a DB write; MySQL remains the single source of truth, with periodic reload covering any missed events.

🏗️ Architecture at a Glance

flowchart TB
    subgraph Clients["Client Entry Points"]
        WebUI["WebUI (Nodes / NodeDetail)"]
        CubeOps["CubeOps REST"]
        CMHTTP["CubeMaster HTTP :8089"]
        CLI["cubemastercli"]
    end

    subgraph CM["CubeMaster Replica (any peer)"]
        direction TB
        OpsH["CubeOps handler writeIsolation"]
        Client["cubemaster.Client PUT/DELETE /internal/meta/nodes/{id}/isolation"]
        MetaSvc["meta.writeIsolation"]
        SetNode["nodemeta.SetNodeSchedulingDisabled"]
        LocalCache["local snapshot + syncLocalcache"]
        Pub["controlevents.Publisher.PublishNodeIsolation (best-effort)"]
        subgraph Peers["Other CubeMaster Replicas"]
            direction TB
            ReplA["consumer.Run — Replica A"]
            ReplB["consumer.Run — Replica B"]
            ReplC["consumer.Run — Replica C"]
        end
        Apply["nodemeta.ApplySchedulingDisabledLocal"]
    end

    DB[("MySQL node_meta (source of truth)")]
    Stream[("Redis Stream cube:v1:shared:master:control:events")]

    WebUI --> OpsH
    CubeOps --> OpsH
    CMHTTP --> MetaSvc
    CLI --> MetaSvc
    OpsH --> Client
    Client --> MetaSvc
    MetaSvc --> SetNode
    SetNode --> DB
    SetNode --> LocalCache
    SetNode -.best-effort.-> Pub
    DB -.loopReload safety net.-> SetNode
    Pub -->|XADD MAXLEN ~10000| Stream
    Stream -.XREAD $ BLOCK 5s broadcast.-> ReplA
    Stream -.XREAD $ BLOCK 5s broadcast.-> ReplB
    Stream -.XREAD $ BLOCK 5s broadcast.-> ReplC
    ReplA --> Apply
    ReplB --> Apply
    ReplC --> Apply
    Apply --> LocalCache

    classDef db fill:#fde,stroke:#c33,color:#000
    classDef stream fill:#def,stroke:#37c,color:#000
    classDef peer fill:#efe,stroke:#393,color:#000
    classDef critical fill:#fee,stroke:#e33,color:#000
    class DB db
    class Stream stream
    class ReplA,ReplB,ReplC peer
    class Pub critical
Loading

DB reload is the safety net: if the stream is trimmed by MAXLEN, a replica misses an event, or Redis flaps, the next loopReload still pulls the correct SchedulingDisabled from MySQL.


📦 Changes

A. CubeMaster control plane

A new controlevents package (publisher / consumer / schema / apply / init + tests) is responsible for broadcasting "node isolation" events to every CubeMaster replica via a Redis Stream: once the local replica finishes its DB write it publishes best-effort; other replicas consume via XREAD $ BLOCK and callback into nodemeta to mutate their local view and localcache; missed events are covered by DB reload. At startup the package issues PING / TYPE against Redis — any failure is fatal.

nodemeta/isolation.go appends a best-effort event publish at the tail of the existing DB-write function; a symmetric "local apply" function (no DB write) is added for consumer callbacks; the two paths are mutually exclusive via lockNodeLabels.

main.go inserts controlevents.Init into the coreInit order, after nodemeta and before scheduler, as a fatal dependency — Redis unavailable ⇒ CubeMaster refuses to start.

rediskey gains a MasterControlEvents() constructor, yielding cube:v1:shared:master:control:events (Stream, shared scope, XADD MAXLEN ~ 10000).

B. CubeOps backend

Two new REST endpoints, PUT/DELETE /api/v1/nodes/{nodeID}/isolation, are added. The handler is a thin pass-through to CubeMaster's existing isolate / unisolate REST; the response is the updated full nodeView, now including a schedulingDisabled field. The nodeView struct, the CubeMasterClient interface, and cubemaster.Client all gain the corresponding methods (including a PUT helper), and CubeOps/README.md gets two extra lines. Both endpoints have no request body and are idempotent.

On the test side, cluster_test.go has its list assertion rewritten and three new cases (isolate / unisolate / 404) added; fake_cm_* get their stubs. CubeOps has no direct coupling to the new controlevents package — it merely wraps the existing CubeMaster REST into two client methods; stream broadcasting happens entirely inside CubeMaster.

C. Web frontend

A generic IsolateConfirmDialog (Portal, reused by the list page and the detail page) is introduced, exposing open / onClose / onConfirm / pending / error props and an error-formatting helper.

The list page Nodes.tsx adds a ⋯ menu at the top-right of every node card: when not isolated, the menu shows a destructive "Isolate" item (opens the global dialog); when isolated, it shows an "Unisolate" item (submits directly); the card title displays an "Isolated" badge driven by schedulingDisabled; the bottom conditions use the unified readable text and colour scheme.

The detail page NodeDetail.tsx adds an isolate / unisolate button to the Header before the heartbeat time. The icon switches with state (ShieldOff / ShieldCheck); isolate goes through the confirm dialog, unisolate submits directly; the title carries the same "Isolated" badge; on success, a toast fires and both the current-node and the list caches are invalidated; errors are rendered as a red banner at the top of the page.

The API client gains isolate / unisolate; the schema gains the schedulingDisabled field; utils.ts gains formatting and colour helpers for condition badges (reused by the list and the detail-page ConditionRow). Bilingual locales are extended with isolation-related copy (12 keys in the detail page + 1 key in the list page).

D. Docs

The bilingual node-isolation.md extends the entry-point list from 2 to 4 (WebUI · CubeOps · CubeMaster HTTP · cubemastercli), adds a "DB + Stream" mechanism section and a "Multi-replica CubeMaster" scope note, and clarifies the 60s wait window as the in-flight create-RPC pipeline window rather than a cache delay.

The bilingual redis-key-spec.md registers the new Stream key, its field contract, and the MAXLEN ~ 10000 TTL policy. EN and ZH are fully aligned — no single-side updates.

… Stream

- Add controlevents package: publish node isolation events to a shared Redis Stream so every CubeMaster replica converges in-memory views within seconds after a DB write. MySQL remains the source of truth; nodemeta.loopReload covers missed events.
- Wire CubeMaster main.go: controlevents.Init is a fatal dependency in coreInit (Redis unreachable => CubeMaster refuses to start).
- Extend nodemeta/isolation.go: append a best-effort event publish at the tail of the DB-write function; add a symmetric ApplySchedulingDisabledLocal for consumer callbacks (mutually exclusive via lockNodeLabels).
- Register cube:v1:shared:master:control:events (Stream, shared scope, XADD MAXLEN ~ 10000) in rediskey + redis-key-spec.md.
- CubeOps: add PUT/DELETE /api/v1/nodes/{nodeID}/isolation (thin pass-through to CubeMaster REST, no request body, idempotent); expose schedulingDisabled on every nodeView; extend CubeMasterClient interface and cubemaster.Client with IsolateNode/UnisolateNode (+ PUT helper).
- Web: introduce IsolateConfirmDialog (Portal), add Isolate / Unisolate actions on Nodes list (Radix dropdown menu) and NodeDetail header, show an Isolated badge, format condition badges via new utils.ts helpers. Bilingual locales extended.
- Docs: extend node-isolation.md (4 entry points, DB+Stream mechanism, multi-replica scope, 60s wait window clarified as create-RPC pipeline window).

Signed-off-by: jinlong <jinlong@tencent.com>
// controlevents fans out control-plane mutations (e.g. node isolation) to
// every Cubemaster replica via Redis Stream. Redis is a hard dependency:
// without it multi-replica cordon convergence is not guaranteed.
if err := controlevents.Init(ctx, nodemeta.ApplySchedulingDisabledLocal); 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.

This makes Redis a fatal startup dependency for CubeMaster. Previously a Redis outage at boot was explicitly non-fatal (lifecycle.Init warns and continues — see the comment just above). Now a PING/TYPE failure aborts coreInitstdlog.Fatalf, so a transient Redis blip during a CubeMaster restart bricks the whole control plane.

For a single-replica deployment the stream provides no convergence value (nothing to fan out to), yet the hard dependency still applies. Consider either:

  • making controlevents.Init non-fatal (log a warning and degrade to DB-reload-only convergence, matching lifecycle's pattern), or
  • gating the fatal requirement on an explicitly multi-replica config.

At minimum this operational regression should be called out in the docs, since deploy/kubernetes/chart/files/cube-master/conf.yaml ships with Redis but a restart during a Redis maintenance window would now fail to boot.

}
// Confirm the Redis server accepts stream commands. TYPE on a missing key
// returns "none"; any transport/command error is fatal at startup.
if _, err := pool.Do("TYPE", EventStreamKey); 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.

TYPE on a missing key returns "none" with no error, so this probe passes even when cube:v1:shared:master:control:events already exists as a different key type (e.g. a leftover String/Hash from a bad deploy or a name collision). In that case XADD/XREAD fail with WRONGTYPE at runtime, and because the publisher swallows XADD errors (publisher.go best-effort), the failure is completely silent — multi-replica convergence silently degrades to DB reload while operators believe the stream is healthy.

Suggest asserting the returned type is "none" or "stream" and failing fast otherwise, e.g.:

kind, err := redis.String(pool.Do("TYPE", EventStreamKey))
if err != nil {
    return fmt.Errorf("controlevents: redis TYPE %s failed: %w", EventStreamKey, err)
}
if kind != "none" && kind != "stream" {
    return fmt.Errorf("controlevents: key %s exists as type %q, want stream", EventStreamKey, kind)
}

if err != nil {
return nil, "", err
}
_ = ctx

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ctx is never actually used — the blocking XREAD BLOCK 5000 cannot be interrupted on shutdown, so the consumer goroutine lingers up to ~5s after ctx is cancelled and holds a pooled Redis connection blocked for the full 5s on every iteration.

With the default max_active: 32 this is fine, but on a deployment with a small pool this permanently consumes one connection (and one MaxActive slot) per replica, and shutdown is delayed by up to 5s + the 1s error-retry sleep. Consider XREAD BLOCK in a shorter loop that checks ctx.Done() between reads (e.g. block 1s and re-check), or pass a context-derived deadline.

nodeID, disabled, changed, out.SchedulingDisabled)

// Fan out to all Cubemaster replicas via Redis Stream (best-effort).
controlevents.PublishNodeIsolationDefault(ctx, nodeID, disabled)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The event is published even when changed == false (idempotent re-PUT/DELETE on a node already in the target state). Every replica then receives and re-applies a no-op, generating redundant stream traffic and log noise. Consider publishing only when changed:

if changed {
    controlevents.PublishNodeIsolationDefault(ctx, nodeID, disabled)
}

Minor, but this also keeps the stream free of duplicate entries that the 10k MAXLEN cap would otherwise have to trim.

@cubesandboxbot

cubesandboxbot Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review: PR #1309 — Node isolation in CubeOps/WebUI + Redis Stream multi-replica convergence

AI-generated review — no human approval implied.

Overall

A well-structured feature. The controlevents package is cleanly separated from nodemeta (the apply func is injected, avoiding an import cycle), the publish-after-DB-commit ordering is correct (MySQL stays authoritative, the stream only accelerates peer convergence), and the broadcast model (each replica independently XREADs from $, no consumer group) matches the stated goal. Tests are meaningful: XADD arg shape, payload JSON, unknown-op / missing-node apply, XREAD reply parsing, and CubeOps isolate/unisolate/404 plus the new schedulingDisabled field. Docs are updated in both languages, and the 60s wait-window clarification is accurate (create-pipeline window, not cache lag).

No clear correctness blockers. The findings below are robustness/design concerns, roughly ordered by severity.

Findings

1. Redis becomes a fatal startup dependency (Medium)

main.go inserts controlevents.Init into coreInit and any PING/TYPE failure aborts boot. Previously a Redis outage at CubeMaster startup was explicitly non-fatal — lifecycle.Init warns and continues (see the comment directly above the new block). Now a transient Redis blip during a CubeMaster restart kills the whole control plane. For a single-replica deployment the stream provides no convergence value (nothing to fan out to), yet the hard dependency still applies. Suggest making Init non-fatal (degrade to DB-reload-only convergence, matching lifecycle's pattern) or gating the fatal requirement on an explicitly multi-replica config; at minimum document the operational regression.

2. Startup TYPE probe does not validate the key is a Stream (Medium)

init.go only checks that TYPE <key> returns no error. TYPE on a missing key returns "none" (no error), so Init passes even if cube:v1:shared:master:control:events already exists as a different type (leftover String/Hash, or a name collision). XADD/XREAD then fail with WRONGTYPE at runtime, and because the publisher swallows XADD errors (best-effort), the failure is silent — multi-replica convergence silently degrades to DB reload while operators believe the stream is healthy. Check kind == "none" || kind == "stream" and fail fast otherwise.

3. Consumer XREAD ignores ctx (Low)

readOnce never uses the context, so the blocking XREAD BLOCK 5000 cannot be interrupted on shutdown (goroutine lingers up to ~5s) and holds a pooled Redis connection blocked for the full 5s on every iteration. With the default max_active: 32 this is fine, but a low-pool deployment permanently consumes one MaxActive slot per replica. Consider a shorter block (e.g. 1s) with a ctx.Done() re-check, or a context-derived deadline.

4. Publish on no-op writes (Low)

SetNodeSchedulingDisabled publishes the event even when changed == false (idempotent re-PUT/DELETE). Every replica receives and re-applies a no-op, generating redundant stream traffic and log noise. Consider publishing only when changed.

5. Frontend: unisolate from the list page has no error feedback (Low)

Nodes.tsx unisolate mutation has no onError; a failed unisolate from the row menu is silent. On the detail page, IsolateConfirmDialog keeps a stale isolate.error after close/reopen (it is only cleared on the next onMutate, and NodeDetail.tsx never calls isolate.reset()).

6. Design note: a stale DB reload can clobber a freshly applied stream event (pre-existing, Low)

nodemeta.mergeReloadResult overwrites existing.Labels unconditionally without taking lockNodeLabels. A periodic reload whose SELECT predates the origin's commit can momentarily revert a just-applied cordon on a peer until the next reload. This hazard pre-exists on the origin's own write path; the stream narrows the convergence window but does not remove it. Worth a sentence in the docs.

Things that look right

  • PublishNodeIsolationDefault no-ops safely when Init has not run (nil default publisher / nil pool / empty nodeID guarded).
  • Best-effort publish placed after the DB commit — DB remains authoritative, and a Redis hiccup cannot roll back a committed write.
  • ApplySchedulingDisabledLocal and SetNodeSchedulingDisabled are serialized per node via lockNodeLabels; the apply path is idempotent and skips unknown nodes (covered by the next DB reload).
  • CubeOps writeIsolation correctly maps CubeMaster 130404 → HTTP 404 via writeCMError, and the new schedulingDisabled field flows through cmNodeSnapshotnodeViewmapNode consistently (with a safe ?? false default for older backends).
  • @radix-ui/react-dropdown-menu, Badge tone="warn", Button variant="destructive/ghost", showToast, and the Card exports all already exist in the base tree, so the frontend additions should compile as-is.

@fslongjin
fslongjin marked this pull request as draft August 7, 2026 07:35
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.

3 participants