feat(cluster): elect a primary writer even when automatic failover is off (#872) - #883
Merged
Merged
Conversation
… off (#872) On local storage, retention, continuous queries and deletes are meant to run on one writer. Choosing which one takes a promotion through Raft, and nothing issued one unless writer failover was both enabled and licensed. With no promotion, IsPrimaryWriter fell back to "any writer is primary" and every writer-role node ran all of it: three writers meant three nodes executing the same continuous queries and the same deletes. A primary is now elected on any local-storage cluster with Raft, whatever the flag says and whatever the licence contains. The maintainer's call, and the licence boundary is drawn where the activation server already draws it: the feature is named "Automatic writer failover", so replacing a primary that died is the paid capability and having one at all is not. The boundary is enforced on durable state, not on anything a process remembers. No designated primary means bootstrap; a designated primary that is unhealthy means replacement. The old code made that distinction with an in-memory field, which is empty on a freshly started process — so a leader restart or a leadership change looked like a cluster that had never had a primary, and it would elect one. That was a replacement under another name, and on an unlicensed cluster it was the paid feature given away. Adds POST /api/v1/cluster/writers/{id}/demote, admin-only and not gated on the failover licence, because it is how a cluster without automatic failover recovers at all. It is written as a promotion of somebody else rather than a demotion of the node named: a promotion already carries the demotion and announces both sides, while demote-then-elect is free to choose the same node straight back. With nobody else to promote it releases the designation anyway and says so. Adversarial validation of the plan caught that my first boundary leaked — GetPrimaryWriter filters on health, so a dead primary would have triggered an election, unlicensed. Review of the implementation then caught two blockers this endpoint would have exposed. A demotion announced nothing, so the node being demoted kept believing it was primary and kept running the singleton work while the cluster still saw a live primary and never elected anyone. And removing the designated primary left the record naming a node that no longer existed, so no election could ever run again — the obvious operator move turning "work runs N times" into "work never runs, and nothing says why". Also from review: a nil-context panic in the failover path, reachable because the callback that leads there is wired one line before the manager starts with health checks already running; a sentinel for the not-leader case instead of matching on message text; a warning for failover_enabled with no raft_data_dir, which previously logged nothing at all; and the doc comments and release-note lines that asserted the behaviour this removes. Behaviour change: on local storage /ready/write now reports not-ready on a writer that is not the primary. Licensed clusters already did this; unlicensed ones reported every writer ready, which matched them all running the singleton work, and both were wrong. Verified on two-node clusters of real binaries. With the flag unset exactly one primary is elected, the other writer is refused with "not primary writer", and /ready/write answers 200 and 503 respectively. Handing over from a non-primary returns 409 naming the real one; from the primary it returns 200 with the successor, after which both nodes' own views, both readiness probes and the retention gate have all followed the role across. 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.
Closes #872. Approach chosen by the maintainer (option 1), with the manual hand-over endpoint added at their request.
On local storage, retention, continuous queries and deletes are meant to run on one writer. Choosing which one takes a promotion through Raft, and nothing issued one unless writer failover was both enabled and licensed. With no promotion,
IsPrimaryWriterfell back to "any writer is primary" and every writer-role node ran all of it. Three writers meant three nodes executing the same continuous queries and the same deletes.The licence boundary
A primary is now elected on any local-storage cluster with Raft, whatever
cluster.failover_enabledsays and whatever the licence contains. What stays paid is what the activation server already names it: "Automatic writer failover", a replacement chosen for you when the primary dies.The line is enforced on durable state rather than anything a process remembers:
The old code drew this distinction with an in-memory field, which is empty on a freshly started process. A leader restart or a leadership change therefore looked like a cluster that had never had a primary, and it would elect one. That is a replacement under another name, and on an unlicensed cluster it was the paid feature given away for free. A test pins it.
The hand-over endpoint
POST /api/v1/cluster/writers/{id}/demote, admin-only, not gated on the failover licence, because it is how a cluster without automatic failover recovers at all.It is written as a promotion of somebody else, not a demotion of the node named. A promotion already carries the demotion with it and announces both sides in one applied command; demote-then-elect leaves the election free to pick the same node straight back, which is not a hand-over. With nobody else to promote it releases the designation anyway, so the cluster is not pinned to a writer that may never return, and the response says plainly that nobody took it.
Two blockers the review caught, which this endpoint would have exposed
Also fixed
A nil-context panic in the failover path, reachable in production because the callback that leads there is wired one line before the manager starts, with health checks already running. A goroutine panic takes the process down.
Behaviour change
On local storage,
/ready/writenow reports not-ready on a writer that is not the primary. Clusters with automatic failover already behaved this way. Clusters without it reported every writer ready, which was consistent with every writer also running the singleton work, and both were wrong. A hand-rolled load-balancer pool built on that endpoint drops to the primary alone. Kubernetes pod readiness is unaffected, since the chart probes/ready.One cell this does not fix
cluster.failover_enabled=truewithcluster.raft_data_dirempty. There is no Raft to elect through, so the old fallback still applies and every writer is primary. It previously logged nothing at all; it now warns explicitly. Called out because the issue lists three triggers and this fixes two.Review
Adversarial validation of the plan, then a deep review of the diff.
The validation killed my first boundary: I had claimed a dead primary keeps its designation so an unlicensed cluster would not re-elect. It does not —
GetPrimaryWriterfilters on health, so the primary dying would have triggered an election with no licence. That is what moved the discriminator to the durable record.The review then found the two blockers above, plus the panic, a not-leader case matched on message text rather than a sentinel, a configuration that logged nothing, and six places where doc comments and release notes still asserted the behaviour this removes.
Test plan
go test -race ./internal/cluster/ ./internal/api/green.failover_enabledunset. Exactly one primary elected. The other writer is refused with "not primary writer";/ready/writeanswers 200 and 503 respectively.https://claude.ai/code/session_01So2gKWp5TzF9gu3QKNqdeV