Skip to content

internal/rest/resources/control: Use independent context for join faiure cleanup - #777

Draft
claudiubelu wants to merge 6 commits into
canonical:v4from
claudiubelu:v4-new-cleanup-context
Draft

claudiubelu wants to merge 6 commits into
canonical:v4from
claudiubelu:v4-new-cleanup-context

Conversation

@claudiubelu

Copy link
Copy Markdown
Contributor

The controlPost reverter used r.Context() for all cleanup operations (PreRemove hook, certificate fetch, client creation).

When a join fails due to context deadline exceeded, r.Context() may already be expired, causing the entire cleanup to silently fail and leaving a ghost node in the dqlite cluster. In particular, shared.GetRemoteCertificate was silently returning due an error returned (due to r.Context() being expired), which means that internalClient.DeleteClusterMember was never called.

Use an independent context with a 2-minute timeout for cleanup operations so they can complete regardless of the original request context state.

Also add error logging to GetRemoteCertificate and client creation failures which previously returned silently.

Copilot AI 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.

Pull request overview

This PR adjusts controlPost’s failure cleanup logic so that cleanup work isn’t blocked by an already-expired request context (e.g., join timeouts), and adds missing error logs for previously silent cleanup failures.

Changes:

  • Introduces a dedicated cleanup context with a 2-minute timeout for failure reverter operations.
  • Uses that cleanup context for pre-remove hook execution, certificate retrieval, and client creation during join cleanup.
  • Adds error logging when remote certificate fetch or client creation fails during cleanup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/rest/resources/control.go Outdated
Comment on lines +95 to +99
// Use an independent context for cleanup so that it is not tied
// to the request context, which may already be expired (e.g. the
// join timed out).
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cleanupCancel()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, by deriving from the daemon's shutdown context instead.

Comment on lines 110 to 114
if joinInfo == nil || req.JoinToken == "" {
reExec, err := resetClusterMember(r.Context(), state, true)
reExec, err := resetClusterMember(cleanupCtx, state, true)
if err != nil {
logger.Error("Failed to reset cluster member on bootstrap error", slog.String("error", err.Error()))
return

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, it uses r.Context() once again.

@roosterfish roosterfish left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, could you please also add a test that triggers this?

Comment thread internal/rest/resources/control.go Outdated
// Use an independent context for cleanup so that it is not tied
// to the request context, which may already be expired (e.g. the
// join timed out).
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Instead of deriving a new context, should we rather use the daemon's shutdownCtx here to be independent of the join request but still account for the health of the daemon when running the hooks?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changed to cleanupCtx, cleanupCancel := context.WithTimeout(intState.Context, 2*time.Minute)

@roosterfish

Copy link
Copy Markdown
Contributor

Marking as draft until you get a chance to get back to it @claudiubelu.

@roosterfish
roosterfish marked this pull request as draft July 8, 2026 12:41
…tstrap or join

When Bootstrap or Join fails, the reverter closes the dqlite app but keeps
the accept channel open. The go-dqlite reader ranging over it keeps receiving
connections from the remaining cluster members and panics when it cannot dial
the closed app's local socket, crashing the daemon before the join failure
cleanup can run.

Rotate the accept channel before closing the dqlite app, like Restart already does.

Signed-off-by: Claudiu Belu <cbelu@cloudbasesolutions.com>
…ffline

On a failed join, the cluster member handling the cleanup resets the
joiner through PUT /cluster/{name}.

That endpoint required an open database, which a joiner whose dqlite
join failed never has, so the reset was refused and the joiner could
not be initialized again until its daemon was restarted.

Signed-off-by: Claudiu Belu <cbelu@cloudbasesolutions.com>
Signed-off-by: Claudiu Belu <cbelu@cloudbasesolutions.com>
@claudiubelu
claudiubelu force-pushed the v4-new-cleanup-context branch from 92fce9c to dcd1e68 Compare October 20, 2026 11:37

@claudiubelu claudiubelu left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated, as required by the comments. Note that this now depends on: #850 . The commits from that PR are contained in this PR, so the tests pass.

Comment thread internal/rest/resources/control.go Outdated
Comment on lines +95 to +99
// Use an independent context for cleanup so that it is not tied
// to the request context, which may already be expired (e.g. the
// join timed out).
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cleanupCancel()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, by deriving from the daemon's shutdown context instead.

Comment on lines 110 to 114
if joinInfo == nil || req.JoinToken == "" {
reExec, err := resetClusterMember(r.Context(), state, true)
reExec, err := resetClusterMember(cleanupCtx, state, true)
if err != nil {
logger.Error("Failed to reset cluster member on bootstrap error", slog.String("error", err.Error()))
return

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, it uses r.Context() once again.

…lure cleanup

The controlPost reverter used r.Context() for all cleanup operations
(PreRemove hook, certificate fetch, client creation).

When a join fails due to context deadline exceeded, r.Context() may already
be expired, causing the entire cleanup to silently fail and leaving a ghost node
in the dqlite cluster. In particular, shared.GetRemoteCertificate was
silently returning due an error returned (due to r.Context() being expired),
which means that internalClient.DeleteClusterMember was never called.

Use an independent context with a 2-minute timeout for cleanup
operations so they can complete regardless of the original request
context state.

Also add error logging to GetRemoteCertificate and client creation
failures which previously returned silently.

Signed-off-by: Claudiu Belu <cbelu@cloudbasesolutions.com>
For cleanup, we now use daemon's context instead of an independent
context. On the `resetClusterMember` call, `r.Context()` is used (only
used for the logger and waiting for the request to finish).

Signed-off-by: Claudiu Belu <cbelu@cloudbasesolutions.com>
Signed-off-by: Claudiu Belu <cbelu@cloudbasesolutions.com>
@claudiubelu
claudiubelu force-pushed the v4-new-cleanup-context branch from dcd1e68 to b98266a Compare October 20, 2026 12:49
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