Replies: 2 comments 1 reply
|
hmm.. It seems like we are losing quorum assurance when cutover. |
|
I draft an alternative one. Shard Split Design: Local-First Approach
OverviewThis document proposes an alternative approach to shard splitting in Oxia that avoids network data replication entirely by leveraging local-first operations on each ensemble member. The core insight is that since all three replicas of a shard already hold a complete copy of the data, we can have each node independently prepare the child shards locally — eliminating snapshot transfer, observer followers, and complex catch-up state machines. The approach draws inspiration from CockroachDB's range split mechanism, where splits are treated as lightweight metadata operations with no data movement. While Oxia's per-shard Pebble instances and Limitations of the Existing ProposalThe original proposal is thorough and correct, but introduces significant complexity to achieve low downtime:
The fundamental reason for this complexity is that the original proposal treats the problem as a replication problem — getting data from parent to child. This proposal reframes it as a local transformation problem. Proposed Approach: Local-First Background SplitHigh-Level FlowPhase 1: Background SplitThe coordinator sends a The goroutine acts as both a DB splitter and a WAL consumer simultaneously. Under high write pressure it spends more time on WAL catch-up; under low pressure it makes faster progress on DB iteration. The split child DBs are continuously kept current with parent writes throughout this phase. Data Flow During Background SplitWAL Trimmer AnchorPhase 2: CutoverOnce all ensemble members report Downtime WindowThe downtime window contains only local operations (no network transfer) plus leader election. It is bounded by the time to checkpoint (hard link, milliseconds) plus election time (typically < 1 second). Key Design DecisionsChild DBs Are Clean at CutoverBy the time cutover happens, each child DB has been continuously updated by the background goroutine throughout the preparation phase. The DB checkpoint at cutover captures this already-filtered state. Child shards only need to apply a tiny incremental WAL window (from Notifications Require Explicit FilteringDuring background preparation, each node filters notification batches as it writes them to the child directories:
Children inherit the parent's offset numbering, allowing clients to resume from their last seen offset after re-subscribing to the child shards. partition_key HandlingKeys with an explicit Comparison with Original Proposal
Failure HandlingNode Failure During Background PreparationEach node operates independently. A single node failure does not affect the others. The failed node restarts, discards its partial child directories, and the coordinator re-sends Parent Leader Failure During PreparationThe parent shard controller runs a normal election — no special handling needed. The WAL trimmer anchor at offset Coordinator FailureThe split state is persisted in Cutover FailureOnce the parent is fenced, the cutover must complete. Child election failures are retried. If a child node is unavailable, the standard ensemble change mechanism handles it — identical to the original proposal. Aborting Before CutoverImplementation PlanMilestone 1: Core Infrastructure
Milestone 2: Background Split Goroutine
Milestone 3: Cutover
Milestone 4: Cleanup and Hardening
Future Work
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Shard Split Design
Purpose
As an Oxia cluster grows, individual shards can become hot spots — either due to
data volume or request rate. Shard splitting allows an operator to divide a parent
shard into two child shards, each covering half of the parent's hash range. The
parent continues serving traffic until the children are fully caught up, at which
point traffic is atomically cut over to the children.
Goals:
causes a brief unavailability window (~100ms), masked by client SDK transparent
retries — similar to a clean leader election.
Terminology
FollowerCursoron the parent leader that streams data to a child leader without participating in the parent's replication quorumSplit Trigger
Splits are triggered manually via an admin RPC on the coordinator:
The
oxiaCLI exposes this asoxia admin split-shard. Auto-splitting based onshard size or load metrics is future work.
No New Shard Status
The split lifecycle is not tracked via a new
ShardStatus. The existingstatus enum (
SteadyState,Election,Deleting) remains unchanged.The parent shard must remain free to transition between
SteadyStateandElectionduring the split (which may take a long time). If the parent leadercrashes mid-split, the coordinator runs a normal leader election for it. A
Splittingstatus would conflict withElectionand require special-casing theentire election code path.
Instead, the split state is tracked through a separate
SplitMetadatafield onShardMetadata, orthogonal to the operational status.Cluster Status Extension
The split points are not stored explicitly — they are derived from the children's
Int32HashRangefields onShardMetadata. This naturally extends to future N-waysplits (multiple children, each with its own hash range).
SplitMetadatais set on both parent and children. Phase values:Bootstrap,CatchUp,Cutover.Data Flow
The parent leader pushes data to child leaders via observer cursors. Each child
leader then replicates to its own ensemble followers:
The observer cursor reuses the existing
FollowerCursormachinery: itautomatically sends a snapshot if the child is empty, then tails the WAL. The
only differences from a normal follower cursor are:
QuorumAckTracker.Proto Changes
No new RPCs for snapshot transfer or WAL streaming — the existing
SendSnapshotand
ReplicateRPCs are reused via the observerFollowerCursor.Client Behavior During Split
During phases 1–3, the parent is included in shard assignments and clients write
to it normally. Children are excluded from assignments.
At the end of cutover, the coordinator updates shard assignments: parent is
removed, children are added. Clients receive the update via their long-poll
WaitForNextUpdate()and the existing overlap detection inshard_manager.gohandles the transition — child hash ranges overlap the parent's, so the parent is
automatically evicted from the client's shard map.
Between fencing the parent and clients receiving the updated assignments, writes
to the parent's hash range get transient errors. This window is bounded by child
election time (< 1s) plus assignment propagation. All other shards are unaffected.
Handling Writes During Split
During Bootstrap and CatchUp, the parent continues accepting writes normally.
New writes go through the parent's WAL and are streamed to children via the
observer cursors. The children filter and apply only keys in their hash range.
Coordinator Split Controller Design
Overview
The split controller drives the state machine through 4 phases. It runs alongside
the parent's
ShardControllerand is persisted in the cluster status so itsurvives coordinator restarts. The design prioritizes data safety (no data
loss) and rollback-ability (abort before the point of no return).
Actors
ShardControllerin thecoordinator. Drives the state machine.
shard. The split controller coordinates with it.
Split Phases
stateDiagram-v2 [*] --> Initiation Initiation --> Bootstrap : MW #1: create children, set Phase=Bootstrap Bootstrap --> CatchUp : MW #3,#4: record parentTerm + childLeaders, Phase=CatchUp CatchUp --> Bootstrap : parent term changed (parent leader election) CatchUp --> Bootstrap : child leader changed (child leader election) Bootstrap --> Abort : timeout (pre-cutover) CatchUp --> Abort : timeout (pre-cutover) state "═══ POINT OF NO RETURN ═══" as PONR CatchUp --> PONR : both children caught up (MW #5) PONR --> Cutover Cutover --> [*] : MW #6-#9: fence parent, elect children, update assignments, trigger DeleteShard Abort --> [*] : RemoveObserver, delete children, clear parent Split note right of Bootstrap MW #2: child Term, Leader Fence children (skip if already elected), elect leaders, add observer cursors end note note right of CatchUp Round-based: snapshot parent commitOffset, wait for children commitOffset to reach it. Detects parent OR child leader changes. end note note right of Cutover NO ROLLBACK — must complete. Fence parent, wait children commit, re-elect children in clean term, update shard assignments. Parent deletion handled by ShardController. end note note left of Abort Safe before Cutover only. Parent resumes normal operation. end noteThe point of no return is the transition from CatchUp to Cutover. Before that
point, the split can be aborted and the parent restored to normal operation. After
that point, the parent is fenced (stops accepting writes) and the split must
complete.
Phase Details
Split Initiation (before the state machine)
The coordinator's
InitiateSplit()method is called (e.g. via admin API).Preconditions (validated before any state changes):
SteadyStateSplit == nil)PendingDeleteShardNodesis empty)Before the split controller starts, the coordinator:
This is the first durable record that a split is in progress. If the coordinator
crashes after this point,
restartInProgressSplits()will find the parent'sSplitmetadata and resume.Phase 1: Bootstrap
Purpose: Validate preconditions, create child shard replicas and start
replicating parent data to them via observer cursors.
Steps:
SteadyState.If not, retry with backoff until healthy or timeout.
For each child shard (left, then right):
Fence child ensemble: Send
NewTerm(child_shard, term+1)to all childensemble members. Require quorum (majority) to respond.
Pick child leader: Select the node with the highest offset from the fencing
responses. (On first split, all nodes are empty so this is arbitrary.)
Elect child leader: Send
BecomeLeader(child, child_leader, followerMap)so the child starts replicating to its followers as soon as data arrives. This
is critical: without an elected leader, only the single child leader node would
have the data and a crash there loses it. By electing the child leader now, its
follower cursors replicate received entries to the other ensemble members, and
commitOffsetadvances as followers acknowledge.Add observer on parent: Send
AddFollowerto the parent leader with:observer = true— non-voting, doesn't affect parent's quorumtargetShard = child_shard_id— receiving node creates a follower controllerfor the child shard, not the parent
term = parent_term— the parent's actual term so the child can validate itsplitHashRange = [child.min, child.max]— filter to only replicate keys inthe child's hash range
Transition: → CatchUp. There is no need to wait for snapshot completion
here — snapshot delivery and WAL catch-up are treated uniformly by the CatchUp
phase, which waits for children's
commitOffsetto reach the parent'scommitOffset.What happens on the data path:
The parent leader pushes 2 copies of its data — one per child leader — via
observer cursors. Each child leader then replicates to its own ensemble followers:
FollowerCursorfor each child leader.then continues streaming WAL entries (also filtered). From the coordinator's
perspective there is no distinction between these two phases.
FollowerControllerapplies entries with split filtering:FilterDBForSplit()— post-snapshot, removes out-of-range keys from the DBApplyLogEntryWithSplitFilter()— during WAL catch-up, skips out-of-range opsentries to its own followers immediately. The child's
commitOffsetadvances asa quorum of the child's followers acknowledge.
Failure handling:
NewTermfails to reach quorum: retry with backoff.BecomeLeaderfails: retry with backoff.AddFollowerfails (parent leader unreachable): retry with backoff.Rollback: Remove observer cursors from parent (
RemoveObserverRPC, best-effort),delete child shards from cluster status, clear parent's split metadata.
Phase 2: CatchUp
Purpose: Wait for children to be reasonably close to the parent's current
position before cutting over.
Algorithm (round-based):
Why round-based: The parent keeps accepting writes during CatchUp. A simple
"lag ≤ N" check is racy because the parent advances continuously. Instead, we
snapshot a target offset and wait for children to reach it. If the round times
out (parent is under heavy write load), we re-read a fresh target and try again.
Eventually, a round will succeed when the children catch up to a snapshot of the
parent's position.
Why commitOffset (not headOffset) for children: The children were elected
leader during Bootstrap (step 5), so they are actively replicating to their
followers. We check
commitOffset— notheadOffset— becausecommitOffsetonly advances when a quorum of the child's followers have acknowledged the data.
This guarantees that before we cut over, each child shard has a quorum of replicas
with the data, not just a single copy on the child leader. Without this, a child
leader crash before Cutover would lose data.
Transition: When both children's commitOffset >= parent commitOffset within a
round:
→ Cutover.
Failure handling:
GetStatusfails → retry with backoff.parentTermAtBootstrap:Child leader changed (child leader election): detected by comparing current
child leader with
childLeadersAtBootstrap:RemoveObserver(old_child_leader)on parent — best-effort, removes stale cursorleaders), re-adds observer cursors for the new child leaders
Child followers dead (commitOffset stuck): CatchUp round times out, retries.
If all rounds time out, the overall split timeout triggers abort.
Rollback: Same as Bootstrap —
RemoveObserver, delete children, clear parentsplit metadata.
Phase 3: Cutover
Purpose: Freeze the parent, wait for children to commit all remaining data,
then re-elect children in a clean term so they operate independently of the parent.
Steps:
NewTerm(parent, term+1)to quorum):parentFinalOffset= max headOffset from fencing responses.Wait for children to commit
parentFinalOffset(for each child):GetStatus(child_leader, child_shard)untilcommitOffset >= parentFinalOffset.actively replicating to their followers.
commitOffsetadvancing meansa quorum of child followers have acknowledged all the parent's data.
being terminated, or the child leader may process the last few entries
from its buffer.
Re-elect child leaders in a new term (for each child):
NewTerm(child, term+1)to child ensemble (quorum).BecomeLeader(child, existing_leader)— re-elect the same leader.parent. The child's follower controller (which was receiving from the
parent's observer cursor) is replaced by a fresh leader controller.
computeNewAssignments()and broadcast.Since children no longer have
Splitmetadata they are included in theassignment map; since parent is
Deletingit is excluded. Clients discoverthe two new child shards and start routing to them.
SplitComplete()on the event listener.The coordinator triggers the parent's
ShardController.DeleteShard(), whichretries
DeleteShardRPCs indefinitely with backoff to all ensemble members,then removes the parent from cluster status.
Transition: → Done (split controller exits; parent deletion is async).
Failure handling:
the coordinator will elect a new parent leader (higher term), and fencing will
succeed on the surviving nodes.
GetStatuson child fails: retry with backoff.BecomeLeaderfails: retry with backoff.replicating to followers).
DeleteShardto unreachable ensemble member: the shard controller retriesindefinitely with backoff (not the split controller's concern).
Rollback: NOT POSSIBLE. The parent is fenced. We must complete the split.
The split controller retries all operations indefinitely (within the overall
split timeout).
Abort (Pre-Cutover Only)
When the split times out (default 5 minutes) or is cancelled before reaching
Cutover:
RemoveObserverRPC for eachchild). Best-effort — if the parent leader is dead, skip.
SplitAborted()callback — coordinator closeschild
ShardControllersand recomputes shard assignments.The parent resumes normal operation.
Failure Scenarios
1. Parent leader dies during Init or Bootstrap
What happens: RPCs (
NewTerm,AddFollower,GetStatus) fail withconnection errors.
Recovery: The split controller retries with exponential backoff. Meanwhile,
the coordinator's
ShardControllerfor the parent detects the failure andtriggers a new leader election (higher term). Once the new parent leader is
elected, the split controller's retries succeed against the new leader.
If timeout expires: Split is aborted. Parent is restored to normal.
Caveat: If observers were already added to the old leader, they are lost when
the old leader dies. Bootstrap will re-add them on the new leader.
2. Parent leader dies during CatchUp
What happens:
GetStatus(parent)fails or returns a higher term.Recovery: The coordinator elects a new parent leader (new term). The CatchUp
phase detects
parentTerm != parentTermAtBootstrapand falls back toBootstrap. Bootstrap skips re-fencing (children already have leaders),
re-adds observer cursors on the new parent leader, and the process resumes.
Note: The children may have partial data from the old leader. The new
observer cursors resume from the children's current state — the new parent leader
sends a snapshot if the child is behind the WAL's first offset, otherwise tails
from where the child left off.
3. Parent leader dies during Cutover
What happens: If the parent is already fenced (step 1 complete), the observer
cursors are already terminated and children have the data up to
parentFinalOffset.The remaining steps (elect children, wait for commit) don't involve the parent.
If fence partially succeeded: The split controller retries
NewTermon theparent ensemble. Since a quorum already accepted the new term, subsequent attempts
will succeed (idempotent with same term).
Known issue: If the old parent leader dies mid-fence and a new leader is
elected with a different term, the split controller may get confused about which
term to use. This needs additional handling.
4. Child leader dies during CatchUp
What happens: The observer cursor on the parent targets the old (dead) child
leader. Data stops flowing to the child.
Recovery: The coordinator's shard controller detects the child leader failure
and elects a new child leader (higher term, updated metadata). The CatchUp phase
detects
childLeader != childLeadersAtBootstrap[childId]and:RemoveObserver(old_leader)to the parent (best-effort, cleans up stalecursor)
child leader
5. Child followers dead (commitOffset stuck)
What happens: The child leader receives data from the parent's observer cursor
(headOffset advances) but the child's followers are unreachable, so commitOffset
stays at -1. The CatchUp round times out repeatedly.
Recovery: If the followers come back, commitOffset resumes and the split
completes. If they don't, the overall split timeout (default 5 minutes) expires
and the split is aborted.
6. Follower/ensemble member dies (any phase)
What happens: Quorum operations (
NewTerm,BecomeLeader) succeed as longas a majority of the ensemble responds. Parent deletion is handled by the shard
controller's
deleteShardWithRetries()which retries indefinitely with backoff.7. Coordinator crashes and restarts (any phase)
The split state machine is designed to survive coordinator failures. All split
state is persisted in the cluster status (stored in the metadata service), and
all operations are idempotent, so a new coordinator instance can resume from
exactly where the old one left off.
Persistence: The cluster status contains, for each shard involved in a split:
Split.Phase— the current phase (Bootstrap, CatchUp, Cutover)Split.ChildShardIDs— on the parent shard, identifies the two childrenSplit.ParentShardId— on child shards, points back to the parentSplit.SplitPoint— the hash boundarySplit.ParentTermAtBootstrap— used to detect stale parent observer cursorsSplit.ChildLeadersAtBootstrap— used to detect child leader electionsStartup resume flow (
NewCoordinator()incoordinator.go):ShardControllerfor every shard (including children in a split).Child
ShardControllersdetectSplit != nilwith noChildShardIDsandenter
waitForSplitComplete()— they do NOT trigger leader elections orbalancing, letting the
SplitControllermanage the child's lifecycle.restartInProgressSplits(clusterStatus):Split != nilandlen(ChildShardIDs) > 0(only parent shards match).
SplitController.SplitControllercallscurrentPhase()to read the persisted phaseand enters
driveStateMachine()which resumes from that phase.Resume behavior per phase:
BootstrapNewTermwith a term the servers already accepted is idempotent.BootstrapAddFollowerreplaces them.CatchUpCutoverfenceEnsemble(parent). If the parent was already fenced with this term, theNewTermis idempotent.CutoverparentFinalOffsetis re-computed from the fencing responses (same value since parent is fenced and not accepting writes). Child election is idempotent.CutoverBecomeLeaderon a node that's already leading is safe. Commit wait resumes. Parent deletion triggered viaSplitComplete.Idempotency guarantees that make resume safe:
NewTerm(shard, term)— If the server already accepted this term, it returnsits current head entry. No state change.
AddFollower(observer)— If an observer cursor for this follower already exists,the leader replaces it (closes old, creates new). If the follower name doesn't
match any existing cursor, a new one is created.
BecomeLeader(shard, term)— If the node is already leading this shard at thisterm, it returns success.
DeleteShard(shard, term)— If the shard is already deleted, returns success.Edge case — coordinator crashes during abort:
If the coordinator crashes while aborting (pre-Cutover), the new coordinator will
see the split metadata still present and attempt to resume the split (not re-abort).
This is acceptable because:
SplitControllerwith a full timeout.came back), the split can complete successfully.
SplitControllerwill time out again and abort.8. Split timeout
What happens: The split controller's context expires (default 5 minutes).
If phase < Cutover: Abort procedure runs (remove observers, delete children,
restore parent).
If phase >= Cutover: The timeout doesn't trigger an abort. The split must
complete. The operations continue retrying. (In practice, post-cutover operations
should complete quickly since they're just elections and status polling.)
Sequence Diagram: Happy Path
sequenceDiagram participant SC as Split Controller participant PL as Parent Leader participant CL as Child-L Leader participant CR as Child-R Leader participant CE as Child Ensemble note over SC,CE: BOOTSTRAP SC->>CE: NewTerm(child-L, t+1) CE-->>SC: quorum ack SC->>CL: BecomeLeader(child-L) note right of CL: NOW LEADING — replicates to followers SC->>PL: AddFollower(observer, child-L, hashRange=[0,split]) PL->>CL: snapshot (filtered) PL->>CL: WAL entries (filtered) CL->>CE: replicate to child-L followers note right of CL: commitOffset advances SC->>CE: NewTerm(child-R, t+1) CE-->>SC: quorum ack SC->>CR: BecomeLeader(child-R) note right of CR: NOW LEADING SC->>PL: AddFollower(observer, child-R, hashRange=(split,max]) PL->>CR: snapshot (filtered) PL->>CR: WAL entries (filtered) CR->>CE: replicate to child-R followers note over SC,CE: CATCHUP (round-based) loop until both children caught up SC->>PL: GetStatus → commitOffset = N SC->>CL: GetStatus → commitOffset >= N? SC->>CR: GetStatus → commitOffset >= N? note over SC: If both >= N within 10s → Cutover / Else re-read parent commitOffset end note over SC,CE: ═══ POINT OF NO RETURN ═══ note over SC,CE: CUTOVER SC->>PL: NewTerm(parent, t+1) note right of PL: FENCED — leader=nil, observers killed PL-->>SC: headOffset = N (parentFinalOffset) SC->>CL: poll GetStatus until commitOffset >= N SC->>CR: poll GetStatus until commitOffset >= N SC->>CE: NewTerm(child-L, t+1) SC->>CL: BecomeLeader(child-L) note right of CL: CLEAN TERM SC->>CE: NewTerm(child-R, t+1) SC->>CR: BecomeLeader(child-R) note right of CR: CLEAN TERM note over SC: Update shard assignments (children added, parent excluded) note over SC: SplitComplete() → ShardController.DeleteShard(parent) [async] note over SC: Children now serve trafficSequence Diagram: Parent Leader Dies During CatchUp
sequenceDiagram participant SC as Split Controller participant ShC as Shard Controller participant PL_old as Parent Leader (old) participant PL_new as Parent Leader (new) participant Ch as Children note over SC,Ch: CATCHUP in progress SC->>PL_old: GetStatus → commitOffset = N SC->>Ch: GetStatus(child-L) PL_old-xPL_old: CRASH ShC->>ShC: detects failure ShC->>PL_new: NewTerm(parent, t+1) ShC->>PL_new: BecomeLeader(new) note right of PL_new: new parent leader elected SC->>SC: reads parent metadata — parentTerm != parentTermAtBootstrap SC->>SC: updatePhase(Bootstrap) note over SC,Ch: BOOTSTRAP restart note over SC: Children already have leaders — skip fencing SC->>PL_new: AddFollower(observer, child-L) PL_new->>Ch: snapshot + WAL SC->>PL_new: AddFollower(observer, child-R) PL_new->>Ch: snapshot + WAL note over SC,Ch: → CatchUp → Cutover → DoneSequence Diagram: Child Leader Dies During CatchUp
sequenceDiagram participant SC as Split Controller participant ShC as Shard Controller (child) participant PL as Parent Leader participant CL_old as Child-L Leader (old) participant CL_new as Child-L Leader (new) note over SC,CL_new: CATCHUP in progress PL->>CL_old: WAL entries (observer cursor) CL_old-xCL_old: CRASH ShC->>ShC: detects child-L failure ShC->>CL_new: NewTerm(child-L, t+1) ShC->>CL_new: BecomeLeader(child-L) note right of CL_new: new child-L leader elected SC->>SC: reads child-L metadata — leader != childLeadersAtBootstrap SC->>PL: RemoveObserver(old child-L leader) SC->>SC: updatePhase(Bootstrap) note over SC,CL_new: BOOTSTRAP restart note over SC: Children already have leaders — skip fencing SC->>PL: AddFollower(observer, new child-L leader) PL->>CL_new: snapshot + WAL (to new leader) note over SC,CL_new: → CatchUp → Cutover → DoneSequence Diagram: Split Timeout (Abort)
sequenceDiagram participant SC as Split Controller participant PL as Parent Leader participant Ch as Children note over SC,Ch: BOOTSTRAP or CATCHUP (stuck/slow) SC->>SC: context.WithTimeout expires (5min) note over SC,Ch: ABORT SC->>PL: RemoveObserver(child-L) [best-effort] SC->>PL: RemoveObserver(child-R) [best-effort] SC->>SC: Delete child-L, child-R from cluster status SC->>SC: Clear parent.Split = nil SC->>SC: SplitAborted() → close child ShardControllers note over PL: Parent resumes normal operationSequence Diagram: Coordinator Crashes During CatchUp and Resumes
sequenceDiagram participant C_old as Coordinator (old) participant MS as Metadata Store participant PL as Parent Leader participant Ch as Children note over C_old,Ch: CATCHUP C_old->>MS: updatePhase(CatchUp) note right of MS: persisted: parent.Split.Phase = CatchUp C_old->>Ch: polling children... PL->>Ch: WAL entries (observer cursors active) C_old-xC_old: CRASH note over PL,Ch: Observer cursors keep streaming if parent leader is alive participant C_new as Coordinator (new) C_new->>MS: Load cluster status MS-->>C_new: parent.Split.Phase = CatchUp C_new->>C_new: Create ShardControllers (children: waitForSplitComplete) C_new->>C_new: restartInProgressSplits() → new SplitController alt parent leader survived (same term) C_new->>PL: GetStatus → commitOffset = N C_new->>Ch: GetStatus → commitOffset >= N note over C_new: observers still valid, resume polling else parent leader died (new term) C_new->>C_new: parentTerm != parentTermAtBootstrap → fall back to Bootstrap end note over C_new,Ch: → Cutover → DoneSequence Diagram: Coordinator Crashes During Cutover and Resumes
sequenceDiagram participant C_old as Coordinator (old) participant MS as Metadata Store participant PE as Parent Ensemble participant Ch as Children note over C_old,Ch: CUTOVER C_old->>MS: updatePhase(Cutover) C_old->>PE: NewTerm(parent, t+1) PE-->>C_old: parentFinalOffset = N (parent fenced) C_old->>Ch: Wait children commitOffset >= N Ch-->>C_old: OK C_old->>Ch: NewTerm(child-L, t+1) + BecomeLeader(child-L) C_old-xC_old: CRASH (before child-R election) participant C_new as Coordinator (new) C_new->>MS: Load cluster status MS-->>C_new: parent.Split.Phase = Cutover C_new->>C_new: restartInProgressSplits() → SplitController, phase = Cutover C_new->>PE: NewTerm(parent, t+1) — IDEMPOTENT, same parentFinalOffset C_new->>Ch: children already committed >= N C_new->>Ch: NewTerm(child-L) + BecomeLeader → idempotent C_new->>Ch: NewTerm(child-R) + BecomeLeader → NEW C_new->>Ch: Wait commitOffset >= N Ch-->>C_new: OK note over C_new,Ch: → DoneKey Invariants
Observer cursors use the parent's actual term (not -1). The child validates
the term — if a parent leader election occurs, the old term entries are rejected,
and the CatchUp phase detects the term change and falls back to Bootstrap.
CatchUp detects both parent and child leader elections. Parent term changes
and child leader changes are both detected by comparing against values stored at
Bootstrap time (
ParentTermAtBootstrap,ChildLeadersAtBootstrap). Both triggera fallback to Bootstrap with
RemoveObserverfor stale cursors.Bootstrap is idempotent and incremental. On re-run, it skips fencing children
that already have leaders (elected by the shard controller). It only re-adds
observer cursors on the parent for the current child leaders.
Abort is safe before Cutover. No data has been moved — children are just
passive observers. Removing them and clearing metadata restores the system to
the pre-split state.
Post-Cutover is forward-only. Once the parent is fenced, it stops accepting
writes. The children must be promoted. All post-cutover operations retry
indefinitely.
All operations are idempotent.
NewTermwith the same term is accepted.AddFollowerfor an existing observer is handled.BecomeLeaderon a nodethat's already leading is safe. This enables safe resume after coordinator
restart.
Child quorum commit before cutover. In CatchUp we wait for children's
commitOffset >= target(not headOffset). In Cutover step 2 we wait forcommitOffset >= parentFinalOffset. This ensures data is replicated to aquorum of child followers before the parent is deleted, so a child leader
failure doesn't lose data.
Parent deletion is delegated to ShardController. The split controller
marks the parent as
Deletingand callsSplitComplete(). The coordinatortriggers the parent shard controller's
DeleteShard()which retriesindefinitely with backoff. This reuses the existing deletion mechanism and
survives coordinator restarts (parent is persisted with
Status=Deleting).Future Work
rate metrics.
All reactions