Skip to content

Force-apply topology on reconcile and add a per-cluster sync endpoint (#395)#400

Open
bocharov wants to merge 2 commits into
apache:unstablefrom
bocharov:feat/force-sync-and-endpoint
Open

Force-apply topology on reconcile and add a per-cluster sync endpoint (#395)#400
bocharov wants to merge 2 commits into
apache:unstablefrom
bocharov:feat/force-sync-and-endpoint

Conversation

@bocharov

@bocharov bocharov commented Jul 18, 2026

Copy link
Copy Markdown

Problem

Fixes #395. Kvrocks cluster nodes do not gossip — the controller is the sole source of topology and must push a complete, identical view to every node. Today a node can silently diverge and never recover:

  • The probe loop re-pushes CLUSTERX SETNODES only when a node's epoch is behind the stored version. A node that has drifted at an equal epoch (a dropped push, a partial view, a stale/phantom node entry) is never repaired.
  • SETNODES is sent without force, so even a re-push at an equal version is a no-op on the server (the version gate rejects a lower version and no-ops an equal one). The only prior recovery was CLUSTER RESET, which requires an empty DB and so is unusable once a node holds data. Controller's cluster_nodes push silently drops when a data node is unreachable, leaving cluster meta stale forever #395 reports exactly this: divergent cluster_nodes snapshots that never converge, with no operator-facing way to force a re-push.

Changes

  1. SyncClusterInfo(ctx, cluster, force) — when force is set, send the SETNODES force flag so the topology applies unconditionally. Because SETNODES replaces the node's whole topology, a forced push also clears stale/phantom entries without a CLUSTER RESET and without data loss. Wrapped in a bounded retry-with-backoff (a push dropped by a transient blip is otherwise not retried until the next tick).

  2. Divergence detection at an equal epoch — the reconcile loop parses cluster_known_nodes and cluster_slots_ok from CLUSTER INFO and force-pushes a node whose peer count or slot coverage disagrees with the desired topology, not only one whose epoch lags. Coverage is compared against the desired covered-slot count (not a hardcoded 16384), so a cluster that is intentionally mid-scale is not seen as drifted; a node that omits these fields falls back to epoch-only reconcile. The existing "node is ahead" adopt branch is preserved.

  3. POST /namespaces/{namespace}/clusters/{cluster}/sync — force-push the stored topology to every node and report per-node results. This is the operator-facing re-push Controller's cluster_nodes push silently drops when a data node is unreachable, leaving cluster meta stale forever #395 asks for. It performs no CLUSTER RESET, so it is safe to run on a populated cluster.

  4. Reject blank/port-less node addresses in CheckNewNodes (the create/add-node boundary) and Shard.ToSlotsString (serialization), so a half-resolved address fails loudly instead of registering a phantom, unreachable node.

Safety

The controller is the single writer of topology (failover routes through it), so "newest complete view wins" holds. Force fires only when a node's version is not greater than the store's; a node legitimately ahead keeps the existing adopt-from-node branch. Verified against Apache Kvrocks 2.16.0 that CLUSTERX SETNODES <str> <ver> force bypasses the equal-version no-op.

Tests

make test passing. New unit tests cover the CLUSTER INFO parsing (incl. sentinel/malformed values), the divergence check, address validation, the blank-address guard, and the /sync handler. A behavioral test asserts the reconcile fix by exercising the real probe path.

Kvrocks cluster nodes do not gossip: the controller is the sole source of
topology and must reliably push one complete, identical view to every node.
Three gaps let a node's view drift and never recover (see apache#395):

1. The probe loop re-pushed only a node whose epoch was behind the store, so a
   node that had drifted at an equal epoch (a dropped push, a partial view, a
   stale/phantom node entry) was never repaired.
2. CLUSTERX SETNODES was sent without force, so even a re-push at an equal
   version is a no-op on the server (the version gate rejects a lower version and
   no-ops an equal one). The only prior recovery was CLUSTER RESET, which needs
   an empty DB and is unusable once a node holds data.
3. A push dropped by a transient error was not retried before the next tick.

Changes:

- SyncClusterInfo takes a force argument and, when set, sends the SETNODES force
  flag so the topology applies unconditionally. A forced push replaces the node's
  whole topology, so it also clears stale/phantom entries without a CLUSTER RESET
  and without data loss. Wrapped in a bounded retry-with-backoff.

- The reconcile loop parses cluster_known_nodes and cluster_slots_ok from CLUSTER
  INFO and force-pushes a node whose peer count or slot coverage disagrees with
  the desired topology, not only one whose epoch lags. Coverage is compared
  against the desired covered-slot count; a node that omits these fields falls
  back to epoch-only reconcile. The node-ahead adopt branch is preserved.

- New endpoint POST /namespaces/{namespace}/clusters/{cluster}/sync force-pushes
  the stored topology to every node and reports per-node results. It performs no
  CLUSTER RESET, so it is safe to run on a populated cluster.

- CheckNewNodes and Shard.ToSlotsString reject a blank or port-less node address,
  so a half-resolved address fails loudly instead of being pushed as a phantom.

Unit tests cover the CLUSTER INFO parsing, address validation, the blank-address
guard, the divergence check, and the sync handler.
@bocharov
bocharov force-pushed the feat/force-sync-and-endpoint branch from 998ffa0 to 3abbe98 Compare July 18, 2026 00:58
@git-hulk
git-hulk self-requested a review July 18, 2026 02:04
@codecov-commenter

codecov-commenter commented Jul 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.30435% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 50.77%. Comparing base (6c56470) to head (807123c).
⚠️ Report is 109 commits behind head on unstable.

Files with missing lines Patch % Lines
store/cluster_node.go 89.65% 3 Missing ⚠️
controller/cluster.go 91.30% 0 Missing and 2 partials ⚠️
server/api/shard.go 0.00% 0 Missing and 2 partials ⚠️
server/route.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           unstable     #400      +/-   ##
============================================
+ Coverage     43.38%   50.77%   +7.39%     
============================================
  Files            37       45       +8     
  Lines          2971     4169    +1198     
============================================
+ Hits           1289     2117     +828     
- Misses         1544     1832     +288     
- Partials        138      220      +82     
Flag Coverage Δ
unittests 50.77% <91.30%> (+7.39%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…paths

Raise patch coverage of the reconcile/sync change by covering the branches the
initial tests left out:

- SyncClusterInfo bounded retry: exhausting the retries returns the last error,
  and a cancelled context short-circuits the backoff (no live server needed —
  the node points at a closed port).
- Cluster.SyncToNodes force-pushes every node and propagates a node failure.
- The reconcile loop's probe error handling: a node restoring from backup is
  skipped, an unreachable node increments its failure count, and an
  uninitialized (CLUSTERDOWN) node is force-pushed rather than counted as a
  failure.
- The /sync handler's cluster-not-found branch.
- ClusterMockNode gains an injectable GetClusterInfo error so the probe error
  paths above can be driven without a live node; its new hooks are covered
  directly from the store package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bocharov

Copy link
Copy Markdown
Author

Addressed the Codecov patch-coverage feedback in 807123c. Added unit tests for the previously-uncovered branches:

  • SyncClusterInfo bounded retry/backoff — retry exhaustion and context-cancel during backoff.
  • Cluster.SyncToNodes — force-push to every node and node-level error propagation.
  • Reconcile probe error paths — node restoring-from-backup is skipped, an unreachable node increments its failure count, and an uninitialized (CLUSTERDOWN) node is force-pushed rather than counted as a failure.
  • The /sync handler's cluster-not-found path.

go test/go vet/-race all clean locally. The workflow run is pending approval (fork PR) — would appreciate a committer approving it so CI + Codecov can re-evaluate. Thanks!

Comment thread store/cluster_shard.go
Comment on lines +425 to +428
host, port, err := net.SplitHostPort(node.Addr())
if err != nil || host == "" || port == "" {
return "", fmt.Errorf("node %s has invalid address %q", node.ID(), node.Addr())
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can add a function validateAddr for node.

Comment thread store/cluster_node.go
// entries (e.g. empty-address ghosts) without the data-destroying CLUSTER RESET the alternative requires.
// Safe because the controller is the single writer of topology; callers must not force a version older
// than a node legitimately ahead of the store (the probe loop keeps the `node ahead` branch for that).
func (n *ClusterNode) SyncClusterInfo(ctx context.Context, cluster *Cluster, force bool) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we add a retry policy for this with:

  • max tries
  • retry delay
  • is force sync

Comment thread controller/cluster.go
Comment on lines +250 to +256
func topologyDiverged(info *store.ClusterInfo, wantNodes, wantSlots int64) bool {
if info == nil || info.KnownNodes < 0 || info.SlotsOk < 0 {
return false
}
return info.KnownNodes != wantNodes || info.SlotsOk != wantSlots
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Move this to a function of ClusterInfo

@git-hulk

git-hulk commented Jul 25, 2026

Copy link
Copy Markdown
Member

@bocharov Thanks for your contribution, a few comments inline.

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.

Controller's cluster_nodes push silently drops when a data node is unreachable, leaving cluster meta stale forever

3 participants