Lab SR2: Shrink and Split an Index
Background
The concept chapter argued — from the partition model — that:
_splitgrows the shard count by a multiplicative factor by copying the parent's segments into children and deleting wrong-partition documents; no document is rehashed, and each old shard becomes a contiguous set of new shards._shrinkreduces the shard count by a factor by hard-linking the source shards' segment files into one target shard (shared inodes — no byte is rewritten)._cloneis the 1:1 case (hard-link everything into a new index).
This lab makes you do both resizes on a running cluster and then prove the
mechanism on disk: you will watch shard counts change in _cat/shards, segment
files change in _cat/segments, and — the payoff — confirm with ls -i/find that
a shrunk index's segment files share inodes with the source index's files. That
inode identity is the hard link, made visible.
Why this matters for contributors
Resize is where routing, allocation, recovery, and the store all meet. Anyone working the in-place split design effort (#12918, #13923, #13925) must understand exactly what today's out-of-place resize does — what it copies, what it hard-links, what it recovers, and why the source must be read-only and (for shrink) co-located. You can't improve the operation you can't observe.
Prerequisites
-
A single-node OpenSearch cluster at
localhost:9200. (Single node is fine; for shrink you'd normally co-locate primaries on one node — with one node that's automatic.) -
Shell access to the node's
path.datadirectory so you canfind/ls -ithe on-disk segment files. Note where it is:bash curl -s 'localhost:9200/_nodes/_local/settings?filter_path=**.path' | jq . # or, if you launched ./gradlew run, look under build/testclusters/.../data - You finished Lab SR1 (you can predict a shard).
-
jqand basicfind/ls -iavailable.
Step 1 — Locate the resize code
# The create-index service implements all three resizes.
grep -rn "ResizeType\|SHRINK\|SPLIT\|CLONE\|resizeIndex\|prepareResizeIndexSettings\|validateSplitIndex\|validateShrinkIndex" \
server/src/main/java/org/opensearch/cluster/metadata/MetadataCreateIndexService.java
# The request/transport action and the hard-link recovery from local shards.
grep -rn "class ResizeRequest\|class TransportResizeAction\|recoverFromLocalShards\|addIndices\|hardLink\|Files.createLink" \
server/src/main/java/org/opensearch/action/admin/indices/shrink/ \
server/src/main/java/org/opensearch/index/store/ \
server/src/main/java/org/opensearch/indices/recovery/ 2>/dev/null | head -30
Note Files.createLink (or a hardLinkOrCopy helper) — that's the literal hard
link a shrink/clone makes. Split, by contrast, goes through a local-shards recovery
that copies and then prunes.
Step 2 — Create a source index with a known geometry and data
Pin number_of_routing_shards so the factor rules are crystal clear. Start at
4 shards with R = 1024 (so you can split up to 1024 and shrink down to 1).
curl -s -XPUT 'localhost:9200/src-idx' -H 'Content-Type: application/json' -d '{
"settings": {
"index.number_of_shards": 4,
"index.number_of_routing_shards": 1024,
"index.number_of_replicas": 0
}
}' | jq .
# Index 2,000 docs so each shard has visible segments.
for i in $(seq 1 2000); do
printf '{"index":{"_id":"%d"}}\n{"n":%d,"pad":"some text for doc %d"}\n' "$i" "$i" "$i"
done | curl -s -H 'Content-Type: application/x-ndjson' \
'localhost:9200/src-idx/_bulk?refresh=true' --data-binary @- | jq '.errors'
curl -s 'localhost:9200/_cat/shards/src-idx?v&h=index,shard,prirep,docs,store'
Record the per-shard docs counts — they should sum to 2000 and be roughly even.
Step 3 — SPLIT 4 → 8 (a valid factor)
Split requires the source to be read-only first (no concurrent writes during the
segment copy), and the target shard count must be a multiple of the source that also
divides number_of_routing_shards. 4 → 8 qualifies (8 is 4×2, divides 1024).
# 1. Make the source read-only.
curl -s -XPUT 'localhost:9200/src-idx/_settings' -H 'Content-Type: application/json' \
-d '{"settings":{"index.blocks.write": true}}' | jq .
# 2. Split into a new index with 8 shards.
curl -s -XPOST 'localhost:9200/src-idx/_split/split-8' -H 'Content-Type: application/json' -d '{
"settings": { "index.number_of_shards": 8, "index.number_of_replicas": 0 }
}' | jq .
# 3. Wait for green, then compare.
curl -s 'localhost:9200/_cluster/health/split-8?wait_for_status=green&timeout=60s' | jq '.status'
curl -s 'localhost:9200/_cat/shards/split-8?v&h=index,shard,prirep,docs,store'
Observe:
split-8has 8 primary shards;src-idxstill has 4.- Total doc count is identical (
_countboth indexes — splitting moves no documents in or out, it subdivides). - Each old shard's documents now live in a contiguous pair of new shards (per the partition map). Verify a specific id moved as predicted:
curl -s 'localhost:9200/src-idx/_count' | jq '.count' # 2000
curl -s 'localhost:9200/split-8/_count' | jq '.count' # 2000
# Predict id "1"'s shard at N=4 vs N=8 with the Lab SR1 oracle, then confirm:
java RouteOracle 4 1024 1 # shard at N=4
java RouteOracle 8 1024 1 # shard at N=8 (same partition, new shard)
curl -s 'localhost:9200/split-8/_search_shards?routing=1' | jq -r '.shards[0][0].shard'
The split-8 shard for id 1 must equal java RouteOracle 8 1024 1's shard.
Note: You cannot split
4 → 6: 6 is not a multiple of 4. Try it and read the rejection — it comes from the validation you grepped in Step 1 (validateSplitIndexinMetadataCreateIndexService).
Step 4 — SHRINK 4 → 2, and prove the hard links
Shrink reduces shards by a factor (the new count must divide the old). It requires
the source read-only and all source primaries on a single node (so hard links can
be made locally). On a single-node cluster the co-location is automatic; on multi-node
you'd set index.routing.allocation.require._name to one node and wait. We use a
fresh source so its segments are pristine.
# Fresh 4-shard source with data.
curl -s -XPUT 'localhost:9200/shrink-src' -H 'Content-Type: application/json' -d '{
"settings": {"index.number_of_shards":4,"index.number_of_routing_shards":1024,"index.number_of_replicas":0}
}' | jq .
for i in $(seq 1 2000); do
printf '{"index":{"_id":"%d"}}\n{"n":%d}\n' "$i" "$i"
done | curl -s -H 'Content-Type: application/x-ndjson' \
'localhost:9200/shrink-src/_bulk?refresh=true' --data-binary @- | jq '.errors'
# Prerequisites: read-only + co-locate (single node => already co-located).
curl -s -XPUT 'localhost:9200/shrink-src/_settings' -H 'Content-Type: application/json' -d '{
"settings": {
"index.blocks.write": true,
"index.routing.allocation.require._name": null
}
}' | jq .
# Shrink 4 -> 2.
curl -s -XPOST 'localhost:9200/shrink-src/_shrink/shrunk-2' -H 'Content-Type: application/json' -d '{
"settings": {"index.number_of_shards":2,"index.number_of_replicas":0}
}' | jq .
curl -s 'localhost:9200/_cluster/health/shrunk-2?wait_for_status=green&timeout=60s' | jq '.status'
curl -s 'localhost:9200/_cat/shards/shrunk-2?v&h=index,shard,prirep,docs,store'
Now the on-disk proof. Find both indexes' UUIDs and their shard index/ directories,
then compare inodes of the segment files. Hard-linked files share an inode number.
DATA=$(curl -s 'localhost:9200/_nodes/_local/settings?filter_path=**.path.data' \
| jq -r '..|.data? // empty' | head -1)
# Fallback for ./gradlew run:
[ -z "$DATA" ] && DATA=$(find . -type d -path '*testclusters*data' | head -1)
SRC_UUID=$(curl -s 'localhost:9200/shrink-src/_settings' | jq -r '.["shrink-src"].settings.index.uuid')
DST_UUID=$(curl -s 'localhost:9200/shrunk-2/_settings' | jq -r '.["shrunk-2"].settings.index.uuid')
echo "data=$DATA src=$SRC_UUID dst=$DST_UUID"
# List segment files with inode numbers for the source and the shrunk target.
echo "=== source shards (.cfs/.si/.fdt etc) ==="
find "$DATA" -path "*$SRC_UUID*/index/*" \( -name '*.cfs' -o -name '*.fdt' -o -name '*.cfe' -o -name '*.si' \) -printf '%i %p\n' | sort
echo "=== shrunk target shards ==="
find "$DATA" -path "*$DST_UUID*/index/*" \( -name '*.cfs' -o -name '*.fdt' -o -name '*.cfe' -o -name '*.si' \) -printf '%i %p\n' | sort
macOS note: BSD
findhas no-printf. Use:find "$DATA" -path "*$DST_UUID*/index/*" -name '*.cfs' -exec ls -i {} \;and compare the leading inode numbers, or install GNU findutils (gfind).
The shrunk target's segment files should report the same inode numbers as the corresponding source files — that is the hard link. Confirm two paths share an inode directly:
# Pick one source .cfs and find a target file with the same inode.
SRC_FILE=$(find "$DATA" -path "*$SRC_UUID*/index/*" -name '*.cfs' | head -1)
SRC_INO=$(ls -i "$SRC_FILE" | awk '{print $1}')
echo "source file inode: $SRC_INO ($SRC_FILE)"
find "$DATA" -path "*$DST_UUID*/index/*" -name '*.cfs' -exec ls -i {} \; | grep -w "$SRC_INO" \
&& echo "HARD LINK CONFIRMED: target shares inode $SRC_INO with source"
A matching inode line proves the shrink did not copy the bytes — the two indexes' files are the same blocks on disk, linked. (Deleting the source index decrements the link count but does not free the blocks until the target releases them too.)
Step 5 — Compare segments before/after
# Segment-level view: shrink concatenates source segments into the target's shards.
curl -s 'localhost:9200/_cat/segments/shrink-src?v&h=shard,segment,docs.count,size'
echo "---"
curl -s 'localhost:9200/_cat/segments/shrunk-2?v&h=shard,segment,docs.count,size'
You should see the shrunk index's shards holding the same segments (same names, same sizes) as the source — because they are literally the same files, just linked into a 2-shard layout. A later merge in the target may rewrite them into new segments (breaking the link for the merged ones); until then, the link is intact.
Step 6 — Clone (the 1:1 baseline)
curl -s -XPUT 'localhost:9200/clone-src/_settings' -H 'Content-Type: application/json' \
-d '{"settings":{"index.blocks.write":true}}' 2>/dev/null
# (create clone-src like shrink-src first if you want a clean one)
curl -s -XPOST 'localhost:9200/shrink-src/_clone/clone-1' -H 'Content-Type: application/json' \
-d '{"settings":{"index.number_of_replicas":0}}' | jq .
curl -s 'localhost:9200/_cat/shards/clone-1?v&h=index,shard,docs'
clone-1 has the same shard count as shrink-src and (like shrink) shares
inodes — it's the cheapest resize, hard-linking everything with no shard-count change.
Deliverables
-
_cat/shardsoutput forsrc-idx(4) andsplit-8(8) with equal total docs. -
A confirmed prediction: id
1'ssplit-8shard equalsRouteOracle 8 1024 1. -
_cat/shardsforshrunk-2(2 shards) and the inode match output proving the hard link ("HARD LINK CONFIRMED"). -
A one-line explanation of why
4 → 6split and4 → 3shrink are both rejected.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Split/shrink rejected: "index must be read-only" | Forgot index.blocks.write: true | Set it, retry; clear with false afterward |
| Shrink rejected: "must be on a single node" | Source primaries spread across nodes | Set index.routing.allocation.require._name to one node, wait green, retry |
| Split rejected: "must be a multiple" | Target N not factor × source N | Use a valid factor (4→8, 4→16, …) |
| No inode match after shrink | A merge already rewrote the segments, or you're on a copy-fallback filesystem | Shrink a fresh index and check immediately; ensure source and target share one filesystem |
find -printf errors on macOS | BSD find | Use -exec ls -i {} \; or GNU gfind |
| Target stuck initializing | Allocation/recovery throttled or blocked | _cluster/allocation/explain for the target shard (see shard allocation) |
Expected output
split-8: 8 primaries, total docs unchanged, id-1shard matches the oracle.shrunk-2: 2 primaries, total docs unchanged, segment files share inodes withshrink-src(the hard-link proof).- Invalid factors are rejected by
MetadataCreateIndexServicevalidation with a clear message.
Stretch goals
- Split all the way up. Split
1024-routing index4 → 8 → 16 → 32, each from the previous (each must be read-only). Watch the partition map subdivide; confirm id1's shard each time withRouteOracle. - Break the link with a merge. After shrink,
POST /shrunk-2/_forcemerge?max_num_segments=1and re-run the inode check: the merged segments are new files (new inodes) — the hard link is broken once Lucene rewrites them. Explain why that's expected. - Measure the cost. Time a
_splitvs a_shrinkvs a_cloneon the same data (time curl ...). Explain the ordering (clone ≈ shrink ≪ split) in terms of copy-vs-link-vs-copy+prune.
Coding Exercises
You drove resize by hand and saw the inodes; now write tests that assert the
mechanism so a regression in the resize path is caught. Locate every class with
rg/find first — never paste a line number from this page.
-
(warm-up) An inode-match assertion script. Promote the Step 4 manual check into
assert_hardlink.shthat resolves$DATA,$SRC_UUID,$DST_UUID, finds a source.cfs, andexit 0only if a target file shares its inode (elseexit 1). Make it portable: detect BSD vs GNUfindand usels -iaccordingly. This is the self-contained "HARD LINK CONFIRMED" gate, now scriptable for CI. -
(core) A split doc-conservation + routing test. Write an
OpenSearchIntegTestCasethat creates a 4-shard source withR=1024, indexes 2,000 known docs, makes it read-only,_splits to 8, and asserts: (a)_countis identical before/after, and (b) a sampled id's post-split shard equals whatRouteOracle 8 1024 <id>predicts. Find the existing resize tests to crib the harness:rg -ln "_split|ResizeType.SPLIT|prepareResizeIndex|class.*Split.*IT" server/src/test rg -n "ResizeType|SHRINK|SPLIT|CLONE" \ server/src/main/java/org/opensearch/cluster/metadata/MetadataCreateIndexService.java -
(core) Assert the invalid-factor rejections. Write a test that attempts
4 → 6split and4 → 3shrink and asserts each fails with the validation message from the service you grepped. Find the exact validators:rg -n "validateSplitIndex|validateShrinkIndex|must be a (multiple|factor)|IllegalArgumentException" \ server/src/main/java/org/opensearch/cluster/metadata/MetadataCreateIndexService.javaexpectThrows(IllegalArgumentException.class, ...)and assert the message text — so a refactor that loosens the factor rule can't slip through unnoticed. -
(advanced) A hard-link proof inside an integration test. Beyond the shell check, write an
OpenSearchIntegTestCasethat shrinks4 → 2, then viainternalCluster().getInstance(...)or the node'spath.datareads two segment files' inode numbers (Files.readAttributes(..., "unix:ino")) and asserts they match — the on-disk hard link, proven in Java. Find the recovery/hard-link code so your assertion targets the right files:rg -n "Files.createLink|hardLinkOrCopy|recoverFromLocalShards|addIndices" \ server/src/main/java/org/opensearch/index/store/ \ server/src/main/java/org/opensearch/indices/recovery/Skip the assert on filesystems that don't support hard links (
assumeTrue). -
(Advanced challenge) A full resize-matrix integration test. Write one
OpenSearchIntegTestCasethat parameterizes over{clone (4→4), shrink (4→2), split (4→8)}and for each asserts: doc count conserved, target reaches green, routing predictions hold for sampled ids (viaRouteOracle/IndexRouting), and the resize type's expected storage behavior (clone/shrink share inodes; split does not). Then add a forcemerge step on the shrunk target and assert the previously linked segments are now new inodes — encoding Stretch Goal 2's "merge breaks the link." Find the forcemerge and segments plumbing:rg -n "ForceMergeRequest|max_num_segments|prepareForceMerge" server/src/main rg -ln "extends OpenSearchIntegTestCase" server/src/test/java/org/opensearch/action/admin/indices/This is the resize-correctness harness a contributor touching the in-place split design would extend — it proves all three operations conserve data and route correctly.
Issues to Practice On
Resize sits where routing, allocation, recovery, and the store meet — the busiest contribution surface in this masterclass thanks to the in-place split effort.
| What to look for | gh command (labels move; confirm on the tracker) |
|---|---|
| Routing / shard-mapping (split rehash) | gh issue list --repo opensearch-project/OpenSearch --label "ShardManagement:Routing" --state open |
| Shard placement / allocation | gh issue list --repo opensearch-project/OpenSearch --label "ShardManagement:Placement" --state open |
| Storage / segment files | gh issue list --repo opensearch-project/OpenSearch --label "Storage" --state open |
| Cluster-manager (create/resize service) | gh issue list --repo opensearch-project/OpenSearch --label "Cluster Manager" --state open |
| Newcomer-friendly | gh issue list --repo opensearch-project/OpenSearch --label "good first issue" --state open |
List labels first (gh label list --repo opensearch-project/OpenSearch). The live
design effort is the in-place split RFC trio
(#12918,
#13923,
#13925) — read them
and their linked issues to find scoped, real work.
Representative patterns. (a) "Split/shrink fails or mis-routes under setting X" —
reproduce on ./gradlew run, locate the validator/recovery path with rg in
MetadataCreateIndexService and the shrink action, fix, and add an integ test asserting
doc count + routing after resize. (b) "Resize leaves shards stuck initializing" — an
allocation/recovery interaction: reproduce, read _cluster/allocation/explain, locate
recoverFromLocalShards, and add a test (cross-link
shard allocation).
Planted-bug drill. Find the split factor validation:
rg -n "validateSplitIndex|multiple|% .*numShards|factor" \
server/src/main/java/org/opensearch/cluster/metadata/MetadataCreateIndexService.java
Weaken the check (e.g. drop the "target must be a multiple of source" clause, or flip a
%==0 to !=0) and run the resize tests
(rg -l "class.*Resize.*Tests|class.*Split.*IT|class.*Shrink.*IT" server/src/test).
Watch a 4→6-style case slip through and a downstream assert blow up. Revert, then add
a test asserting 4→6 is rejected with the precise message — the guard the bug removed.
Etiquette: claim the issue before working it, reproduce first; every PR needs a test +
a CHANGELOG.md entry + DCO Signed-off-by (git commit -s). See
community-interaction.
Validation: self-check
- Why must the source index be read-only for both split and shrink? What would a concurrent write corrupt?
- Why does shrink additionally require all source primaries on one node, but split does not?
- You ran
ls -iand the shrunk file's inode equals the source file's inode. State precisely what that proves about the bytes on disk. - After a
_forcemergeon the shrunk index, the inodes no longer match. Why is that not a bug? - Explain why
_splitkeeps the total document count identical even though every document's shard may change — connect it to the partition invariant from Lab SR1. - Which validation method rejects
4 → 6, and what rule is it enforcing?