Clustering Implementation Plan
Last updated: 2026-07-08
Progress Summaryā
| Phase | Name | Status | Progress |
|---|---|---|---|
| 1 | Partition Leader Propagation | Done (2026-07-08) ā all blockers fixed incl. 1.3 formation livelock; DoD met; E2E green & blocking in CI | 2/2 code, E2E in CI (blocking) |
| 2 | Configurable Partitions | Not Started | 0/5 |
| 3 | Data Consistency & Routing | In Progress | 2/4 |
| 4 | Partition Rebalancing & Node Lifecycle | Not Started | 0/4 |
| 5 | Job Manager Multi-Node Support | Not Started | 0/4 |
| 6 | Hardening & Slow-Tier Tests | Not Started | 0/4 |
| 7 | Voter-Only Nodes | Not Started | 0/7 |
Dependency Graphā
Phase 1 (Partition Leader Prop.)
āāā 1.1 partitionLeaderChange
āāā 1.2 Engine lifecycle
ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā ā
Phase 2 (Configurable Partitions) Phase 3 (Data Consistency)
āāā 2.1 Proto changes āāā 3.1 Leader reads (Won't fix)
āāā 2.2 FSM handler āāā 3.2 Off-by-one fix
āāā 2.3 Initial config āāā 3.3 Message correlation
āāā 2.4 Config RPC āāā 3.4 DMN visibility (Won't fix)
āāā 2.5 Controller loop
ā ā
āāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāā
ā ā
Phase 4 (Rebalancing) Phase 5 (Job Manager)
āāā 4.1 Assignment āāā 5.1 Enable role change
āāā 4.2 Shutdown āāā 5.2 Stream reconnect
āāā 4.3 Reap cleanup āāā 5.3 Job rejection
āāā 4.4 Handoff āāā 5.4 Throttling
āāā 4.5 Node lifecycle callbacks + RPCs
ā ā
āāāāāāāā¬āāāāāāāā
ā
Phase 6 (Hardening) Phase 7 (Voter-Only Nodes)
āāā 6.1 Slow-tier tests āāā 7.1 Node role config + state
āāā 6.2 Backup/restore RPCs āāā 7.2 Base cluster decline leadership
āāā 6.3 Parallel queries āāā 7.3 Partition assignment excludes voters from leader
āāā 6.4 Incremental migrations āāā 7.4 Partition Raft decline leadership
āāā 7.5 Engine lifecycle guard
āāā 7.6 Quorum safety validation
āāā 7.7 Tests
Note: Phase 2 and Phase 3 can be done in parallel after Phase 1. Phase 4 and Phase 5 can be done in parallel after Phases 2+3. Phase 7 depends on Phase 4 and can run in parallel with Phase 5 and 6.
Phase 1: Partition Leader Propagationā
Goal: When a partition elects a new leader, the base cluster state reflects it. Engine starts/stops accordingly. Dependency: None ā start here.
Definition of Done ā 1 partition, 3 nodes, production-viable:
- 3 nodes form a cluster; 1 partition is created with all 3 nodes in its Raft group (proper quorum,
BootstrapExpect=3) āTestThreeNodeWithPartitionpasses - Partition leader failover works without crash: kill the partition leader, a new leader is elected, engine starts on the new leader ā
TestPartitionLeaderFailoverpasses 5/5 - Engine stops on the old leader (or crashed node recovers without duplicate engine) ā
stopEngineIfRunninginhandlePartitionStateInitialized - Write on any node, read from any node (data replicates through partition Raft) ā
TestReadAfterWritepasses; unblocked by theBootstrapExpectwiring, lifecycle-ctx, schema-gate, and deploy-retry fixes (see Phase 1 Blocker History below) - No
panic("unimplemented")reachable in this configuration - E2E:
TestThreeNodeWithPartitionā ,TestPartitionLeaderFailoverā ,TestReadAfterWriteā ā each passes in isolation and in most suite runs; suite-level stability is gated by the formation churn item under "Remaining Work to Close Phase 1"
1.1 ā Implement partitionLeaderChange callback + RPC ā DONEā
- Replace
panic("unimplemented")inpartitionLeaderChange(controller.go) - Map RqLite
ServerID(format:zen-{nodeId}-partition-{id}) back to ZenNode ID āparsePartitionServerIDhelper - Write
NodePartitionChange{nodeId, partitionId, State=INITIALIZED, Role=LEADER}to base store - Write
NodePartitionChange{oldLeaderId, partitionId, State=INITIALIZED, Role=FOLLOWER}for the old leader - Verify
state.Partitions[id].LeaderIdis updated - Replace
panic("unimplemented")inPartitionNodeLeaderChangeRPC (server.go) - Retry loop for
ClusterLeader()lookups (handles the base-cluster-re-election race when killing a node that was both base and partition leader)
Files: internal/cluster/controller/controller.go, internal/cluster/server/server.go
Tests:
TestParsePartitionServerIDā 8 casesTestPartitionNodeLeaderChange_WritesLeaderAndDemotesOldLeader,ā¦_SameLeaderIsNoOp,ā¦_FirstElectionTestPartitionLeaderChange_UpdatesClusterState,ā¦_EmptyServerIDIsNoOp,ā¦_InvalidServerIDReturnsError
1.2 ā Engine lifecycle on leadership change ā DONEā
- When this node becomes partition leader: create engine + script runtimes (if not already running)
- When this node loses partition leadership: stop engine, nil out
Enginefield, stop runtimes - Handle engine creation/start failures (return early, nil out partial state, allow retry on next
ClusterStateChangeNotification) - Refactored into
startEngineIfLeader/stopEngineIfRunninghelpers ā used by bothhandlePartitionStateInitializingandhandlePartitionStateInitialized
Files: internal/cluster/controller/controller.go
Tests:
TestEngineStartsOnRegainedPartitionLeadership
Also fixed (discovered during Phase 1):
- Bug: nil observer channel in
partition.goāobserverChanwas declared as a local variable, shadowing the uninitialized struct fieldzpn.observerChanthat the observer goroutine was reading from. This meant no partition Raft events (including leader changes) ever reached our callbacks. Without this fix, Phase 1 would appear implemented but nothing would actually work. - 4 controller callbacks + 4 server RPCs replaced
panic("unimplemented")with minimal logging stubs:partitionAddNewNode,partitionShutdownNode,partitionRemoveNode,partitionResumeNode, and server RPCsAddPartitionNode,RemovePartitionNode,ResumePartitionNode,ShutdownPartitionNode.
Phase 1 Blocker History (all resolved)ā
Every blocker that previously gated Phase 1 is fixed and verified in code (2026-07-08 audit):
- Partition RqLite Raft replication not catching up followers (
failed to get previous log: ... log not found) ā root cause was a hardcodedBootstrapExpect: 1ininternal/cluster/partition/config.go: every node bootstrapped its own 1-node partition Raft cluster, so nothing replicated. Fixed inb3150af2:GetRqLiteDefaultConfigtakesbootstrapExpect, wired fromConfig.Raft.BootstrapExpect(controller.go:81). - Controller context cancelled by rapid FSM applies ā followers got stuck in
handlePartitionStateInitializingbecause each FSMApplycancelled the previous apply-scoped ctx. Fixed in4e5305d5:ClusterStateChangeNotificationignores the apply ctx and runs handlers on the controller lifecycle ctx (controller.go:96-100). - Migration/engine-start race (two layers:
no such table: process_definition, thenno engine available/store not open) ā fixed in1fd7ff73(schema gate:waitForSchema/DB.SchemaReadybefore a node advertises INITIALIZED) and2068fe49(boundedretryDeployon transient windows, re-resolving the partition leader each attempt). - Deploy-then-immediate-read test anti-pattern ā accepted eventual consistency, not an engine bug; the affected tests poll via
GetFirstDefinitionKey(7b9f1811). - E2E harness FEEL pool hang ā harness config struct literals omitted the
Scriptsection, producing a zero-capacity FEEL VM pool that deadlocked on first evaluation. Fixed indf1cc315(harness sets script pool config).
All cross-node read-after-write E2E tests pass (TestReadAfterWrite, TestCreateInstanceRoutesToPartitionLeader, TestJobCompletionAcrossNodes, TestDMNEvaluationAcrossNodes, TestConcurrentWritesToDifferentNodes, TestPartitionLeaderFailoverDuringProcessExecution, TestFailoverPreservesInFlightJobs), alongside the formation/failover tests (TestThreeNodeWithPartition, TestPartitionLeaderFailover, TestSimultaneousBaseAndPartitionLeaderFailure).
Remaining Work to Close Phase 1ā
- CI visibility (the root cause of this doc going stale):
test/e2e/cluster/is gated behind//go:build cluster_e2e, which nothing in the Makefile or CI referenced ā the suite ran only by hand and silently rotted twice. FIXED 2026-07-08:make test-e2e-cluster(fast tier,-short) runs as a blocking step in thego-testCI workflow;make test-e2e-cluster-slowruns the full suite manually. Tests requiring unfinished phases carry at.Skipwith a phase reference (6 multi-partition tests skip until Phase 2). - Leaked background goroutines after
partition.Stop(): the observer goroutine (5s metrics ticker) and the data-cleanup scheduler were never stopped ā every stopped partition left them hammering the closed store (store not openlog floods; a 2026-07-08 full-suite run accumulated ~27 failed queries/s by test 20, degrading later cluster formations). FIXED 2026-07-08:Stop()closes the observer channel andDB.Stop()terminates the cleanup scheduler;TestDataCleanupdeflaked the same way. - Deploy-then-immediate-read sites swept onto
GetFirstDefinitionKeypolling across both tiers (2026-07-08:stream_resilience_test.go,data_test.go, plus slow-tierstress_test.go(4 sites),scaling_test.go:92,recovery_test.go:156). -
TestPartitionStateTransitionsstring-enum rot fixed (2026-07-08) ā it was the comparison site missed by the 2026-06-30helpers.gofix. The durable cure (generating status types fromopenapi/system.yamlso drift breaks at compile time) remains a follow-up. - Formation livelock ā Phase 1.3, the last real Phase 1 stability gap (ROOT-CAUSED & FIXED 2026-07-08): the intermittent "2-4 random tests fail at
WaitForHealthy, stuck formations never converge" symptom was a deadlock, not slow election. Chain: (1) a follower's INITIALIZING partition-change is applied on the cluster leader first and only later replicated back into the follower's own FSM, so during that window the follower's local state still readsJOININGwhile its partition node is already running; (2) any state-change notification in that window re-enteredhandlePartitionStateJoining, whose guard only skipped on advanced state, not on an already-running partition, so it calledStartZenPartitionNodea second time; (3) the duplicate rqlite mux listener registration panics (listener already registered under header byte: 11); (4)performMemberOperationsheldpartitionsMuwith a plainUnlock()(nodefer), and safego recovered the panic above the unlock ā leaving the mutex locked forever. Every later partition-state notification on that node then deadlocked, so it could never advertise INITIALIZED regardless of timeout. FIX:handlePartitionStateJoiningreturns early whenc.partitions[partitionId]already exists (the INITIALIZING write is still re-sent first, covering the lost-write case); allpartitionsMucritical sections inperformMemberOperationsand the join-retry goroutine now unlock viadeferinside closures so no recovered panic can leak the lock. Regression test:TestJoiningReentryDoesNotRestartRunningPartition(reproduces the exact panic deterministically). Verified: two back-to-back fast-tier suite runs green, 0 panics, 0 failures (previously 2-4 failures + 1-3 panics per run); suite wall-clock dropped ~350-577sā~181s (no nodes riding the 150s deadlock timeout). - Slow tier run for the first time & triaged (2026-07-08):
make test-e2e-cluster-slow(allskipIfShortscenarios: chaos/stress/network-partition/recovery/scaling) had never run in CI and had rotted like the fast tier. First run: 54 pass / 9 fail / 7 skip / 0 panics (the 1.3 deadlock fix holds under heavy load). Of the 9 failures: 6 were test rot ā fixed (harnessAddNodeself-join: it appended the new node's own proxy to its join addresses but a joiner usesBootstrapExpect:0; + the immediate-read sweep above); 2 were contention-only (TestNodeRecoveryFromDisk,TestSimultaneousBaseAndPartitionLeaderFailureā pass in isolation, fail only under full-suite sequential CPU pressure on one machine); 2 are genuine but out of Phase 1 scope and nowt.Skipwith reasons (below). - Deploy idempotency race (engine-scope,
TestConcurrentDeploymentsskipped): concurrent identical deploys hitUNIQUE constraint failed: process_definition.keyin the read-then-insert save path (pkg/bpmn/pkg/storage/internal/sql, outside cluster scope) ā flag to engine team. - Network-partition tolerance (
TestPartitionDuringProcessExecutionskipped) ā Phase 4/6 task: after isolating a partition leader, no new leader is elected. Two root causes: (1) the test harnessNodeProxy.BlockPeeris a no-op (itsblockedmap is never consulted), so an isolated leader's outbound heartbeats still reach followers ā meaning other passing network-partition tests may pass vacuously; (2) partition raftRaftLeaderLeaseTimeout: 0, so an isolated leader never self-demotes on lost quorum. Fixing (2) risks reintroducing formation churn and must be validated independently. Crash-based partition failover (TestPartitionLeaderFailover) passes, so the Phase 1 DoD is unaffected. - Engine-side residue (owned by engine team,
pkg/bpmnis outside cluster scope):timer_manager.gokeeps polling the partition store briefly after engine stop (store not opennoise, bounded ~150 log lines/run after the cluster-side leak fix). Related to the known timer duplicate-fire item. - Architectural note (candidate future hardening, not a blocker): cluster state can advertise partition leadership before local engine/store serviceability holds. The deploy retry covers the write path and the Phase 1.3 fix closes the formation deadlock; other leader-routed paths could still see brief transient windows. A deeper coordination fix (state leadership reflects actual engine/store readiness) remains optional.
Phase 2: Configurable Partitionsā
Goal: Allow changing DesiredPartitions through Raft consensus instead of hardcoding to 1.
Dependency: Phase 1
2.1 ā Add TYPE_CONFIGURATION_CHANGE to protobufā
- Add new command type and message to
zencommand.proto - Run
make generate
message Command {
enum Type {
...
TYPE_CONFIGURATION_CHANGE = 4;
}
oneof request {
...
ConfigurationChange configuration_change = 4;
}
}
message ConfigurationChange {
uint32 desired_partitions = 1;
}
Files: internal/cluster/command/proto/zencommand.proto
2.2 ā FSM: handle configuration changeā
- Add
applyConfigurationChangemethod to FSM - Update
state.Config.DesiredPartitionsfrom command
Files: internal/cluster/store/fsm.go
2.3 ā Store: initial config from app config + write methodā
- Remove hardcoded
DesiredPartitions: 1atstore.go:125 - Read from
config.Clusterand apply as initial Raft command on bootstrap - Add
WriteConfigurationChange()method to Store
Files: internal/cluster/store/store.go
2.4 ā Server: implement ConfigurationUpdate RPCā
- Replace
panic("unimplemented")inConfigurationUpdate(server.go:178) with real implementation - Validate request, call
store.WriteConfigurationChange()
Files: internal/cluster/server/server.go
2.5 ā Controller: create partitions in a loopā
- Fix
performLeaderOperationsto create all missing partitions, not just one per state change
// Current (creates one):
if int(cs.Config.DesiredPartitions) > currentPartitionCount {
c.assignNewPartition(ctx, currentPartitionCount+1)
}
// Fixed (creates all missing):
for currentPartitionCount < int(cs.Config.DesiredPartitions) {
c.assignNewPartition(ctx, currentPartitionCount+1)
currentPartitionCount++
}
Files: internal/cluster/controller/controller.go
Tests:
TestConfigurationChangeViaRaftā write config change, verify all nodes see new DesiredPartitionsTestPartitionScaleUpā change from 1ā3, verify 3 partitions created and assignedTestConfigurationUpdateRPCā call RPC, verify config propagatedTestInitialConfigFromAppConfigā bootstrap with DesiredPartitions=3, verify state
Unblocks E2E:
TestPartitionCreationTestPartitionAssignmentTestMultiplePartitionsPerNodeTestMaxPartitionsTestIncreasePartitionCountTestListAggregatesAcrossPartitionsTestConcurrentStreamsMultiplePartitions
Phase 3: Data Consistency & Routingā
Goal: Deploys and queries return consistent results from any node. Dependency: Phase 1
3.1 ā Fix read-after-write for definitions (Won't fix)ā
Decision: Keep current eventual consistency (follower reads). In real usage there is always enough time between deploy and first process start ā replication lag is not a practical problem. No code change needed.
Files: N/A
3.2 ā Fix LeastStressedPartition off-by-one panicā
- Fix
rand.Intn(len(c.Partitions) - 1)ārand.Intn(len(c.Partitions)) - Consider replacing random selection with actual load-based selection
Files: internal/cluster/state/state.go (line 98)
3.3 ā Fix message correlation routing across partitionsā
-
PublishMessagehashes correlation key to pick a partition for subscription lookup, but subscription lives on the partition where instance was created (random) - Fix: broadcast
FindActiveMessageSubscriptionPointeracross all partition DBs, or add a routing index
Files: internal/cluster/node.go (PublishMessage method)
3.4 ā Fix DMN cross-node visibility (Won't fix)ā
Decision: Same as 3.1 ā eventual consistency is acceptable. No code change needed.
Files: N/A Tests:
TestLeastStressedPartitionSinglePartition(3.2)TestMessageCorrelationMultiPartition(3.3)
Unblocks E2E:
TestMessageCorrelationAcrossNodes(3.3)
Phase 4: Partition Rebalancing & Node Lifecycleā
Goal: Partitions distribute evenly across nodes. Nodes join/leave cleanly. All node lifecycle panics resolved. Dependency: Phase 2
4.1 ā Fix partition assignment to balance across nodesā
- Fix
performLeaderOperationsā new nodes always assigned to partition 1 (controller.go:124) - Assign unassigned nodes to partition with fewest members
Files: internal/cluster/controller/controller.go
4.2 ā Implement shutdown notificationā
- Replace
panic("unimplemented")inpartitionShutdownNode(controller.go) - Replace
panic("unimplemented")inShutdownPartitionNodeRPC (server.go:208) -
NotifyShutdown()calls base cluster leader to mark this node's partitions as LEAVING - Wait for partition handoff (or timeout)
Files: internal/cluster/controller/controller.go (line 444), internal/cluster/server/server.go
4.3 ā Clean up reaped node partition entriesā
- When a node is reaped in
store.remove(), also writeNodePartitionChange{State=LEAVING}for each of its partitions - Prevent reaped nodes from "owning" partitions in state
Files: internal/cluster/store/store.go (remove method)
4.4 ā Partition handoff on node departureā
- When leader detects node is SHUTDOWN, check partition quorum
- Reassign partition members from healthy nodes if needed
- Handle graceful drain of in-flight work in
handlePartitionStateLeaving
Files: internal/cluster/controller/controller.go
4.5 ā Implement remaining node lifecycle callbacks + RPCsā
- Replace
panic("unimplemented")inpartitionAddNewNode(controller.go) ā writeNodePartitionChange{State=JOINING}for new partition member - Replace
panic("unimplemented")inpartitionRemoveNode(controller.go) ā remove node from partition membership in base state - Replace
panic("unimplemented")inpartitionResumeNode(controller.go) ā re-mark node's partition as active after heartbeat resumed - Replace
panic("unimplemented")inAddPartitionNodeRPC (server.go:197) - Replace
panic("unimplemented")inRemovePartitionNodeRPC (server.go:200) - Replace
panic("unimplemented")inResumePartitionNodeRPC (server.go:204) - Replace
panic("unimplemented")inAssignPartitionRPC (server.go:182) - Replace
panic("unimplemented")inUnassignPartitionRPC (server.go:185)
Files: internal/cluster/controller/controller.go, internal/cluster/server/server.go
Tests:
TestPartitionBalancedAssignmentTestNodeDepartureReassignsPartitionsTestGracefulShutdownNotifiesClusterTestReapedNodePartitionsCleanedUpTestPartitionAddNewNodeCallbackTestPartitionRemoveNodeCallbackTestPartitionResumeNodeCallbackTestPartitionShutdownNodeCallback
Unblocks E2E:
TestGracefulLeaveTestPartitionReassignmentOnLeaveTestScaleDownFromThreeToOneTestScaleUpFromOneToThree
Phase 5: Job Manager Multi-Node Supportā
Goal: Job distribution works correctly across partition leader changes and multiple partitions. Dependency: Phase 1, Phase 2
5.1 ā Enable OnPartitionRoleChange in job managerā
- Uncomment
m.OnPartitionRoleChange(ctx)atmanager.go:161 - Verify job server starts/stops when partition leadership changes
Files: internal/cluster/jobmanager/manager.go
5.2 ā Implement stream reconnectionā
- When
handleJobStreamRecvgets EOF/error (client.go:161), backoff and reconnect - Call
updateNodeSubsto refresh leader info, re-subscribe to new partition leader
Files: internal/cluster/jobmanager/client.go
5.3 ā Implement job rejection handlingā
-
onJobRejected(server.go:417) should remove job fromdistributedJobsto unlock it - Job becomes available again in next
distributeJobscycle
Files: internal/cluster/jobmanager/server.go
5.4 ā Client-side job throttlingā
- Add
maxActiveJobsper client tracking in job client - Don't forward jobs to clients that are at capacity
Files: internal/cluster/jobmanager/client.go
Tests:
TestJobManagerPartitionLeaderChangeTestJobStreamReconnectionTestJobRejectionRequeueTestJobClientThrottling
Unblocks E2E:
TestJobCompletionAcrossNodesTestJobActivateCompleteAcrossFailoverTestGrpcStreamReconnectAfterPartitionLeaderFailover
Phase 6: Hardening & Slow-Tier Testsā
Goal: Run and pass all 79 e2e tests including slow tier. Polish remaining rough edges. Dependency: Phases 1-5
6.1 ā Run and triage slow-tier e2e testsā
- Run categories 5-8 (network, scaling, recovery, stress)
- Triage and fix failures
Test categories:
| Category | File | Tests |
|---|---|---|
| 5. Network | network_test.go | 8 |
| 6. Scaling | scaling_test.go | 5-7 |
| 7. Recovery | recovery_test.go | 7-9 |
| 8. Stress | stress_test.go | 8-9 |
| 9. Stream (slow subset) | stream_resilience_test.go | 4 |
6.2 ā Implement backup/restore RPCsā
- Replace
panic("unimplemented")inClusterBackupRPC (server.go:170) - Replace
panic("unimplemented")inClusterRestoreRPC (server.go:174) - Replace
panic("unimplemented")inPartitionBackupRPC (server.go:188) - Replace
panic("unimplemented")inPartitionRestoreRPC (server.go:191)
Files: internal/cluster/server/server.go
Needed by: Category 7 recovery tests
6.3 ā Parallelize cross-partition queriesā
-
GetJobs(node.go:864) ā query partitions in goroutines instead of sequentially -
GetProcessInstances(node.go:940) ā same
Files: internal/cluster/node.go
6.4 ā Incremental migrationsā
- Track applied migrations, only run missing ones
- Current code at
controller.go:374re-applies all migrations every engine start
Files: internal/cluster/controller/controller.go
Phase 7: Voter-Only Nodesā
Goal: Support nodes that participate in Raft quorum (base cluster + all partition groups) but never become leaders and never run engine workload. Enables witness/arbiter deployments ā e.g., 2 workers + 1 voter for 3-node quorum with 2 active workloads. Dependency: Phase 4 (partition assignment and rebalancing must respect node roles).
Model: voter-only node joins both Raft tiers as a full voter (replicates logs, counts toward quorum). In standard Raft, voters must hold the log ā a "quorum-only no-data" node would require forking Raft with arbiter semantics (see Risks). Cost: disk + replication bandwidth on voter nodes. Gain: no engine CPU, no job traffic.
7.1 ā Node role config + stateā
- Add
Role: voter|workerfield to app cluster config (conf/) - Default to
workerwhen unset (backward compat with existing deployments) - Add
Roleenum tostate.Node(VOTER,WORKER) - Add
node_rolefield toNodeChangeprotobuf message - FSM applies role to
state.Nodes[id].Role - Surface role via
ClusterState()for downstream decisions
Files: conf/, internal/cluster/state/state.go, internal/cluster/command/proto/zencommand.proto, internal/cluster/store/fsm.go, internal/cluster/store/store.go
7.2 ā Base cluster: voter-only declines leadershipā
- Subscribe to base cluster leadership changes (existing
raft.LeaderObservationchannel inStore) - If self is voter-only and self became leader: immediately call
raft.LeadershipTransfer()to a worker peer - Pick transfer target from
ClusterState().Nodesfiltered toRole=WORKER - If no worker peer is available: log warning, retain leadership as fail-safe (cluster is unhealthy but not stuck)
- Accept a brief leadership window during the transfer ā no forking Raft
Files: internal/cluster/store/store.go
7.3 ā Partition membership: voters join groups, never leadā
-
performLeaderOperationsand rebalancing MUST add voter-only nodes to partition Raft groups ā they count toward partition quorum - When selecting a partition leader candidate in
NodePartitionChange{Role=LEADER}writes, filter eligible nodes toRole=WORKER - Initial partition assignment: distribute members across both worker + voter nodes; elect leader from workers only
- Rebalancing on node join/leave: preserve invariant that voters are members but never leaders
Files: internal/cluster/controller/controller.go
7.4 ā Partition Raft: voter-only declines partition leadershipā
- In
partition.observe(), onraft.LeaderObservationwhere self is the partition leader and self is voter-only: call partition RaftLeadershipTransfer()to a worker peer - Target selection: query
ClusterStatefor other partition members filtered toRole=WORKER - If no worker peer in the partition: log and retain (defensive; should be prevented by 7.3 and 7.6)
Files: internal/cluster/partition/partition.go
7.5 ā Engine lifecycle guard (defense in depth)ā
- In
handlePartitionStateInitialized: before starting engine, checkself.Role. If voter-only, skip engine start even ifRole=LEADERis set - Symmetric guard when deciding whether to stop engine ā a voter should never have a running engine in the first place
- Guards against races between 7.4's transfer and engine-start callbacks
Files: internal/cluster/controller/controller.go
7.6 ā Quorum safety validationā
- On node join and config change: validate that the resulting topology has a reachable leader for every partition (ā„1 worker per partition)
- Reject configs where losing any single worker would leave a partition with no leader-eligible node
- Warn (not reject) if voter count ā„ worker count across the cluster (unusual but not invalid)
Files: internal/cluster/controller/controller.go, internal/cluster/store/store.go
7.7 ā Testsā
Unit:
-
TestVoterOnlyJoinsPartitionGroupā voter is added as partition Raft member after join -
TestVoterOnlyNeverBecomesBaseClusterLeaderā leadership transfer fires on election -
TestVoterOnlyNeverBecomesPartitionLeaderā partition leadership transfer fires on election -
TestVoterOnlyDoesNotRunEngineā engine guard prevents start even with LEADER role -
TestPartitionAssignmentFiltersLeaderToWorkersā voter never getsRole=LEADERinNodePartitionChange -
TestQuorumValidationRejectsNoWorkerPartitionā config validation fails when all members are voters
E2E (unblocks / new):
-
TestVoterOnlyTopology3Nodeā 2 workers + 1 voter cluster forms, partition has all 3 as members, only workers lead -
TestTwoWorkerOneVoterSurvivesWorkerFailureā kill a worker; partition still has quorum (voter + remaining worker); failover to remaining worker -
TestPartitionQuorumWithVoterā writes + reads succeed through failover
Files: internal/cluster/store/store_test.go, internal/cluster/controller/controller_test.go, internal/cluster/partition/partition_test.go, test/e2e/ (new voter topology test file)
Panic Trackerā
All panic("unimplemented") calls and the phase where each gets resolved:
| Location | Function/RPC | Resolved In | Status |
|---|---|---|---|
controller.go | partitionLeaderChange | Phase 1.1 | ā Done (full impl) |
controller.go | partitionAddNewNode | Phase 4.5 | ā Phase 1 logging stub; full impl Phase 4.5 |
controller.go | partitionShutdownNode | Phase 4.2 | ā Phase 1 logging stub; full impl Phase 4.2 |
controller.go | partitionRemoveNode | Phase 4.5 | ā Phase 1 logging stub; full impl Phase 4.5 |
controller.go | partitionResumeNode | Phase 4.5 | ā Phase 1 logging stub; full impl Phase 4.5 |
server.go | ClusterBackup | Phase 6.2 | ā³ still panics |
server.go | ClusterRestore | Phase 6.2 | ā³ still panics |
server.go | ConfigurationUpdate | Phase 2.4 | ā³ still panics |
server.go | AssignPartition | Phase 4.5 | ā³ still panics |
server.go | UnassignPartition | Phase 4.5 | ā³ still panics |
server.go | PartitionBackup | Phase 6.2 | ā³ still panics |
server.go | PartitionRestore | Phase 6.2 | ā³ still panics |
server.go | PartitionNodeLeaderChange | Phase 1.1 | ā Done (full impl) |
server.go | AddPartitionNode | Phase 4.5 | ā Phase 1 no-op stub; full impl Phase 4.5 |
server.go | RemovePartitionNode | Phase 4.5 | ā Phase 1 no-op stub; full impl Phase 4.5 |
server.go | ResumePartitionNode | Phase 4.5 | ā Phase 1 no-op stub; full impl Phase 4.5 |
server.go | ShutdownPartitionNode | Phase 4.2 | ā Phase 1 no-op stub; full impl Phase 4.2 |
All panics reachable in the 3-node, 1-partition scenario are removed. The 7 remaining ā³ server panics fire only on operations not exercised in Phase 1 (backup/restore, config change, rebalancing).
Risks & Open Questionsā
| # | Question | Affects | Status |
|---|---|---|---|
| 1 | RqLite ServerID ā ZenNode ID mapping reliability | Phase 1.1 | Open |
| 2 | Read consistency strategy: leader reads vs replication wait | Phase 3.1 | Resolved ā keep eventual consistency, no change needed |
| 3 | Message correlation routing: broadcast vs routing index | Phase 3.3 | Open |
| 4 | Partition count reduction (scale DOWN) ā data migration needed? | Phase 4 | Out of scope for now |
| 5 | Concurrent partition creation throttling | Phase 2.5 | Open |
| 6 | Voter-only nodes: replicate data or fork Raft for arbiter semantics? | Phase 7 | Resolved ā replicate data (standard Raft safety). Arbiter-style is a separate, out-of-scope project. |
| 7 | Voter-only leadership avoidance: transfer-on-win vs. Raft fork? | Phase 7.2, 7.4 | Resolved ā transfer-on-win. Accepts brief leadership window, avoids forking Raft. |
Files Referenceā
All changes stay within allowed scope (internal/cluster/**, test/e2e/, docs/, conf/).
| File | Phases |
|---|---|
controller/controller.go | 1.1, 1.2, 2.5, 4.1, 4.2, 4.4, 4.5, 6.4, 7.3, 7.5, 7.6 |
server/server.go | 1.1, 2.4, 4.2, 4.5, 6.2 |
store/store.go | 2.3, 4.3, 7.1, 7.2, 7.6 |
store/fsm.go | 2.2, 7.1 |
command/proto/zencommand.proto | 2.1, 7.1 |
state/state.go | 3.2, 7.1 |
partition/partition.go | 7.4 |
node.go | 3.3, 6.3 |
jobmanager/manager.go | 5.1 |
jobmanager/client.go | 5.2, 5.4 |
jobmanager/server.go | 5.3 |
conf/ | 7.1 |