Level 4: Cluster Coordination and State
This is the hardest level in the curriculum so far, and one of the hardest in the whole book. Say it plainly: the coordination layer is where strong engineers get humbled. It is a distributed consensus protocol (Zen2 — a Raft-like algorithm), an immutable versioned state object, a two-phase publish/commit protocol, two cooperating single-threaded services, and a shard allocation engine — all interacting. You will not understand it in one pass. You will read it, run it, watch the logs, re-read it, and only then will the picture cohere.
This level is the OpenSearch analog of Tez's state-machine internals (VertexImpl/DAGImpl). Where
Tez has a DAG state machine driven by an async dispatcher, OpenSearch has a cluster state driven
by consensus and applied through a publish protocol. Both reward the same approach: read the state
object, read the transitions, run it, watch it move.
Warning: Do not start Level 4 until you can trace REST → Transport → Action without a guide (Level 3). The coordination layer assumes you know what a
TransportAction,Writeable, and the cluster manager (formerly master) are.
Learning Objectives
By the end of Level 4 you must be able to:
- Explain the coordination layer: the
Coordinator, election, joins, and publish/commit, and how it forms and maintains a cluster. - Describe
ClusterStateas an immutable, versioned snapshot and name its four major components:Metadata,RoutingTable,DiscoveryNodes,ClusterBlocks. - Distinguish the two services: the cluster-manager service (
MasterService) that computes new states from update tasks, and theClusterApplierServicethat applies committed states. - Trace a cluster state update from a
ClusterStateUpdateTaskthrough computation, two-phase publication, and application — and explain why each phase exists. - Distinguish a
ClusterStateApplierfrom aClusterStateListenerand say when to use each. - Explain shard allocation:
AllocationService,RoutingAllocation, and theAllocationDeciderschain, and read a single decider end to end. - Read and reason about
org.opensearch.cluster.coordinationandorg.opensearch.cluster.routing.allocationwithout a guide.
The Coordination Layer
Everything in OpenSearch that is "the cluster agreeing on something" runs through
org.opensearch.cluster.coordination. The protagonist is the Coordinator: a per-node object
that implements OpenSearch's consensus (called Zen2, a Raft-inspired protocol that replaced the
original "Zen" discovery). It owns election, joins, failure detection, and publication.
find server/src/main/java -path "*cluster/coordination*" -name "*.java" | sort
The cast you will meet in Lab 4.1:
| Class | Responsibility |
|---|---|
Coordinator | The orchestrator: discovery, election, joins, publication, failure handling |
CoordinationState | The persisted consensus state: term, last-accepted/committed state, voting config |
PreVoteCollector | Pre-voting round (avoids disruptive elections before a real vote) |
ElectionSchedulerFactory | Randomized, backing-off election timers (prevents thundering herds) |
JoinHelper | Sends/receives join requests; a follower joins an elected manager |
FollowersChecker | The manager pings followers; removes ones that stop responding |
LeaderChecker | A follower pings the manager; triggers a new election if it disappears |
ClusterFormationFailureHelper | Produces the "cluster not formed; reason: ..." diagnostics |
PublicationTransportHandler | The transport handler for two-phase publish then commit |
Consensus in one paragraph. A cluster-manager-eligible node that thinks there is no leader starts
a pre-vote (PreVoteCollector). If it gathers enough pre-votes, it bumps the term and starts a
real election, collecting join votes from a quorum of the voting configuration
(CoordinationState.VoteCollection). Once it has a quorum it becomes the elected manager, and from
then on it is the only node allowed to compute and publish new cluster states. Quorum-based
voting is what prevents split-brain: with a voting config of N nodes, you need ⌊N/2⌋+1 votes, so two
disjoint majorities cannot both exist.
The first cluster ever formed needs a seed: cluster.initial_cluster_manager_nodes (deprecated
alias: cluster.initial_master_nodes) names the bootstrap voting configuration. You set it once,
on first formation, and never again — it is ignored after the cluster has a committed state.
See the discovery & coordination deep dive and the cluster state publishing deep dive.
ClusterState: The Immutable Versioned Snapshot
ClusterState is the single source of truth about the cluster. It is immutable: every change
produces a new ClusterState with an incremented version. The elected cluster manager publishes
it to every node; all nodes converge on the same versioned snapshot.
find server/src/main/java -name "ClusterState.java"
grep -n "public.*version\|Metadata\|RoutingTable\|DiscoveryNodes\|ClusterBlocks\|Builder\|long version" \
server/src/main/java/org/opensearch/cluster/ClusterState.java | head -40
Its four major components:
| Component | Class | What it holds |
|---|---|---|
| Metadata | Metadata | Index settings/mappings/aliases, templates, persistent settings, custom metadata |
| Routing table | RoutingTable | Which shard copies (primary/replica) are assigned to which node, and their state |
| Nodes | DiscoveryNodes | The set of nodes (id, name, address, roles), and who is the cluster manager |
| Blocks | ClusterBlocks | Cluster/index-level blocks (e.g. read-only, no-cluster-manager) |
Two more properties matter constantly:
version— a monotonically increasing number per published state. "The state moved from v42 to v43" is the unit of progress you will watch in logs.stateUUID— a unique id for this exact state; used to validate diffs.
Because publishing the whole state every time would be expensive, OpenSearch ships diffs: a node
already on v42 receives only the Diff to reach v43 (Diffable/Diff). Full treatment in
Lab 4.2 and the
cluster state deep dive.
The Two Services: Compute vs Apply
OpenSearch separates deciding a new state from applying it. Two services, both effectively
single-threaded, both hung off ClusterService:
| Service | Class | Runs where | Job |
|---|---|---|---|
| Cluster-manager service | MasterService (a.k.a. cluster-manager service) | only on the elected manager | Batches ClusterStateUpdateTasks, computes the next ClusterState, publishes it |
| Applier service | ClusterApplierService | every node | Applies each committed ClusterState to local components |
find server/src/main/java -name "MasterService.java" -o -name "ClusterApplierService.java" -o -name "ClusterService.java"
grep -n "submitStateUpdateTask\|runTasks\|calculateTaskOutputs\|publish" \
server/src/main/java/org/opensearch/cluster/service/MasterService.java | head
Why split them? Only the manager may compute a new state (consensus authority). But every node must apply the committed state to its own services (routing, mappings, settings, allocation). Splitting compute (manager-only) from apply (all nodes) is what lets the same state object drive the whole cluster.
A state change always flows: update task → compute (manager) → publish → commit → apply (all nodes). The next section diagrams it.
A Cluster State Update, End to End
flowchart TD
SRC[Trigger: PUT _cluster/settings,<br/>create index, node join, shard event] --> TASK[submitStateUpdateTask<br/>ClusterStateUpdateTask]
TASK --> BATCH[MasterService batches tasks<br/>by ClusterStateTaskExecutor]
BATCH --> COMP[executor.execute oldState -> newState<br/>version++ , new stateUUID]
COMP --> PUB1[PublicationTransportHandler<br/>PHASE 1: PUBLISH diff to all nodes]
PUB1 -->|each node validates & ACKs| QUORUM{quorum of<br/>cluster-manager nodes<br/>ACKed?}
QUORUM -->|no| FAIL[publication fails;<br/>state NOT applied; retry/step down]
QUORUM -->|yes| PUB2[PHASE 2: COMMIT to all nodes]
PUB2 --> APPLY[each node: ClusterApplierService.applyState]
APPLY --> AP[ClusterStateAppliers run first<br/>routing, mappings, allocation]
AP --> LIS[ClusterStateListeners run after<br/>observers, plugins, your listener]
LIS --> ACKD[task listeners ACKed; update complete]
The two phases are the crux:
- Publish (phase 1): the manager sends the new state (as a diff) to every node. Each node validates it and ACKs, but does not apply it yet. The manager waits for a quorum of cluster-manager-eligible nodes to ACK.
- Commit (phase 2): once a quorum has ACKed, the manager sends a commit. Now every node
applies the state via
ClusterApplierService.
If the quorum is not reached, the state is never committed and never applied — this is what keeps
a partitioned manager from unilaterally changing the cluster. On apply, appliers run before
listeners: appliers (ClusterStateApplier) make the node's components consistent with the new
state (and may fail the apply); listeners (ClusterStateListener) are fire-and-forget observers.
Both halves are covered in Lab 4.2.
Shard Allocation
The routing table doesn't compute itself. When shards are unassigned (new index, node left, restored
snapshot), the AllocationService runs to place them, gated by a chain of AllocationDeciders.
find server/src/main/java -path "*routing/allocation*" -name "AllocationService.java"
find server/src/main/java -path "*routing/allocation/decider*" -name "*.java" | sort
| Class | Role |
|---|---|
AllocationService | Entry point: reroute(...), applyStartedShards(...), applyFailedShards(...) |
RoutingAllocation | The mutable working context for one allocation round (routing nodes, deciders, state) |
RoutingNodes | Mutable view of shard→node assignment being computed |
AllocationDeciders | The ordered chain of deciders; each returns a Decision |
BalancedShardsAllocator | The default balancer that proposes moves within what deciders allow |
Decision | YES / NO / THROTTLE with an explanation string |
Common deciders you will meet (and fix in Lab 4.4):
| Decider | Prevents |
|---|---|
SameShardAllocationDecider | Putting a primary and its replica on the same node |
MaxRetryAllocationDecider | Endlessly retrying a shard that keeps failing to allocate |
DiskThresholdDecider | Allocating onto nodes over the disk watermark |
AwarenessAllocationDecider | Violating zone/rack awareness |
FilterAllocationDecider | Ignoring include/exclude/require allocation filters |
ThrottlingAllocationDecider | Too many concurrent recoveries on a node |
A Decision is the unit of correctness here. A wrong Decision.YES/NO, or an unhelpful
explanation, is exactly the kind of bug Lab 4.4 fixes. See the
shard allocation deep dive.
Key Classes Quick Reference
| Class | Package | Role |
|---|---|---|
Coordinator | org.opensearch.cluster.coordination | Consensus: discovery, election, joins, publication |
CoordinationState | org.opensearch.cluster.coordination | Persisted term, accepted/committed state, voting config |
JoinHelper / PreVoteCollector | org.opensearch.cluster.coordination | Joins and pre-voting |
FollowersChecker / LeaderChecker | org.opensearch.cluster.coordination | Bidirectional failure detection |
PublicationTransportHandler | org.opensearch.cluster.coordination | Two-phase publish then commit |
ClusterState | org.opensearch.cluster | The immutable, versioned snapshot |
Metadata / RoutingTable / DiscoveryNodes / ClusterBlocks | org.opensearch.cluster.* | The four state components |
MasterService | org.opensearch.cluster.service | Cluster-manager service: computes states from tasks |
ClusterApplierService | org.opensearch.cluster.service | Applies committed states on every node |
ClusterService | org.opensearch.cluster.service | Ties the two services together; addListener, submitStateUpdateTask |
ClusterStateUpdateTask / ClusterStateTaskExecutor | org.opensearch.cluster | A state change + how a batch of them computes a new state |
ClusterStateApplier / ClusterStateListener | org.opensearch.cluster | Apply hooks (consistency) vs observe hooks (reactions) |
AllocationService / RoutingAllocation / AllocationDeciders | org.opensearch.cluster.routing.allocation | Shard placement and its gating |
The Labs
| Lab | Title | Type |
|---|---|---|
| 4.1 | Read the Coordinator and Cluster-Manager Election | Code-reading (90 min trace) |
| 4.2 | ClusterState, Publishing, and the Applier Model | Reading + live TRACE logging |
| 4.3 | Build It — A Custom ClusterStateListener / Custom Metadata | Build-it project |
| 4.4 | Fix It — An AllocationDecider Edge Case | Fix-it lab with a diff |
Deliverables
You must demonstrate all of the following before advancing to Level 5:
- A 90-minute guided trace from node start → election → first published state, with class/file citations (Lab 4.1).
-
A
_cluster/settingschange watched live inTRACElogs, with the state version incrementing (Lab 4.2). -
A working plugin whose
ClusterStateListenerlogs index create/delete events, exercised under a node test (Lab 4.3). -
A fixed
AllocationDeciderwith a passing unit test and a_cluster/allocation/explainreproduction (Lab 4.4). -
From memory: the four components of
ClusterState, the two services, and the two phases of publication.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Mutating a ClusterState in place | Compile/illegal-state errors; you misread the model | State is immutable; build a new one via ClusterState.builder(oldState) |
Confusing ClusterStateApplier with ClusterStateListener | Wrong extension point; missed consistency requirement | Appliers ensure consistency (run first, may fail apply); listeners only observe |
| Thinking publish == commit | Misunderstand partition behavior | Two phases: publish+ACK (quorum), then commit; uncommitted states are never applied |
Setting cluster.initial_cluster_manager_nodes on a running cluster | No effect; confusion | It seeds only the first bootstrap; ignored once a state is committed |
| Reading deciders as boolean | You miss THROTTLE | A Decision is YES/NO/THROTTLE; throttling is not rejection |
| Blocking inside an applier/listener | Stalls the single-threaded applier; cluster freezes | Appliers/listeners must be fast and non-blocking |
Expecting master terminology to be gone | You can't find code | master survives as deprecated aliases throughout (MasterService, master_timeout) |
How to Verify Success
# 1. You can see the current cluster state version and who is manager.
curl -s 'localhost:9200/_cluster/state/version,master_node?pretty'
curl -s 'localhost:9200/_cat/cluster_manager?v'
# 2. You can find any coordination/allocation class by grep, not memory.
find server/src/main/java -path "*cluster/coordination*" -name "Coordinator.java"
find server/src/main/java -path "*routing/allocation/decider*" -name "SameShardAllocationDecider.java"
# 3. You can watch a state version increment.
curl -s -XPUT localhost:9200/_cluster/settings -H 'Content-Type: application/json' \
-d '{"transient":{"cluster.routing.allocation.enable":"all"}}'
curl -s 'localhost:9200/_cluster/state/version?pretty' # version went up
When you can explain election, the two services, the two-phase publish, and a single decider — and prove each with a command — you are ready for Level 5: Testing and Debugging.