Skip to content

fix(controller): periodic resync to pick up new nodes in source clusters#26

Merged
Andrei Kvapil (kvaps) merged 8 commits into
cozystack:mainfrom
IvanHunters:fix/periodic-resync-for-new-nodes
Jun 23, 2026
Merged

fix(controller): periodic resync to pick up new nodes in source clusters#26
Andrei Kvapil (kvaps) merged 8 commits into
cozystack:mainfrom
IvanHunters:fix/periodic-resync-for-new-nodes

Conversation

@IvanHunters

@IvanHunters IvanHunters commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Problem

When a worker node is added to a source cluster, the ClusterMesh CR does not
change, so the controller receives no watch event. The reconciler never runs,
never creates a Peer object for the new node, and any hostNetwork workload on
that node (such as the ceph-csi-cephfs nodeplugin) cannot reach the remote
cluster. The failure manifests as mount errors with "Module ceph not found"
and "no mds up" until a manual reconcile is triggered via annotation.

Solution

Add syncRequeueAfter (5 minutes) that fires after every successful,
fully-converged reconcile. This caps the worst-case delay between a node
joining and its Peer appearing at 5 minutes:

node joins → kilo daemon writes annotations → next sync window → Peer created → WireGuard tunnel up → CSI mount succeeds

The existing bootstrapRequeueAfter (30 s) is retained for the incomplete
state, so initial cluster setup latency is unchanged. On error, RequeueAfter
remains zero so controller-runtime's exponential backoff is unaffected.

Summary by CodeRabbit

  • Refactor

    • Reworked cluster mesh reconciliation scheduling to choose requeue timing based on reconciliation progress, including a 5-minute periodic resync after full convergence.
    • If the mesh is still bootstrapping, reconciliation now retries sooner for quicker completion.
    • On reconciliation errors, the controller relies on standard rate-limiting/backoff behavior.
    • Updated node watch enqueueing to use consistent request keys per source cluster.
  • Tests

    • Added unit tests validating requeue timing for error, bootstrapping, synced, and done scenarios.

The reconciler watches only ClusterMesh CRs. When a new worker node joins
a source cluster the CR itself does not change, so the controller receives
no watch event and never creates a Peer for the new node. Any workload
running hostNetwork=true on that node (e.g. ceph-csi-cephfs nodeplugin)
cannot reach the remote cluster until a manual reconcile is triggered.

Add syncRequeueAfter (5 min) that fires on every successful, fully-converged
reconcile. This caps the worst-case lag between a node joining and its Peer
appearing at 5 minutes, covering the race: node joins → kilo daemon writes
annotations → next sync window → Peer created → WireGuard tunnel up.

The faster bootstrapRequeueAfter (30 s) is retained for the incomplete
state (cluster still bootstrapping), so initial setup latency is unchanged.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2c39c36b-154a-493e-9e0b-815b883284ea

📥 Commits

Reviewing files that changed from the base of the PR and between 3a6cc2e and 46ea373.

📒 Files selected for processing (2)
  • internal/controller/clustermesh_controller.go
  • internal/controller/requeue_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/controller/requeue_test.go
  • internal/controller/clustermesh_controller.go

📝 Walkthrough

Walkthrough

Replaces the boolean-based incomplete flow with a typed reconcileState enum (Done/Bootstrap/Synced) and centralizes requeue decisions via a selectResult(state, err) helper. The internal reconcile() method is refactored to return reconcileState instead of a boolean, and watch registration is updated to use typed controller-runtime handlers. All three result paths are validated with a parallel unit test.

Changes

Typed reconciliation state and periodic sync requeue

Layer / File(s) Summary
reconcileState type and selectResult helper
internal/controller/clustermesh_controller.go
Adds syncRequeueAfter = 5 * time.Minute constant and introduces reconcileState enum (Done/Bootstrap/Synced) with a selectResult(state, err) helper. The helper returns ctrl.Result{} plus the original error for backoff when err != nil, bootstrapRequeueAfter when bootstrapping, syncRequeueAfter when synced, and ctrl.Result{} with no requeue when done. Updates imports to include controller-runtime packages.
Reconcile method integration
internal/controller/clustermesh_controller.go
Updates ClusterMeshReconciler.Reconcile to receive a typed reconcileState from r.reconcile() and delegates all result selection to selectResult(state, err) instead of inlining incomplete-based logic.
reconcile() method refactoring
internal/controller/clustermesh_controller.go
Refactors the internal reconcile(ctx, meshName) method to return (reconcileState, error) instead of (bool incomplete, error). Maps terminal paths (NotFound, finalizer deletion, fatal errors) to reconcileStateDone, and computes final state (reconcileStateBootstrap when incomplete, reconcileStateSynced otherwise) before returning.
Controller watch registration with typed handlers
internal/controller/clustermesh_controller.go
Updates SetupWithManager and nodeToClusterMeshRequests to use typed controller-runtime watch and enqueue primitives: per-source-cluster Node watches via source.Kind(), typed map/enqueue via handler.TypedEnqueueRequestsFromMapFunc and handler.TypedMapFunc, and request key construction via types.NamespacedName.
selectResult unit tests
internal/controller/requeue_test.go
Adds a parallel table-driven TestSelectResult covering all three result paths: error path (RequeueAfter=0, error wraps sentinel), bootstrap path (RequeueAfter=bootstrapRequeueAfter), synced path (RequeueAfter=syncRequeueAfter), and done path (RequeueAfter=0 with no requeue).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • cozystack/kilo-clustermesh-operator#18: Both PRs modify internal/controller/clustermesh_controller.go's reconcile requeue behavior—this PR centralizes requeue timing via a typed reconcileState and selectResult(state, err) helper with syncRequeueAfter, while the retrieved PR changes when the controller computes the incomplete condition during node validation.

Suggested reviewers

  • Arsolitt

Poem

🐇 A state machine hops into place,
No more booleans muddying the trace.
Bootstrap fast or sync every five,
selectResult keeps the mesh alive.
Typed and tested—requeue done right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main change: introducing periodic resync to detect new nodes in source clusters, which directly addresses the core problem outlined in the PR objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a periodic reconciliation interval (syncRequeueAfter set to 5 minutes) for fully-converged cluster meshes to handle node scale-out scenarios where remote nodes do not trigger ClusterMesh CR events. However, the review highlights a critical issue where this logic triggers an infinite reconciliation loop for deleted ClusterMesh resources, and suggests introducing a requeue flag to restrict periodic syncs to steady-state reconciliations.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 136 to 141
if err == nil {
if incomplete {
return ctrl.Result{RequeueAfter: bootstrapRequeueAfter}, nil
}
return ctrl.Result{RequeueAfter: syncRequeueAfter}, 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.

critical

This change introduces an infinite reconciliation loop for deleted ClusterMesh resources.

When a ClusterMesh is deleted, r.Get returns a NotFound error, which reconcile ignores and returns false, nil. Because err is nil and incomplete is false, this block will return ctrl.Result{RequeueAfter: syncRequeueAfter}, nil, causing the controller to periodically reconcile the non-existent resource forever.

Similarly, when a resource is being deleted or when the finalizer is first added, we should not schedule a periodic requeue.

To fix this, update the reconcile method to return a requeue boolean indicating whether a periodic sync is actually needed (i.e., only when we successfully reach the end of a steady-state reconciliation).

Suggested change
if err == nil {
if incomplete {
return ctrl.Result{RequeueAfter: bootstrapRequeueAfter}, nil
}
return ctrl.Result{RequeueAfter: syncRequeueAfter}, nil
}
if err == nil && requeue {
if incomplete {
return ctrl.Result{RequeueAfter: bootstrapRequeueAfter}, nil
}
return ctrl.Result{RequeueAfter: syncRequeueAfter}, nil
}

Extract the three-case dispatch (error/incomplete/converged) into
selectResult so it can be unit-tested without envtest. Add table-driven
tests for all three cases: error path (zero RequeueAfter, backoff
preserved), bootstrap path (30 s), and steady-state sync path (5 m).

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
@IvanHunters
IvanHunters marked this pull request as ready for review June 23, 2026 14:01
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/controller/clustermesh_controller.go`:
- Around line 145-148: Add a blank line after the closing brace of the if
incomplete block to satisfy the wsl_v5 and nlreturn linters. The return
statement following the if block needs proper spacing separation to comply with
whitespace linting rules. Insert a blank line between the closing brace of the
if incomplete block and the final return statement that returns ctrl.Result with
syncRequeueAfter.
- Around line 125-148: The selectResult function cannot distinguish between a
fully converged mesh that should be periodically synced and a non-existent or
deleted ClusterMesh that should not be requeued. When reconcile() returns
(false, nil) for a NotFound scenario via client.IgnoreNotFound(err),
selectResult still schedules a periodic requeue with syncRequeueAfter, causing
unnecessary no-op reconciles every 5 minutes. Add an explicit parameter (such as
a "shouldSync" boolean flag or a richer reconcile status enum) to selectResult
alongside the existing incomplete and err parameters, then update the
reconcile() function to return this state information so that NotFound paths can
return no requeue while converged existing meshes still use periodic sync.

In `@internal/controller/requeue_test.go`:
- Line 31: Move the sentinel error variable from inside the test function to a
package-level variable declaration above the test function. Replace the inline
errors.New("reconcile failed") call with a package-level var statement that
creates this sentinel error once, then reference this package variable within
the test instead of creating a new error each time. This satisfies the err113
lint rule which requires sentinel errors to be package-level variables rather
than created inline within functions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6f5f778d-1ea7-471b-9253-2c70ae845375

📥 Commits

Reviewing files that changed from the base of the PR and between 1469e48 and 9ac9773.

📒 Files selected for processing (2)
  • internal/controller/clustermesh_controller.go
  • internal/controller/requeue_test.go

Comment thread internal/controller/clustermesh_controller.go Outdated
Comment thread internal/controller/clustermesh_controller.go Outdated
Comment thread internal/controller/requeue_test.go Outdated
Worst-case delay for a new node to receive a Peer drops from ~5 min to
~1.5 min (1 min sync tick + up to a few 30 s bootstrap cycles while kilo
writes annotations). Cost is one extra List call per minute per
ClusterMesh CR, which is negligible.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
Instead of relying solely on a periodic resync to detect new nodes, register
a Node watch on every source cluster's informer cache. When a node is added
to or removed from a source cluster, the watch immediately enqueues every
ClusterMesh that references that cluster, triggering Peer creation without
any polling delay.

The periodic syncRequeueAfter is kept at 5 minutes as a safety-net fallback
for missed events after watch reconnects. The bootstrapRequeueAfter (30 s)
path is unchanged for clusters still converging.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>

@kvaps Andrei Kvapil (kvaps) left a comment

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.

Changes requested. The Node-watch design is solid (immediate Peer sync on node join, with ChangeWatcher restarting on registry-affecting changes and the 5-minute resync as a fallback). One blocker before merge:

Deleted/non-existent ClusterMesh gets requeued forever. reconcile() returns (false, nil) for the client.IgnoreNotFound path (clustermesh_controller.go:240), which selectResult(false, nil) maps to the converged branch and schedules syncRequeueAfter (:152). Every ClusterMesh that is ever deleted then leaves a permanent 5-minute no-op reconcile (Get→NotFound→requeue) in the work queue for the lifetime of the operator process. Pre-PR this path returned an empty ctrl.Result{}. This is exactly what the Gemini and CodeRabbit comments flagged — note the CodeRabbit thread is marked "Addressed" but the fix is not present on HEAD.

Suggested fix: thread an explicit "should periodically sync" signal out of reconcile() (a third state, or a synced bool) so only a live, fully-reconciled mesh arms syncRequeueAfter; NotFound / deletion / finalizer-add paths should return ctrl.Result{}. Please add a selectResult test case for the NotFound path — the current table can't reach it, so the bug slips through.

Minor (non-blocking):

  • SetupWithManager:178-179 comment says the predicate reacts to "Ready condition", but GenerationChangedPredicate doesn't fire on status.conditions changes (no generation bump). ValidateNode doesn't check Ready, so it's harmless, but the comment is misleading.
  • AnnotationChangedPredicate fires on any annotation change, including kilo.squat.ai/discovered-endpoints churn — fine (and useful for endpoint enrichment), just worth being aware of at scale.
  • Consider making syncRequeueAfter / bootstrapRequeueAfter configurable flags.
  • The PR description only covers the 5-minute periodic resync; please mention the Node watch, which is now the primary mechanism.

selectResult mapped reconcile()'s (false, nil) result to the converged
branch and scheduled syncRequeueAfter. The NotFound path (deleted or
non-existent ClusterMesh) also returns (false, nil), so every deleted mesh
left a permanent 5-minute no-op reconcile in the work queue for the lifetime
of the operator process — an unbounded leak under tenant churn.

Replace the incomplete bool threaded out of reconcile() with a reconcileState
enum whose zero value (reconcileStateDone) schedules no requeue. Terminal
paths (NotFound, deletion, finalizer-add, CIDR overlap) now return
reconcileStateDone; only a live, fully reconciled mesh arms syncRequeueAfter
and a still-converging mesh arms bootstrapRequeueAfter. Add a selectResult
test case covering the no-requeue terminal path.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>

@kvaps Andrei Kvapil (kvaps) left a comment

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.

The blocker (deleted/terminal ClusterMesh scheduled a perpetual 5-minute no-op requeue) is resolved in 46ea373: reconcile() now returns a reconcileState whose zero value schedules no requeue, terminal paths (NotFound, deletion, finalizer-add, CIDR overlap) return it, and the new test case covers the no-requeue path. CI is green (build, test, integration, lint). Approving.

@kvaps
Andrei Kvapil (kvaps) merged commit 4f83ce8 into cozystack:main Jun 23, 2026
9 checks passed
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