Lab 9.1: Write Backward-Compatibility (BWC) Tests
Lab type: Build It + Test It Estimated time: 4–6 hours
Background
OpenSearch clusters upgrade one node at a time. For the duration of a rolling
upgrade — which on a large cluster can last hours — nodes of two different versions
serve traffic in the same cluster and exchange transport messages. A 3.1 node must
be able to send a request to a 3.0 node and read its response, and vice versa. The
machinery that makes this possible is version-gated serialization: every object
that crosses the wire implements Writeable, carries the peer's Version on its
StreamInput/StreamOutput, and writes new fields only when the peer is new enough
to understand them.
This lab makes that abstract. You will add a new field to a transport request/response
pair, gate it correctly with a Version check, and then write the tests that prove
an old node can still talk to a new node. You will run both the cheap round-trip test
(AbstractWireSerializingTestCase) and the expensive but authoritative qa/ BWC
suite (rolling-upgrade, mixed-cluster, full-cluster-restart).
Read the Serialization and Backward Compatibility deep dive
first. This lab assumes you know why reordering stream writes corrupts a stream,
what out.getVersion() returns during a rolling upgrade, and the three kinds of
compatibility. This lab is the hands-on counterpart to that chapter.
Why This Lab Matters for Contributors
A missing version gate is the single most common reason a maintainer rejects an otherwise-good PR — and the most dangerous bug class to miss, because it produces no failing unit test. It surfaces only in a real mixed-version cluster, typically in production, during a customer's upgrade. Learning to write BWC tests is learning to make this invisible class of bug visible before merge. Every maintainer in the project carries this reflex; by the end of this lab, so do you.
Prerequisites
-
You can build OpenSearch from source and run
./gradlew :server:test. - You have read the serialization BWC deep dive and the transport layer deep dive.
-
You understand
Writeable: awriteTo(StreamOutput)method plus aStreamInputconstructor, read in the same order they were written. -
You know the current version lines:
3.x/mainis current,2.xis the maintenance line,1.xis legacy.
Step-by-Step Tasks
Step 1: Orient yourself in the version and BWC machinery
Find the Version constants — these are the levers for every wire gate — and the
qa/ suites that prove BWC end to end.
# The named version constants and the ordering helpers you will gate on.
grep -n "public static final Version V_3_\|public static final Version CURRENT\|minimumCompatibilityVersion\|minimumIndexCompatibilityVersion" \
server/src/main/java/org/opensearch/Version.java | head -40
# The methods you will use in gates.
grep -n "public boolean onOrAfter\|public boolean before\|public boolean onOrBefore\|public boolean after" \
server/src/main/java/org/opensearch/Version.java
# The BWC QA suites.
ls qa/
# expect: rolling-upgrade mixed-cluster full-cluster-restart smoke-test-* verify-version-constants ...
Note: The newest unreleased version constant on
mainis what you usually gate a brand-new field on. Pick it by readingVersion.javaon your branch — do not guessV_3_1_0from memory. IfCURRENTon your branch is3.2.0, a field you add now ships in3.2.0and gates onVersion.V_3_2_0. "Exact names vary by branch."
Step 2: Find a real, simple transport request/response to extend
You want a small Writeable request/response pair so the serialization is easy to
read. Good candidates are simple stats or info responses. Find one and read both its
writeTo and its StreamInput constructor.
# Find transport requests/responses with explicit version gates already in them —
# these are your worked examples of the correct pattern.
grep -rln "out.getVersion().onOrAfter\|in.getVersion().onOrAfter" \
server/src/main/java/org/opensearch/action/ | head
# A concrete, readable example to study end to end:
grep -n "writeTo\|StreamInput\|getVersion().onOrAfter" \
server/src/main/java/org/opensearch/action/admin/cluster/health/ClusterHealthRequest.java | head -40
For this lab we will use a worked, self-contained example so the pattern is crisp.
Imagine a transport response NodeFeatureStatsResponse (your real target will be an
existing class) that today serializes two fields:
public class NodeFeatureStatsResponse extends TransportResponse {
private final long indexedDocs;
private final String engineType;
public NodeFeatureStatsResponse(long indexedDocs, String engineType) {
this.indexedDocs = indexedDocs;
this.engineType = engineType;
}
public NodeFeatureStatsResponse(StreamInput in) throws IOException { // READ
super(in);
this.indexedDocs = in.readVLong();
this.engineType = in.readString();
}
@Override
public void writeTo(StreamOutput out) throws IOException { // WRITE
out.writeVLong(indexedDocs);
out.writeString(engineType);
}
}
Step 3: Add the new field — gated, appended, symmetric
We add an optional mergedSegmentCount (a long) that ships in the next release.
Determine that version from Version.java (Step 1); here we assume V_3_2_0.
Three rules, all non-negotiable:
- Append, never insert in the middle. The new write goes after every existing write; the new read goes after every existing read.
- Gate symmetrically. The
if (out.getVersion().onOrAfter(...))on write and theif (in.getVersion().onOrAfter(...))on read must be the identical condition. - Give the old peer a sensible default on read, since an old node never sent the field.
public class NodeFeatureStatsResponse extends TransportResponse {
private final long indexedDocs;
private final String engineType;
+ private final long mergedSegmentCount; // new in 3.2.0
- public NodeFeatureStatsResponse(long indexedDocs, String engineType) {
+ public NodeFeatureStatsResponse(long indexedDocs, String engineType, long mergedSegmentCount) {
this.indexedDocs = indexedDocs;
this.engineType = engineType;
+ this.mergedSegmentCount = mergedSegmentCount;
}
public NodeFeatureStatsResponse(StreamInput in) throws IOException { // READ
super(in);
this.indexedDocs = in.readVLong();
this.engineType = in.readString();
+ if (in.getVersion().onOrAfter(Version.V_3_2_0)) {
+ this.mergedSegmentCount = in.readVLong();
+ } else {
+ this.mergedSegmentCount = 0L; // old peer didn't send it
+ }
}
@Override
public void writeTo(StreamOutput out) throws IOException { // WRITE
out.writeVLong(indexedDocs);
out.writeString(engineType);
+ if (out.getVersion().onOrAfter(Version.V_3_2_0)) {
+ out.writeVLong(mergedSegmentCount); // only send to 3.2+ peers
+ }
}
}
Warning: The two
ifconditions are the entire safety argument. If the read gate saysV_3_2_0and the write gate saysV_3_1_0, then a 3.1 node will read amergedSegmentCountthat a 3.2 node never wrote (or worse, the reverse), and the stream desynchronizes — every field after it is garbage. They must be character-for- character the same condition.
When the field is a reference type, prefer the writeOptional*/readOptional*
helpers so null round-trips cleanly:
// write side, gated:
out.writeOptionalString(maybeNullName);
// read side, gated:
this.maybeNullName = in.readOptionalString();
Step 4: Write the round-trip serialization test
This is the cheapest, highest-value BWC guard. AbstractWireSerializingTestCase<T>
serializes your instance, deserializes it, asserts equality — and crucially also does
this at random older Versions, which catches a missing or asymmetric gate
immediately.
package org.opensearch.action.admin.cluster.stats;
import org.opensearch.Version;
import org.opensearch.core.common.io.stream.Writeable;
import org.opensearch.test.AbstractWireSerializingTestCase;
public class NodeFeatureStatsResponseTests
extends AbstractWireSerializingTestCase<NodeFeatureStatsResponse> {
@Override
protected Writeable.Reader<NodeFeatureStatsResponse> instanceReader() {
return NodeFeatureStatsResponse::new; // the StreamInput ctor
}
@Override
protected NodeFeatureStatsResponse createTestInstance() {
return new NodeFeatureStatsResponse(
randomNonNegativeLong(),
randomFrom("internal", "nrt_replication", "read_only"),
randomNonNegativeLong() // mergedSegmentCount
);
}
@Override
protected NodeFeatureStatsResponse mutateInstance(NodeFeatureStatsResponse in) {
// Optional but good practice: ensure equals/hashCode discriminate the new field.
return new NodeFeatureStatsResponse(
in.getIndexedDocs(),
in.getEngineType(),
in.getMergedSegmentCount() + 1
);
}
}
For the cross-version assertion to mean anything, your type must implement equals
and hashCode over all fields, including the new one — otherwise the round-trip
"passes" while silently dropping the field. The base class compares the deserialized
instance to the original with equals.
Run it:
./gradlew :server:test --tests "org.opensearch.action.admin.cluster.stats.NodeFeatureStatsResponseTests"
If you forgot or mis-gated the field, this test fails at a randomly chosen old
Version with a mismatch — often before you ever touch the slow qa/ suite. That is
the point: make the gate fail in two seconds, not in a customer's upgrade.
Note:
AbstractWireSerializingTestCaseuses the randomized testing framework. A failure prints a-Dtests.seed=...line; re-run with that exact seed to reproduce the specific oldVersionthat exposed the bug.
Step 5: Understand and run the qa/ BWC suites
The round-trip test proves your type round-trips at an older version in a single
JVM. The qa/ suites prove that two real, separately-built clusters of different
versions actually interoperate over a real network — the authoritative proof.
| Suite | What it proves | Gradle project |
|---|---|---|
qa:rolling-upgrade | A cluster upgraded node-by-node (old → mixed → new) keeps working at every step | :qa:rolling-upgrade |
qa:mixed-cluster | A cluster running two versions simultaneously serves traffic correctly | :qa:mixed-cluster |
qa:full-cluster-restart | A cluster shut down on the old version and restarted on the new one (covers on-disk/index format) | :qa:full-cluster-restart |
These are wired to run against specific old versions via bwcVersion. The build
resolves the set of versions still in the compatibility window from the version
catalog.
# See how the BWC versions are declared and wired into the qa/ projects.
grep -rn "bwcVersion\|bwc_version\|BWC_VERSION\|wireCompatVersions\|indexCompatVersions" \
qa/ build.gradle settings.gradle gradle/ 2>/dev/null | head
# Run the rolling-upgrade suite (slow — it builds and boots two-version clusters).
./gradlew :qa:rolling-upgrade:check
# Run the mixed-cluster suite.
./gradlew :qa:mixed-cluster:check
# Run the full-cluster-restart suite (also exercises index/on-disk format BWC).
./gradlew :qa:full-cluster-restart:check
Warning: These tasks download and build the old version's artifacts and start multiple JVMs. They are slow (minutes to tens of minutes) and resource-hungry. Run the round-trip test in Step 4 on every change; run the
qa/suite before you open the PR and whenever you touch serialization of something that crosses the wire.
To scope a qa/ run while iterating, target a single BWC version or a single test:
# List the version-parameterized tasks the rolling-upgrade project generates.
./gradlew :qa:rolling-upgrade:tasks --all | grep -i bwc
# Run just one test class inside the suite.
./gradlew :qa:rolling-upgrade:check --tests "*Recovery*"
Step 6: Wire the new field into a real qa/ assertion (optional but ideal)
The strongest BWC test asserts the behavior the field enables across versions: a
new node should still get a correct response from an old node (where the field is
absent and defaults), and an old node should not choke on the new node's message
(where the field is present but the old node skips it because the write gate
suppressed it). In the rolling-upgrade suite these are typically REST-level
assertions in the OLD/MIXED/UPGRADED task phases. Read an existing one:
grep -rln "MIXED\|UPGRADED\|TEST_STEP\|isRunningAgainstOldCluster" qa/rolling-upgrade/ | head
grep -rn "assertBusy\|client().performRequest" \
qa/rolling-upgrade/src/test/java/org/opensearch/upgrades/ 2>/dev/null | head
Model your assertion on those: in the MIXED phase, hit the endpoint that returns
your field and assert the cluster behaves correctly regardless of which node answers.
Index / Lucene Format BWC (know the difference)
Everything above is wire BWC — nodes on a network. There is a second, distinct
kind: index/Lucene format BWC — data already written to disk. OpenSearch reads
indices created by the previous major version (Lucene's N-1 codec back-compat),
but not older; an index created two majors ago must be reindexed. The creation
version is recorded as index.version.created in IndexMetadata, and the engine
refuses to open an index whose format is too old.
grep -rn "index.version.created\|VERSION_CREATED\|minimumIndexCompatibilityVersion\|isCompatible" \
server/src/main/java/org/opensearch/cluster/metadata/IndexMetadata.java \
server/src/main/java/org/opensearch/Version.java | head
The qa:full-cluster-restart suite is where index-format BWC is exercised: it writes
data on the old version, restarts on the new version, and asserts the index still
opens and serves. If your change touches how a segment, a doc value, or index
metadata is written, this is the suite that catches a format break. See the
refresh/flush/merge deep dive for what
actually lands on disk.
Pitfalls
| Pitfall | What goes wrong | How to avoid |
|---|---|---|
| Reordering existing writes | Positional stream misread → silent wrong values, no exception | Append only; never touch the order of existing writes |
| Asymmetric gate (read vs write conditions differ) | One side reads a field the other never wrote → stream desync | Copy the exact same onOrAfter(...) condition to both sides |
| Forgetting the version guard entirely | Old node reads bytes it doesn't expect → deserialize failure or corruption | Always gate a new field; the round-trip test catches the omission |
Gating on the wrong Version constant | Field gated to a version that never shipped, or the wrong line | Read Version.java on your branch; gate on where it first ships |
Missing field in equals/hashCode | Round-trip test "passes" but silently drops the new field | Include every field in equals/hashCode |
Using readInt/writeInt where the other side used VInt | Type/width mismatch → misread | Read and write the exact same method pair |
Changing getWriteableName() of a NamedWriteable | Old nodes registered the old name → unknown NamedWriteable [x] | The name is a contract; never rename it |
Skipping qa/ because units are green | Wire BWC only manifests across real versions | Run :qa:rolling-upgrade:check before the PR |
Expected Output
A correct round-trip test run:
> Task :server:test
NodeFeatureStatsResponseTests > testSerialization PASSED
NodeFeatureStatsResponseTests > testEqualsAndHashcode PASSED
BUILD SUCCESSFUL
A failure from a missing/asymmetric gate looks like a mismatch at a random old version, with a reproducible seed:
NodeFeatureStatsResponseTests > testSerialization FAILED
java.lang.AssertionError: expected ... but was ...
REPRODUCE WITH: ./gradlew :server:test --tests "...NodeFeatureStatsResponseTests" \
-Dtests.seed=DEADBEEFCAFE -Dtests.method="testSerialization"
A clean qa/ run ends with BUILD SUCCESSFUL after booting and tearing down the
multi-version clusters.
Stretch Goals
- Add an enum field instead of a primitive. Enums are subtler: an old node cannot deserialize a value it doesn't know. Gate the producer so an old peer never receives the new value, and write a test that proves it.
-
Make the new field a
NamedWriteableand register it inNamedWriteableRegistry. Write a test that a node without the registration getsunknown NamedWriteable [x]— then add the registration and watch it pass. See the serialization BWC deep dive. -
Find a real merged PR on
github.com/opensearch-project/OpenSearchthat added a version-gated field. Read itswriteTo/StreamInputdiff and its test. Confirm the gates are symmetric and the test exercises old versions. -
Run
qa:full-cluster-restartand trace how it asserts that an index written on the old version still opens on the new version.
Coding Exercises
BWC is a code skill, not a reading skill — the bug it prevents leaves no failing unit test unless
you write one. Each exercise produces a serialization test that catches an asymmetric or missing
version gate. Pick a real Writeable request/response with rg and study its existing gates before
you touch anything.
-
(warm-up) A
StreamInput/StreamOutputround-trip test. For a simple, real transport response, write anAbstractWireSerializingTestCase<T>withinstanceReader,createTestInstance, andmutateInstance. Run it green, then confirmequals/hashCodecover every field (the base class compares byequals). Find a candidate and a worked-example test:grep -rln "extends AbstractWireSerializingTestCase" server/src/test/java/org/opensearch/action/ | head grep -rln "out.getVersion().onOrAfter\|in.getVersion().onOrAfter" server/src/main/java/org/opensearch/action/ | head -
(core) Add a version-gated field and prove the gate symmetric. Append one optional
long, gate write and read with the identicalonOrAfter(...)condition (pick the version by readingVersion.javaon your branch — never from memory), default it for old peers, and extendcreateTestInstance/mutateInstance. The round-trip test serializes at random older versions, so a correct gate passes and a missing one fails.grep -n "public static final Version V_3_\|public static final Version CURRENT" \ server/src/main/java/org/opensearch/Version.java | tail -10 -
(core) A serialization-BWC assertion at an explicit old version. Beyond random versions, write a test that serializes your instance with
out.setVersion(Version.V_3_0_0)(a version before your field shipped), deserializes, and asserts the new field equals its default — proving the write gate suppressed it. Then serialize atCURRENTand assert the field round-trips intact. This pins both halves of the gate explicitly. Find the version-setting API:grep -rn "setVersion\|copyWriteable\|copyInstance" server/src/test/framework/ server/src/main/java/org/opensearch/core/common/io/stream/ 2>/dev/null | head -
(core) The asymmetric-gate red test. Deliberately make the read gate
V_3_2_0and the write gateV_3_1_0(or swap one forbefore(...)), run the round-trip test, and capture the failure + its-Dtests.seed=. Then restore symmetry. You now know exactly what a desynced stream looks like in CI — the thing the round-trip test exists to catch in two seconds instead of in a customer's upgrade. -
(advanced) Advanced challenge — a
NamedWriteableenum field with a producer gate, end to end. Add an enum field to a transport message, register it (or a new value) inNamedWriteableRegistry, and gate the producer so an old peer never receives a value it cannot deserialize. Write three tests: (a) a round-trip atCURRENT; (b) a node missing the registration getsunknown NamedWriteable [x]; (c) serializing to an old version omits/falls back the new enum value rather than sending it. Then wire one REST-level assertion intoqa:rolling-upgradeso theMIXEDphase proves a new node and an old node interoperate. This is the full version-gated-field contract — the reflex every maintainer carries.grep -rn "NamedWriteableRegistry\|registerWriteable\|getNamedWriteables" \ server/src/main/java/org/opensearch/ | head grep -rln "MIXED\|isRunningAgainstOldCluster" qa/rolling-upgrade/ | head
Issues to Practice On
BWC and serialization work lives in opensearch-project/OpenSearch core; filter on backward-compat and
the affected area (labels move; confirm with gh label list --repo opensearch-project/OpenSearch).
| Goal | Command |
|---|---|
| Backward-compatibility work | gh issue list --repo opensearch-project/OpenSearch --label "backwards-compatibility" --state open |
| BWC-related bugs | gh issue list --repo opensearch-project/OpenSearch --label "bug" --state open --search "BWC OR serialization OR mixed-cluster in:title,body" |
Flaky qa/ BWC suites | gh issue list --repo opensearch-project/OpenSearch --label "flaky-test" --state open --search "rolling-upgrade OR mixed-cluster in:title,body" |
Representative patterns. (1) A "field added without a version gate / mixed-cluster deserialize
failure" bug — reproduce by serializing across versions (or via qa:mixed-cluster), locate the
ungated writeTo/StreamInput via rg "getVersion().onOrAfter", add the symmetric gate, and ship a
versioned round-trip test. (2) A "flaky rolling-upgrade test" — often a real BWC break hiding behind
intermittence; reproduce with the pinned seed, isolate the phase (OLD/MIXED/UPGRADED), and fix
the gate or the assertion. Loop: reproduce → locate via rg → fix → test → PR with CHANGELOG + DCO
sign-off.
Planted-bug drill. In a real Writeable you've added a gated field to, change only the read gate
from onOrAfter(V_3_2_0) to onOrAfter(V_3_1_0) (leave the write gate alone) — an asymmetric gate.
Run ./gradlew :server:test --tests "*<YourType>Tests" and watch the round-trip fail at a random old
version with a reproducible seed. Restore symmetry, then add an explicit setVersion(V_3_1_0)
assertion (Exercise 3) that would have caught it deterministically, not just under a lucky random seed.
This is the safest way to see a silent stream desync without shipping one.
Etiquette: Claim the issue before working it, reproduce on
main(and across versions) first, run:qa:rolling-upgrade:checkbefore opening the PR, and every PR needs a test + aCHANGELOG.mdentry + a DCOSigned-off-by(git commit -s). See community interaction, the compatibility chapter, and the level-2 PR lab.
Validation / Self-check
Answer all of these before marking the lab complete:
- Write, from memory, a correct version-gated
writeTo/StreamInputpair that adds one optionallongfield in the next release. State the single invariant that makes it safe in a mixed cluster. - With a byte-level sketch, explain why reordering two existing
writeTowrites corrupts the stream silently rather than throwing. - During a 3.0→3.2 rolling upgrade, a 3.2 node sends your response to a 3.0 node.
What does
out.getVersion()return, and exactly which field "disappears" and why? - Why must your type implement
equals/hashCodeover the new field forAbstractWireSerializingTestCaseto actually catch a missing gate? - Name the three
qa/suites and state what each one proves that the round-trip unit test cannot. - Distinguish wire BWC from index/Lucene format BWC. Which
qa/suite exercises the index-format kind, and how many major versions back can an index be read? - Your round-trip test is green but
qa:rolling-upgradeis red. List two concrete causes and where you would look first.