Lab DC4: Allocation and Rebalancing Under Churn
Background
The control plane decides what shards exist; the data plane decides where they
live. That placement is computed by AllocationService — wrapping the
BalancedShardsAllocator (the balancer), the AllocationDeciders (the YES/NO/THROTTLE
gate), and GatewayAllocator (placing existing shards on recovery). Every time a node
joins or leaves, an index is created, or a shard fails, the cluster reroutes: it
recomputes the routing table, moves shards, and recovers them — all of it published
through the same consensus you traced in Lab DC3.
This lab makes you drive that machinery under churn and read the result. You will:
- create indices with replicas and watch them allocate across nodes;
- add a node and watch rebalancing relocate shards to flatten load;
- remove a node and watch replicas reallocate + recover;
- deliberately force an UNASSIGNED shard and diagnose it with
_cluster/allocation/explain; - nudge allocation with
_cluster/reroute; - observe recovery throttling and map every behavior back to the real classes.
This is the data-plane counterpart to the consensus labs. The mechanics live in the deep-dives shard-allocation and recovery; the routing model is in the masterclass sharding-routing. Here you operate it and diagnose it.
Note: "cluster manager" is the role formerly called master; allocation runs on the elected cluster manager as part of computing each new cluster state.
Why This Matters for Contributors
"Shards stuck unassigned," "cluster yellow forever," "rebalancing won't stop,"
"recovery is too slow" — these are among the most common operational issues, and
fixing or even reproducing them requires reading _cluster/allocation/explain and
knowing which decider said NO/THROTTLE. Contributors who touch allocation must
be able to induce these states and diagnose them from the explain API down to the
decider. This lab is that loop.
Prerequisites
-
OpenSearch builds; you can run a multi-node cluster (
./gradlew run -PnumNodes=3). - You read the intensive data-plane section.
- Notes:
mkdir -p ~/opensearch-notes/dc4
Find the allocation classes you'll map behavior to:
cd ~/src/OpenSearch
find server/src/main/java/org/opensearch/cluster/routing/allocation -name "*.java" | sort | head -40
grep -rln "extends AllocationDecider" \
server/src/main/java/org/opensearch/cluster/routing/allocation/decider/ | sort
grep -n "class BalancedShardsAllocator\|weight\|balance\|relocate" \
server/src/main/java/org/opensearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java | head
Bring up 3 nodes:
./gradlew run -PnumNodes=3 | tee ~/opensearch-notes/dc4/run.out
Step-by-Step Tasks
Step 1 — Create indices with replicas; watch allocation
# An index with 6 primaries + 1 replica each = 12 shards to place across 3 nodes.
curl -s -XPUT 'localhost:9200/dc4-a?pretty' -H 'content-type: application/json' -d '{
"settings": { "number_of_shards": 6, "number_of_replicas": 1 }
}'
# Where did they land? (one row per shard copy)
curl -s 'localhost:9200/_cat/shards/dc4-a?v&h=index,shard,prirep,state,node'
# index shard prirep state node
# dc4-a 0 p STARTED runTask-0
# dc4-a 0 r STARTED runTask-1
# dc4-a 1 p STARTED runTask-2
# ...
# Health: green means every primary AND replica is STARTED.
curl -s 'localhost:9200/_cluster/health/dc4-a?pretty' \
| grep -E 'status|active_shards|unassigned_shards|relocating'
Observe: primaries and replicas are spread so that no primary shares a node with its
own replica (SameShardAllocationDecider), and shard counts are roughly even across
the 3 nodes (BalancedShardsAllocator).
# shard count per node — should be roughly balanced (12 / 3 = 4 each):
curl -s 'localhost:9200/_cat/allocation?v&h=node,shards,disk.percent'
cat >> ~/opensearch-notes/dc4/notes.md <<'EOF'
## Initial allocation (3 nodes, 12 shards)
- ~4 shards/node (BalancedShardsAllocator)
- no shard's primary and replica on the same node (SameShardAllocationDecider)
- status: green
EOF
Step 2 — Add a node; watch rebalancing relocate shards
Start a 4th node so the balancer wants to move ~3 shards onto it (12 / 4 = 3 each):
# In a separate shell, add a node to the running gradlew cluster is fiddly;
# the simplest reproducible path is to restart with numNodes=4:
./gradlew --stop
./gradlew run -PnumNodes=4 | tee -a ~/opensearch-notes/dc4/run.out
# Re-create dc4-a if it didn't persist:
curl -s -XPUT 'localhost:9200/dc4-a' -H 'content-type: application/json' -d '{"settings":{"number_of_shards":6,"number_of_replicas":1}}' >/dev/null
# Watch RELOCATING shards as the balancer flattens onto the 4th node:
watch -n1 "curl -s 'localhost:9200/_cat/shards/dc4-a?h=shard,prirep,state,node' | sort | grep -c RELOCATING"
# and health shows relocating_shards > 0 transiently:
curl -s 'localhost:9200/_cluster/health/dc4-a?pretty' | grep -E 'relocating|status'
Note: Adding a node to an already-running
gradlew runcluster is awkward; for a live add/remove without restarts, use the InternalTestCluster path in Step 7. The REST observations are identical either way.
After it settles, the per-node shard count should be ~3 each:
curl -s 'localhost:9200/_cat/allocation?v&h=node,shards'
The relocations are BalancedShardsAllocator lowering its weight function
(per-node weight ~ shard count + index skew + load) — confirm the weight knobs:
grep -n "INDEX_BALANCE_FACTOR\|SHARD_BALANCE_FACTOR\|THRESHOLD\|weight(" \
server/src/main/java/org/opensearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java | head
Step 3 — Remove a node; watch replicas reallocate + recover
Stop one node (simulate a leave). Replicas that lived there go UNASSIGNED, then recover from their primaries onto surviving nodes:
# Identify a non-manager node and stop it (or kill its pid as in Lab DC1).
curl -s 'localhost:9200/_cat/nodes?v&h=name,cluster_manager'
# kill -9 <pid-of-a-non-manager node> # via the *.pid files (Lab DC1 Step 6)
# Immediately: health goes yellow (replicas missing), then back to green as they recover.
curl -s 'localhost:9200/_cluster/health/dc4-a?pretty' \
| grep -E 'status|unassigned_shards|initializing_shards'
# Watch the recovery (INITIALIZING → STARTED) of the displaced replicas:
curl -s 'localhost:9200/_cat/recovery/dc4-a?v&active_only=true&h=index,shard,type,stage,source_node,target_node,bytes_percent'
| Health color | Meaning |
|---|---|
green | every primary and replica STARTED |
yellow | every primary STARTED, but ≥1 replica UNASSIGNED/recovering |
red | ≥1 primary UNASSIGNED — data unavailable for that shard |
Losing a node with replicas elsewhere → yellow then green. Losing the only copy of a primary → red.
Step 4 — Force an UNASSIGNED shard and diagnose it
Make a shard impossible to allocate, then read why with the explain API. The cleanest way is to demand more replicas than there are nodes to hold them:
# 4 nodes → ask for 5 copies (1 primary + 4 replicas). One replica per shard can't be
# placed without violating SameShardAllocationDecider → UNASSIGNED.
curl -s -XPUT 'localhost:9200/dc4-stuck?pretty' -H 'content-type: application/json' -d '{
"settings": { "number_of_shards": 1, "number_of_replicas": 4 }
}'
curl -s 'localhost:9200/_cluster/health/dc4-stuck?pretty' | grep -E 'status|unassigned'
# status: yellow, unassigned_shards: 1
# THE diagnostic tool — explain the unassigned shard:
curl -s 'localhost:9200/_cluster/allocation/explain?pretty' -H 'content-type: application/json' -d '{
"index": "dc4-stuck", "shard": 0, "primary": false
}'
Read the response fields that matter:
| Field | What it tells you |
|---|---|
current_state | unassigned |
unassigned_info.reason | e.g. INDEX_CREATED, NODE_LEFT, ALLOCATION_FAILED |
can_allocate | overall verdict: no / throttled / yes |
node_allocation_decisions[].deciders[] | per-node, per-decider decision + human explanation |
For the over-replicated case you'll see, on every candidate node, a NO from
same_shard ("a copy of this shard is already on node X"). That decider name maps
straight to SameShardAllocationDecider:
grep -n "same_shard\|class SameShardAllocationDecider\|a copy of this shard is already" \
server/src/main/java/org/opensearch/cluster/routing/allocation/decider/SameShardAllocationDecider.java | head
cat >> ~/opensearch-notes/dc4/notes.md <<'EOF'
## Forced UNASSIGNED + explain
- dc4-stuck: 1p/4r on 4 nodes → 1 replica cannot be placed
- explain: can_allocate=no; every node's same_shard decider = NO
- maps to SameShardAllocationDecider
EOF
Now induce a different cause — a disk watermark — and re-explain to see a different decider win:
# Drop the high watermark very low so DiskThresholdDecider refuses placement.
curl -s -XPUT 'localhost:9200/_cluster/settings' -H 'content-type: application/json' -d '{
"transient": {
"cluster.routing.allocation.disk.threshold_enabled": true,
"cluster.routing.allocation.disk.watermark.low": "1b",
"cluster.routing.allocation.disk.watermark.high": "1b",
"cluster.routing.allocation.disk.watermark.flood_stage": "1b"
}
}'
curl -s -XPUT 'localhost:9200/dc4-disk' -H 'content-type: application/json' -d '{"settings":{"number_of_shards":1,"number_of_replicas":0}}'
curl -s 'localhost:9200/_cluster/allocation/explain?pretty' -H 'content-type: application/json' -d '{"index":"dc4-disk","shard":0,"primary":true}' \
| grep -A3 -iE 'disk_threshold|watermark|can_allocate'
# Now reset the watermarks!
curl -s -XPUT 'localhost:9200/_cluster/settings' -H 'content-type: application/json' -d '{
"transient": { "cluster.routing.allocation.disk.watermark.low": null,
"cluster.routing.allocation.disk.watermark.high": null,
"cluster.routing.allocation.disk.watermark.flood_stage": null }
}'
Same API, different decider (disk_threshold → DiskThresholdDecider). The explain
API is how you turn "stuck shard" into "this exact decider, on these nodes, for this
reason."
Step 5 — Nudge allocation with _cluster/reroute
_cluster/reroute lets you force or retry allocation. Two common moves:
# (a) Retry shards that hit MaxRetryAllocationDecider (gave up after N failures):
curl -s -XPOST 'localhost:9200/_cluster/reroute?retry_failed=true&pretty' | grep -E 'acknowledged'
# (b) Dry-run a manual move and see the deciders WITHOUT applying it:
curl -s -XPOST 'localhost:9200/_cluster/reroute?explain=true&dry_run=true&pretty' \
-H 'content-type: application/json' -d '{
"commands": [
{ "move": { "index": "dc4-a", "shard": 0,
"from_node": "runTask-0", "to_node": "runTask-1" } }
]
}' | grep -E 'decisions|explanation|"decision"' | head
retry_failed=true resets the MaxRetryAllocationDecider counter — the fix for "the
shard failed 5 times and the cluster stopped trying." Confirm the decider:
grep -n "MAX_RETRY\|class MaxRetryAllocationDecider\|retry_failed" \
server/src/main/java/org/opensearch/cluster/routing/allocation/decider/MaxRetryAllocationDecider.java | head
Step 6 — Observe recovery throttling
Recovery is throttled so a node join doesn't saturate disk/network. Two knobs and
the ThrottlingAllocationDecider:
# How many shards can recover at once per node, and at what byte rate:
curl -s 'localhost:9200/_cluster/settings?include_defaults=true&filter_path=**.recovery,**.node_concurrent_recoveries&flat_settings=true&pretty' \
| grep -iE 'recover|concurrent'
# Force visible throttling: lower the limits, then add load.
curl -s -XPUT 'localhost:9200/_cluster/settings' -H 'content-type: application/json' -d '{
"transient": {
"indices.recovery.max_bytes_per_sec": "1mb",
"cluster.routing.allocation.node_concurrent_recoveries": 1
}
}'
# Bump replicas to trigger many recoveries; watch some sit in INITIALIZING/THROTTLED:
curl -s -XPUT 'localhost:9200/dc4-a/_settings' -H 'content-type: application/json' -d '{"index":{"number_of_replicas":3}}'
curl -s 'localhost:9200/_cat/recovery/dc4-a?v&active_only=true&h=shard,stage,bytes_percent,source_node,target_node'
# The explain API will show THROTTLE (not NO) for replicas waiting their turn:
curl -s 'localhost:9200/_cluster/allocation/explain?pretty' -H 'content-type: application/json' -d '{"index":"dc4-a","shard":0,"primary":false}' \
| grep -iE 'throttl|can_allocate'
# Reset:
curl -s -XPUT 'localhost:9200/_cluster/settings' -H 'content-type: application/json' -d '{
"transient": { "indices.recovery.max_bytes_per_sec": null,
"cluster.routing.allocation.node_concurrent_recoveries": null }
}'
THROTTLE ≠ NO: it means "yes, eventually, but not right now" — the cluster is
pacing recovery. Confirm:
grep -n "THROTTLE\|class ThrottlingAllocationDecider\|node_concurrent_recoveries" \
server/src/main/java/org/opensearch/cluster/routing/allocation/decider/ThrottlingAllocationDecider.java | head
Step 7 (optional) — Live add/remove with InternalTestCluster
For deterministic add/remove without restarting gradlew run:
// server/src/test/java/.../DC4ChurnIT.java
package org.opensearch.cluster.routing.allocation;
import org.opensearch.cluster.health.ClusterHealthStatus;
import org.opensearch.test.OpenSearchIntegTestCase;
import org.opensearch.test.OpenSearchIntegTestCase.ClusterScope;
import org.opensearch.test.OpenSearchIntegTestCase.Scope;
import static org.opensearch.test.hamcrest.OpenSearchAssertions.assertAcked;
import static org.hamcrest.Matchers.equalTo;
@ClusterScope(scope = Scope.TEST, numDataNodes = 3)
public class DC4ChurnIT extends OpenSearchIntegTestCase {
public void testAddNodeRebalancesThenRemoveRecovers() throws Exception {
assertAcked(prepareCreate("dc4")
.setSettings(org.opensearch.common.settings.Settings.builder()
.put("index.number_of_shards", 6)
.put("index.number_of_replicas", 1)));
ensureGreen("dc4");
// ADD a node → rebalancing should move shards onto it.
String added = internalCluster().startNode();
ensureGreen("dc4");
long onNew = client().admin().cluster().prepareState().get().getState()
.getRoutingNodes().node(
internalCluster().clusterService(added).localNode().getId()).size();
logger.info("--> shards on the new node after rebalance: {}", onNew);
assertTrue("new node should receive shards", onNew > 0);
// REMOVE a node → replicas reallocate + recover; cluster returns to green.
internalCluster().stopRandomDataNode();
ensureGreen("dc4"); // recovers within the green timeout
ClusterHealthStatus status = client().admin().cluster().prepareHealth("dc4")
.get().getStatus();
assertThat(status, equalTo(ClusterHealthStatus.GREEN));
}
}
./gradlew :server:internalClusterTest \
--tests "org.opensearch.cluster.routing.allocation.DC4ChurnIT" \
-Dtests.logger.org.opensearch.cluster.routing.allocation=DEBUG
This asserts the two churn behaviors deterministically: the added node receives shards (rebalance), and after a removal the cluster recovers back to green.
Deliverables
-
~/opensearch-notes/dc4/notes.mdwith: initial balanced allocation; the rebalance onto an added node; the yellow→green recovery after a removal; the_cluster/allocation/explainoutput for two different stuck causes (same_shardanddisk_threshold) each mapped to its decider class; and the THROTTLE-during-recovery observation. -
One
_cluster/rerouteinvocation (retry or dry-run move) with its decision output. -
(Optional) A green
DC4ChurnIT.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
_cat/shards shows UNASSIGNED you didn't expect | a decider said NO/THROTTLE | run _cluster/allocation/explain for that shard |
Cluster stuck yellow after node removal | not enough nodes for the replica count | reduce number_of_replicas or add nodes; check same_shard in explain |
Cluster red | a primary is unassigned (lost its only copy) | explain the primary; check disk/MaxRetry; reroute?retry_failed=true |
| Shards never recover | recovery throttled too low | raise indices.recovery.max_bytes_per_sec / node_concurrent_recoveries |
| Everything UNASSIGNED after the disk experiment | you forgot to reset the watermarks | reset cluster.routing.allocation.disk.watermark.* to null |
| Rebalance never happens | cluster.routing.rebalance.enable not all, or cluster not green | check rebalance settings; rebalancing waits for indices_all_active |
_cluster/reroute retry has no effect | shards didn't hit MaxRetry | the cause is a different decider — explain it |
Expected Output
Indices allocate ~evenly with no primary/replica colocated; adding a node triggers
visible RELOCATING shards and re-flattens the per-node count; removing a node goes
yellow→green as replicas recover from primaries (throttled); a forced
over-replication stays UNASSIGNED with _cluster/allocation/explain naming the
same_shard decider, and a forced low disk watermark names disk_threshold; lowering
recovery limits makes the explain show THROTTLE.
Stretch Goals
-
Use
index.routing.allocation.require._nameto pin an index to one node, then explain a replica that can't be placed (thefilterdecider →FilterAllocationDecider). -
Turn on awareness (
cluster.routing.allocation.awareness.attributes: zone) with fakednode.attr.zonevalues and watchAwarenessAllocationDeciderspread copies across zones. -
Set
cluster.routing.allocation.enable: none, create an index, and confirm everything stays UNASSIGNED with theenabledecider (EnableAllocationDecider); then flip it back toalland watch allocation proceed. -
Read
RoutingNodesand trace howAllocationService.reroutemutates it during one pass, then publishes the new routing table as a cluster-state change (Lab DC3). Link shard-allocation.
Coding Exercises
You drove allocation by hand with curl and explain; now turn each behaviour into a
test or a small variant. You already have the DC4ChurnIT skeleton (Step 7) — build on
it. Locate every class with rg first
(rg -l "class SameShardAllocationDecider" server/src/main); never trust a stale line
number.
-
(warm-up) Assert the
SameShardAllocationDeciderinvariant in an integ test. ExtendDC4ChurnIT: afterensureGreen("dc4"), walk theRoutingNodesand assert that no node holds both the primary and a replica of the same shard. Read the routing model withrg -n "class RoutingNodes|node(\|assignedShards" server/src/main/java/org/opensearch/cluster/routing/RoutingNodes.java. Verify:./gradlew :server:internalClusterTest --tests "...DC4ChurnIT". -
(core) Assert rebalance onto an added node, quantitatively. Strengthen Step 7's
assertTrue(onNew > 0)into a tighter bound: with 12 shards over 4 nodes the new node should end with at least 2 (ideally ~3). Read the weight knobs first (rg -n "INDEX_BALANCE_FACTOR|SHARD_BALANCE_FACTOR|THRESHOLD" server/src/main/java/org/opensearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java) and write the assertion so it documents what the balancer is optimising. -
(core) Reproduce the
same_shardNOas a unit test. Find the decider's tests (rg -l "class SameShardAllocationDeciderTests|class SameShardRoutingTests" server/src/test). Add a unit test that builds aRoutingAllocationwith a shard copy already on a node and assertsdecide(...)returnsDecision.NOfor that node — the in-code version of the explain output you captured in Step 4. This avoids a live cluster entirely. -
(core) Allocation-under-churn integ test: removal recovers to green. Generalise Step 7 into a loop: add a node,
ensureGreen, remove a random data node,ensureGreen, repeat 3 times, asserting green after each step and thatunassignedShards()returns to 0. This is the churn-stability contract a balancer change must not break. ReadstopRandomDataNode/startNodesemantics inrg -n "stopRandomDataNode|startNode" test/framework/src/main/java/org/opensearch/test/InternalTestCluster.java. -
(core) Assert
THROTTLE ≠ NOfor the throttling decider. FindThrottlingAllocationDeciderTests(rg -l "ThrottlingAllocationDeciderTests"). Add a test that setsnode_concurrent_recoveries: 1, puts one recovery in flight, and asserts a second waiting replica's decision isDecision.THROTTLE(notNO) — "yes, eventually, not now." This is the in-code form of the explainTHROTTLEyou saw in Step 6. -
(advanced) An allocation-under-churn IT with a custom decider. Advanced challenge: read how deciders are registered (
rg -n "AllocationDecider|class AllocationDeciders|createAllocationDeciders" server/src/main/java/org/opensearch/cluster/routing/allocation/), then write a tinyAllocationDeciderthat refuses to place more than K shards of one index on any node (an index-spread cap), register it in a test plugin'sgetAllocationDeciders, and write anOpenSearchIntegTestCasethat creates an index, churns nodes, and asserts your cap is never violated across the run — and that_cluster/allocation/explain(drive it via the Java client) names your decider when it bites. The deliverable is one IT proving a custom placement constraint holds under add/remove churn, propagated through the same publish/commit you traced in Lab DC3.
Issues to Practice On
"Shards stuck unassigned" and "rebalancing won't stop" are perennial on
opensearch-project/OpenSearch. Hunt them (labels move; confirm on the tracker):
gh issue list --repo opensearch-project/OpenSearch --label "good first issue" --state open
gh issue list --repo opensearch-project/OpenSearch --label "bug" --state open --search "allocation OR unassigned OR rebalance OR recovery"
gh issue list --repo opensearch-project/OpenSearch --label "Storage" --state open
gh label list --repo opensearch-project/OpenSearch | grep -iE "alloc|cluster|storage|recovery|flaky"
Representative patterns. (1) "Shard stuck UNASSIGNED, explain shows decider X" —
reproduce the exact decider with a small index + settings (as in Step 4), confirm the
verdict in a ...DeciderTests unit test, fix the logic or the message, ship the test.
(2) "Rebalancing thrashes / never settles" — reproduce with a churn IT, read the
BalancedShardsAllocator weight function via rg, and either fix the weight/threshold
or add a regression IT that asserts the cluster reaches a stable shard count. Arc:
reproduce → locate via rg → fix → test → PR with CHANGELOG + DCO.
Planted-bug drill. In SameShardAllocationDecider (locate with
rg -n "canForceAllocatePrimary|decide" server/src/main/java/org/opensearch/cluster/routing/allocation/decider/SameShardAllocationDecider.java),
flip the colocation check so it returns Decision.YES where it should return NO. Run
./gradlew :server:test --tests "*SameShard*" and watch which test goes red — that
test is the guard against primary+replica colocation (a durability hazard). Revert,
then add the RoutingNodes invariant assertion from Exercise 1 so this regression is
caught at the integ level too.
Etiquette: claim the issue first, reproduce before theorising, and every PR ships a test +
CHANGELOG.mdentry + DCOSigned-off-by(git commit -s). See community-interaction.
Validation / Self-check
Cite an API field or a class for each:
- After initial allocation, which decider guarantees a shard's primary and replica are
never on the same node? How would you confirm it from
_cat/shards? - You added a node and saw
RELOCATINGshards. What is the balancer optimizing, and which class/setting controls the weight function? - A node left and the cluster went yellow then green — but a different failure made it red. Explain the difference in terms of primaries vs replicas.
- You forced a stuck shard two different ways. For each, name the field in
_cluster/allocation/explainthat revealed the cause and the decider class it maps to. - What is the difference between a decider returning
NOvsTHROTTLE? Give a concrete example of each you produced in this lab. - When is
_cluster/reroute?retry_failed=truethe right fix, and which decider's counter does it reset? - Tie it back to consensus: every one of these reallocations changed the cluster state. Through which two-phase protocol did each new routing table reach the other nodes? (Link Lab DC3.)
When you can induce an unassigned shard, diagnose it down to the exact decider with the
explain API, fix it with reroute, and explain how the new routing table propagates
via consensus — you've completed Lab DC4 and the Distributed Consensus masterclass.
Return to the intensive and answer its Validation questions, then continue
to a sibling masterclass: query-engine or
sharding-routing.