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.
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.