Repository navigation
fix(cluster): elect a primary writer, and let the promotion reach the gate that reads it (#850) - #859
Merged
Merged
Conversation
… gate that reads it (#850) Singleton work — the retention and continuous-query schedulers, and the non-dry-run retention, CQ and delete endpoints — is gated on IsPrimaryWriter(). With cluster.failover_enabled=true and shared_storage_mode=false, which is the Enterprise Helm chart's default for local storage, that gate was false on every node forever: retention never deleted anything, continuous queries never ran, and those endpoints answered 503. Nothing logged an error, because each node simply believed it was not the writer. Three defects, each of which alone was enough: - The writer failover manager could only fail over FROM an existing primary. Its health check triggered a promotion only once it had recorded one, HandleWriterUnhealthy returned early unless the node was already primary, and PromoteWriter had a single production caller inside that path, so the first promotion could never happen. An initial election now runs when a cluster has no primary, the way the compactor manager already elected its own lease. It completes without arming the failover cooldown, because an election is not a failover and nothing was lost to back off from. - The promotion never reached the gate. Registry.Get returns a clone, so the callback updated a copy and re-registered it, while IsPrimaryWriter reads the coordinator's own node object. That object is now updated too, on both the promote and the demote leg, and on the snapshot-restore path where the FSM carries the state and no callback fires. - A writer that merely restarted lost the designation: a re-join replaces the node's record and the join payload carries no writer state, so the cluster kept a primaryWriterID it could not name. In the single-writer topology this feature documents, it never recovered, because the only candidate was the node the failover logic had just excluded. The node table now keeps the designation across a re-join, and a healthy writer that is the only candidate can be re-elected rather than skipped. One behaviour changes as a result: a write proxied from a reader now goes to the elected primary instead of being spread across all healthy writers, which is what Pattern 1 intends. GET /api/v1/cluster/nodes now reports writer_state, whose absence is a large part of why this stayed hidden. A rejected promotion no longer records a designation for a node that does not exist, and the manual-failover goroutine is tracked on the WaitGroup that Stop joins. Shared-storage multi-writer clusters were never affected: writer failover is suppressed there by design and the gate keys off Raft leadership. Verified on the documented topology, one writer and three readers with failover on: before, the retention execute returned 503 on every node; after, a primary is elected within a second, the API shows it, the execute returns 200, and a 100 second soak shows one election and no churn. Closes #850. Claude-Session: https://claude.ai/code/session_01So2gKWp5TzF9gu3QKNqdeV
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
IsPrimaryWriter()gates every singleton task: the retention and continuous-query schedulers, and the non-dry-run retention, CQ and delete endpoints. In Pattern 1 (cluster.failover_enabled=true,shared_storage_mode=false, the Enterprise Helm chart's default for local storage) that gate was false on every node, forever, so retention never ran, continuous queries never ran, and those endpoints answered 503is not primary writer. Nothing logged an error.Three defects, each sufficient on its own:
WriterFailoverManagercould only fail over from an existing primary: the health check needed one recorded,HandleWriterUnhealthyreturned early unless the node was already primary, andPromoteWriter's only production caller sat inside that path. An initial election now runs when the cluster has no primary, as the compactor manager already did for its lease. It completes without arming the failover cooldown, so a real writer failure in the first minute is not swallowed.Registry.Getreturns a clone, so the callback mutated a copy and re-registered it whileIsPrimaryWriterreadsc.localNode. That object is now updated on both the promote and demote legs, and on the snapshot-restore path where the FSM carries the state and no callback fires.primaryWriterIDit could not name; in a single-writer cluster it never recovered, because the only candidate was the node the failover logic had just excluded. The node table now preserves the designation across a re-join, and a healthy writer that is the only candidate can be re-elected.Also:
GET /api/v1/cluster/nodesnow reportswriter_state(its absence is a large part of why this stayed hidden); a rejected promotion no longer records a designation for a node that does not exist; the manual-failover goroutine is tracked on the WaitGroupStopjoins; the Helm README no longer claims readers can be promoted.Behaviour change: a write proxied from a reader now goes to the elected primary rather than being spread across healthy writers, which is what Pattern 1 intends. Writers still serve their own traffic directly. Shared-storage clusters are unaffected: failover is suppressed there and the gate keys off Raft leadership.
Out of scope and tracked: #856 (readers are not promotion candidates), #857 (a load balancer cannot target the primary because
/readyis role-agnostic), and #858 (a node that leaves gracefully and restarts never re-joins, which I hit while testing this and reproduced identically onorigin/main).Note on #858: with a primary now elected, a node in that state keeps its designation and goes on running singleton work while the cluster no longer lists it. That is a consequence of the membership bug, not of this change, and in Pattern 1 each node owns its storage, which bounds it. It is the strongest argument for fixing #858 next.
Test plan
internal/cluster/writer_initial_election_test.go: election happens, happens once, is leader-only, does not run without candidates, does not arm the cooldown, survives a re-join, re-elects the sole healthy writer, reaches the local gate on promote and demote, and carries through a restore.origin/main; the two amended behaviours each fail when that behaviour alone is removed.go test -race ./internal/cluster/ ./internal/cluster/raft/, the api cluster tests and a full build: green.writer_state='primary'in the API, retention execute 200, and a 100s soak with exactly one election and no churn.origin/mainreturns 503 both before and after. The same run shows the node failing to re-join the others' view on both builds, which is cluster: a node that leaves gracefully and restarts does not re-join, and the rest of the cluster never sees it again #858.https://claude.ai/code/session_01So2gKWp5TzF9gu3QKNqdeV