The Transport Layer
Every interaction between nodes in an OpenSearch cluster — a search fan-out, a
replication request, a join, a cluster-state publish — travels over the
transport layer. It is OpenSearch's internal RPC fabric: a binary protocol
on port 9300 (distinct from the REST/HTTP port 9200), built on Netty, with a
typed request/response model and a custom serialization framework
(StreamInput/StreamOutput, Writeable). This chapter explains
TransportService and the Transport SPI, how request handlers are registered
and invoked, the serialization primitives that make payloads cross the wire, and
the version-aware hooks that keep a mixed-version cluster working.
After this chapter you should be able to: register a transport request handler;
explain what Writeable, StreamInput/StreamOutput, and
NamedWriteableRegistry each do; trace a TransportRequest from sender to
handler to TransportResponse; and find where the wire format is version-gated
for backward compatibility (the hook into serialization-bwc.md).
Note: Port 9200 is HTTP/REST (clients talk to it). Port 9300 is the transport protocol (nodes talk to each other). They are different stacks with different handlers. This chapter is about 9300; rest-layer.md is about 9200.
The classes
find server/src/main/java/org/opensearch/transport -name "TransportService.java"
find . -path "*transport-netty4*" -name "Netty4Transport.java"
grep -n "class TransportService\|registerRequestHandler\|sendRequest\|interface Transport" \
server/src/main/java/org/opensearch/transport/TransportService.java
| Class | Package / module | Role |
|---|---|---|
TransportService | org.opensearch.transport | The high-level API every component uses: register handlers, open connections, send requests. Wraps a Transport. |
Transport | org.opensearch.transport | The SPI for the wire implementation (connect, bind, send bytes). |
Netty4Transport | modules/transport-netty4 | The default Transport implementation, on Netty. Bound to 9300. |
TransportRequest | org.opensearch.transport | Base class for anything sent. Implements Writeable. |
TransportResponse | org.opensearch.transport | Base class for anything returned. Implements Writeable. |
TransportChannel | org.opensearch.transport | A handler's reply handle: sendResponse(resp) or sendResponse(exc). |
TransportRequestHandler<T> | org.opensearch.transport | The functional interface a handler implements: messageReceived(request, channel, task). |
TransportResponseHandler<T> | org.opensearch.transport | The callback on the sender side: handleResponse / handleException, plus the executor and the response reader. |
flowchart LR
Comp[some component] -->|sendRequest| TS1[TransportService A]
TS1 -->|Transport SPI| N4A[Netty4Transport A]
N4A -->|"TCP 9300, framed bytes"| N4B[Netty4Transport B]
N4B --> TS2[TransportService B]
TS2 -->|dispatch by action name| H[registered handler]
H -->|channel.sendResponse| TS2
TS2 --> N4B
N4B --> N4A
N4A --> TS1
TS1 -->|handleResponse| Comp
Registering and invoking a handler
Every transport interaction is keyed by an action name string (e.g.
"indices:data/read/search[phase/query]"). A node that can serve an action
registers a handler:
transportService.registerRequestHandler(
ACTION_NAME, // String key
ThreadPool.Names.SEARCH, // which thread pool runs the handler
MyRequest::new, // Writeable.Reader<MyRequest> (deserializer)
(request, channel, task) -> {
MyResponse resp = handle(request);
channel.sendResponse(resp); // or channel.sendResponse(exception)
});
grep -n "registerRequestHandler" server/src/main/java/org/opensearch/transport/TransportService.java
# Real examples across the codebase:
grep -rn "registerRequestHandler" server/src/main/java | head -20
To call a remote (or local) action:
transportService.sendRequest(targetNode, ACTION_NAME, request,
new ActionListenerResponseHandler<>(listener, MyResponse::new, ThreadPool.Names.SAME));
| Sender ingredient | Why |
|---|---|
targetNode (DiscoveryNode) | Where to send; resolved from cluster state. |
ACTION_NAME | Selects the remote handler. |
request (TransportRequest) | The Writeable payload. |
TransportResponseHandler | Reads the response and routes it (often ActionListenerResponseHandler, which adapts to an ActionListener). |
ActionListenerResponseHandler is the bridge between the transport world and the
action world (action-framework.md): it deserializes the
response and feeds it to an ActionListener.onResponse/onFailure.
Note: The thread-pool name in
registerRequestHandlerdecides which pool runs the handler — pick it deliberately. A search handler runs onSEARCH, a write onWRITE, a lightweight metadata reply onSAME(the calling thread). Choosing the wrong pool causes rejections or starves other work; see threadpools-concurrency.md.
Serialization: Writeable, StreamInput, StreamOutput
Everything on the wire is serialized by hand through a binary stream. There is
no reflection-based JSON here — it is explicit, ordered, byte-for-byte. The
primitives live in libs/core:
find libs -name "Writeable.java" -o -name "StreamInput.java" -o -name "StreamOutput.java"
grep -n "interface Writeable\|void writeTo\|interface Reader" \
libs/core/src/main/java/org/opensearch/core/common/io/stream/Writeable.java
| Primitive | Contract |
|---|---|
Writeable | void writeTo(StreamOutput out) — serialize self in a fixed field order. |
Writeable.Reader<T> | T read(StreamInput in) — usually a constructor MyType(StreamInput in) that reads fields in the same order writeTo wrote them. |
StreamOutput | Typed writers: writeString, writeVInt, writeOptionalString, writeList, writeEnum, writeBoolean, ... |
StreamInput | Matching readers: readString, readVInt, readOptionalString, readList, readEnum, ... |
The cardinal rule: read order must match write order, exactly. A
TransportRequest typically looks like:
public MyRequest(StreamInput in) throws IOException {
super(in);
this.indexName = in.readString();
this.size = in.readVInt();
this.filter = in.readOptionalWriteable(Filter::new);
}
@Override
public void writeTo(StreamOutput out) throws IOException {
super.writeTo(out);
out.writeString(indexName);
out.writeVInt(size);
out.writeOptionalWriteable(filter);
}
Warning: A mismatch between
writeToand the reader is the single most common serialization bug. It does not fail at compile time; it manifests as a corrupt deserialization (EOFException, wrong values, orIllegalStateException) at runtime, often only against a different node version. The round-trip test base classes (AbstractWireSerializingTestCase) exist precisely to catch this — every newWriteableowes one. See serialization-bwc.md.
NamedWriteableRegistry: polymorphism on the wire
StreamInput can read a concrete type if it knows the reader. But many fields
are polymorphic — a QueryBuilder could be any of dozens of subclasses, an
Aggregation could be any aggregation. For those, OpenSearch writes a name
and looks up the reader in a NamedWriteableRegistry.
find . -name "NamedWriteableRegistry.java" -o -name "NamedWriteable.java"
grep -n "writeNamedWriteable\|readNamedWriteable\|getNamedWriteableName\|class Entry" \
libs/core/src/main/java/org/opensearch/core/common/io/stream/NamedWriteableRegistry.java
| Mechanism | Used for |
|---|---|
Writeable + a known Reader | Concrete, non-polymorphic types (a specific request). |
NamedWriteable + NamedWriteableRegistry | Polymorphic types selected by a registered name (queries, aggs, suggesters, cluster-state customs). |
The registry is assembled at node start from core plus every plugin's
getNamedWriteables() (see plugin-architecture.md).
The XContent analog (NamedXContentRegistry) does the same for JSON parsing on
the REST side — see rest-layer.md.
flowchart LR
Q["QueryBuilder field"] -->|writeNamedWriteable| W["name 'bool' + bytes"]
W -->|wire| R[reader node]
R -->|"readNamedWriteable(QueryBuilder.class)"| Reg[NamedWriteableRegistry]
Reg -->|"lookup 'bool'"| BB["BoolQueryBuilder(StreamInput)"]
BB --> Obj[reconstructed QueryBuilder]
Connections, ports, and request options
TransportService opens multiple logical channels per node connection, grouped
by purpose so that, say, a flood of bulk traffic cannot starve cluster-state
pings.
grep -n "ConnectionProfile\|TransportRequestOptions\|Type\.\(BULK\|PING\|REG\|STATE\|RECOVERY\)" \
server/src/main/java/org/opensearch/transport/TransportRequestOptions.java \
server/src/main/java/org/opensearch/transport/ConnectionProfile.java 2>/dev/null
| Channel type | Carries |
|---|---|
PING | Fault-detection heartbeats (LeaderChecker/FollowersChecker). |
STATE | Cluster-state publish/commit. |
RECOVERY | Peer-recovery byte transfers. |
BULK | Indexing/bulk traffic. |
REG | Everything else (the default). |
TransportRequestOptions lets a caller set the channel type and a per-request
timeout. The port (default 9300, transport.port) and bind addresses come from
NetworkModule/transport settings.
The version hook (BWC)
OpenSearch runs mixed-version clusters during rolling upgrades. The
serialization layer must therefore be version-aware: a newer node writing to an
older node must omit fields the older node cannot parse, and vice versa. Every
StreamInput/StreamOutput carries a Version, and serialization code branches
on it:
@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeString(name);
if (out.getVersion().onOrAfter(Version.V_3_0_0)) {
out.writeOptionalString(newFieldAddedIn3x); // older nodes never see this
}
}
grep -rn "out.getVersion()\|in.getVersion()\|onOrAfter\|before(" \
server/src/main/java/org/opensearch/action/ | head
This is the seam where transport meets backward compatibility. The full rules,
the Version constants, and the qa/ BWC test suite are in
serialization-bwc.md. For the purposes of this chapter:
any time you add or change a field on a TransportRequest/TransportResponse,
you must version-gate it and add a BWC round-trip test.
Where the transport layer sits in a request
flowchart TD
REST["RestSearchAction (9200)"] --> NC[NodeClient.execute]
NC --> TA[TransportSearchAction]
TA -->|"per-shard sub-requests"| TS[TransportService.sendRequest]
TS -->|9300| Remote[data node]
Remote --> H["search query-phase handler"]
H --> SS[SearchService.executeQueryPhase]
SS --> Resp["channel.sendResponse(QuerySearchResult)"]
The transport layer is the connective tissue: the action framework (action-framework.md) decides what to send and where; the transport layer moves the bytes; the thread pools (threadpools-concurrency.md) decide which thread runs each handler.
Reading exercise
# 1. The send/receive API surface.
grep -n "public .*sendRequest\|registerRequestHandler\|openConnection" \
server/src/main/java/org/opensearch/transport/TransportService.java
# 2. A real handler registration (search query phase).
grep -rn "registerRequestHandler" server/src/main/java/org/opensearch/search/ | head
# 3. The serialization primitives.
grep -n "public .* read\|public void write" \
libs/core/src/main/java/org/opensearch/core/common/io/stream/StreamInput.java | head -30
# 4. NamedWriteable in action for queries.
grep -rn "writeNamedWriteable\|readNamedWriteable" server/src/main/java/org/opensearch/index/query/ | head
# 5. Tests.
./gradlew :server:test --tests "org.opensearch.transport.TransportServiceTests"
./gradlew :server:test --tests "*AbstractWireSerializingTestCase*" 2>/dev/null || true
Answer:
- What three things must you supply to
registerRequestHandler, and what does the thread-pool-name argument control? - State the cardinal rule relating
writeToto theStreamInputconstructor. What runtime error appears when it is violated? - When does a field use
NamedWriteableRegistryinstead of a plainWriteablereader? Give a concrete OpenSearch type that needs it and why. - What is
ActionListenerResponseHandlerfor, and how does it connect the transport layer to theActionListenerworld? - Why does the transport layer use multiple channel types (PING, STATE, BULK, …)? What failure does the separation prevent?
- Find one
if (out.getVersion().onOrAfter(...))in the codebase. Explain what breaks in a rolling upgrade if that guard is removed.
Common bugs and symptoms
| Symptom | Root cause | Where to look |
|---|---|---|
EOFException / garbage values deserializing a request | writeTo and the StreamInput reader disagree on field order/count | the type's writeTo vs its (StreamInput) constructor; add an AbstractWireSerializingTestCase |
| Works same-version, breaks during rolling upgrade | New field not version-gated with getVersion().onOrAfter(...) | serialization-bwc.md; add a BWC test |
IllegalArgumentException: Unknown NamedWriteable [...] | A polymorphic type's name not registered (plugin missing on this node) | getNamedWriteables(); NamedWriteableRegistry |
| Handler runs on the wrong/overloaded pool; rejections | Wrong ThreadPool.Names in registerRequestHandler | the registration call; threadpools-concurrency.md |
| Cluster-state pings delayed under bulk load | Traffic not separated onto distinct channel types | ConnectionProfile; TransportRequestOptions |
NodeNotConnectedException | No open connection to target (node left, or never connected) | TransportService.connectToNode; cluster state freshness |
Validation: prove you understand this
- Distinguish port 9200 from 9300 and name the top-level class that owns each stack.
- Write, from memory, the skeleton of a
TransportRequestwith one string and one optional sub-object, including bothwriteToand the reader constructor in the correct order. - Explain
NamedWriteableRegistryversus a plainWriteable.Reader, and name two OpenSearch types that require the registry. - Trace a single
sendRequestfrom caller throughNetty4Transportto a remote handler and back to the caller'shandleResponse. - Show one version-gated write and explain the exact rolling-upgrade failure that the guard prevents.
- Given a registered handler that runs on
ThreadPool.Names.GENERICbut does heavy CPU search work, explain the operational symptom and the fix.