Skip to content

internal/rest/resources: Allow forced removal of pending members - #774

Open
claudiubelu wants to merge 1 commit into
canonical:v4from
claudiubelu:v4-cleanup-dqlite
Open

claudiubelu wants to merge 1 commit into
canonical:v4from
claudiubelu:v4-cleanup-dqlite

Conversation

@claudiubelu

@claudiubelu claudiubelu commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

A freshly bootstrapped member stays PENDING until the first heartbeat. If a second member joins in that window and its join fails after dqlite added it, the join failure cleanup sends a forced DELETE for it, which was refused with "Cannot remove cluster members, there are no remaining non-pending members".

The failed joiner was then left behind in core_cluster_members, the truststore and dqlite, and was never reset. As its dqlite app is closed, the cluster cannot reliably reach its dqlite leader anymore, which blocks token generation and further joins until the member is removed manually.

Skip the pending members check for the forced removal of a member that is itself pending, as is always the case for a failed joiner. Removing a pending member does not change the number of non-pending members, and the check that the cluster keeps at least 2 dqlite members still applies.

Extend test_membership_consistency with a join of c2 right after the bootstrap, while c1 is still PENDING, which fails after dqlite added c2 because of an invalid schema patch.

The failed join must be rolled back on the cluster, which must stay consistent and allow c2 to join afterwards.

Depends-On: #850

dqlite.Close() tears down Raft connections but does not remove the node
from the dqlite cluster membership. When db.Join() fails after the node
has already been added to the cluster (via cli.Add in app.run), the
reverter only called Close(), leaving a ghost member in dqlite.

This ghost node causes CheckMembershipConsistency to fail (the node
exists in dqlite but not in core_cluster_members), which blocks token
generation and all future cluster joins.

Fix the Join reverter to explicitly ask the dqlite leader to remove us
before closing. Also correct a misleading comment in the Bootstrap
reverter.

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

@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.

Hey, can you please share some reproducer steps how you could end up in this state?
I see we have an exception to continue starting up the database in case we hit a update.ErrGracefulAbort error. But I would be very interested to see what else could go wrong.

Also could you please add the reproducer as an additional check to test_membership_consistency ?

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

Fixes a cluster-join failure mode where a node that was added to dqlite membership but failed later in db.Join() could be left behind as a “ghost” dqlite member, breaking membership consistency checks and blocking future operations like token generation.

Changes:

  • Corrected comments clarifying that dqlite.Close() tears down Raft connections but does not remove a node from dqlite membership.
  • Updated the Join() reverter to attempt removal of the local node from the dqlite cluster before closing, to avoid leaving a ghost member behind.

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

Comment thread internal/db/dqlite.go
Comment on lines +247 to +251
cli, err := db.dqlite.Leader(removeCtx, dqliteClient.WithConcurrentLeaderConns(1))
if err != nil {
db.log().Warn("Failed to connect to dqlite leader to remove ourselves from the cluster", slog.String("address", db.listenAddr.String()), slog.String("error", err.Error()))
} else {
err = cli.Remove(removeCtx, db.dqlite.ID())
Comment thread internal/db/dqlite.go
Comment on lines +244 to +248
removeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

cli, err := db.dqlite.Leader(removeCtx, dqliteClient.WithConcurrentLeaderConns(1))
if err != nil {
@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:40
@claudiubelu claudiubelu changed the title internal/db: Remove node from dqlite cluster on failed join internal/rest/resources: Allow forced removal of pending members Oct 28, 2026
@claudiubelu

Copy link
Copy Markdown
Contributor Author

Revisited the problem(s) again:

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