Level 3: OpenSearch Architecture
This level builds the mental model you will use for the rest of the curriculum: how a running OpenSearch process is organized, how a request travels from an HTTP socket to a shard, and which thread does the work at each hop. By the end you will be able to open the codebase, point at the class that handles any given request, and explain why that class exists.
Levels 1 and 2 got you building, testing, and submitting a trivial PR. From here on, every lab assumes you can find a class without a search engine and run a local cluster. Level 3 is where you stop reading about OpenSearch and start reading OpenSearch.
Learning Objectives
By the end of Level 3 you must be able to:
- Explain the node / cluster / index / shard model and how it maps to running processes and Lucene.
- Enumerate the node roles (
cluster_manager,data,ingest,coordinating,search, …) and say what each one is allowed to do. - Trace
GET /_cluster/healthandPOST /<index>/_docfrom the HTTP layer throughRestController→RestHandler→NodeClient→TransportActionwithout a guide. - Distinguish the REST layer (port 9200, HTTP/JSON) from the transport layer (port 9300, binary, node-to-node) and say which classes own each.
- Explain how actions are registered in
ActionModuleand how anActionTyperesolves to a concreteTransportAction. - Name the major thread pools (
ThreadPool.Names) and predict which one runs a given operation. - Build a minimal plugin that registers a new REST endpoint and transport action (Lab 3.3).
The Node / Cluster / Index / Shard Model
Start with the four nouns. Everything in OpenSearch is built out of them.
| Concept | What it is | Backing class | Lives where |
|---|---|---|---|
| Cluster | A named set of nodes that share one cluster state | (no single class; ClusterService is its hub) | The whole system |
| Node | One running OpenSearch JVM process | Node (server/.../node/Node.java) | One machine/container |
| Index | A logical collection of documents with a mapping | IndexMetadata + IndexService | Metadata in cluster state; data on data nodes |
| Shard | A horizontal slice of an index; one Lucene index | IndexShard wrapping an Engine/InternalEngine | A data node's disk |
An index is split into primary shards at creation time (immutable count, modulo
_split/_shrink); each primary has zero or more replicas. A shard — primary or replica — is a
self-contained Apache Lucene index made of immutable segments. IndexShard wraps a Lucene
IndexWriter/DirectoryReader through an Engine (the default is InternalEngine). Indexing,
refresh, flush, and merge all happen at the shard level. The cluster's job is to decide which node
holds which shard — that is the routing table, and computing it is the subject of
Level 4.
Cluster "logs-prod"
├── Node A (cluster_manager, data)
│ ├── shard [orders][0] primary ← a Lucene index (segments on disk)
│ └── shard [orders][1] replica
├── Node B (data, ingest)
│ ├── shard [orders][0] replica
│ └── shard [orders][1] primary
└── Node C (coordinating only) ← holds no data; routes requests
Note: "Index" is overloaded. An OpenSearch index is a collection of shards; a Lucene index is one shard. When reading the engine code, the word almost always means the Lucene one.
For the full storage story — IndexShard, InternalEngine, translog, segments, refresh/flush —
see Level 6 and the indexing-path deep dive.
The Request Path: REST → RestController → Action → Shards
This is the spine of the system. Memorize it. Every user-facing operation — search, index, cluster health, snapshot — follows the same skeleton.
flowchart TD
HTTP[HTTP request on :9200] --> HC[HttpServerTransport<br/>Netty4HttpServerTransport]
HC --> RC[RestController.dispatchRequest]
RC -->|matches path+method| RH[RestHandler<br/>e.g. RestClusterHealthAction / RestIndexAction]
RH -->|parse JSON/params| REQ[builds an ActionRequest]
REQ --> NC[NodeClient.execute ActionType, request, listener]
NC -->|looks up in actions map| TA[TransportAction<br/>HandledTransportAction]
TA --> AF[ActionFilters chain]
AF --> DO[doExecute on the right node]
DO -->|fan out over transport :9300| SH[per-shard work<br/>IndexShard / SearchService]
SH -->|results| LIS[ActionListener.onResponse]
LIS --> XC[ToXContent -> JSON]
XC --> HTTP
Read it as five questions:
- Who accepts the socket?
HttpServerTransport(defaultNetty4HttpServerTransportfrommodules/transport-netty4). It turns bytes into aRestRequest. - Who routes it?
RestController.dispatchRequestmatches(method, path)against the registered handlers and calls the matchingRestHandler. - Who parses and validates it? A
RestHandler— almost always a subclass ofBaseRestHandler. It reads path params, query params, and the JSON body, builds a typedActionRequest, and returns aRestChannelConsumerthat invokes theNodeClient. - Who dispatches to logic?
NodeClient.execute(ActionType, request, listener)looks up theTransportActionregistered for thatActionTypeand runs it locally. - Who does the work? A
TransportAction(typicallyHandledTransportAction). For cluster-wide reads it may route to the cluster manager; for writes it routes to the primary shard and replicates; for searches it fans out to many shards and reduces.
The crucial decoupling: a RestHandler never calls business logic directly. It only knows how to
turn HTTP into an ActionRequest and hand it to the client. The same TransportAction can be
invoked from REST, from the Java client, or from another action — REST is just one front door.
For the layered detail, read the REST layer deep dive, the transport layer deep dive, and the action framework deep dive.
Node Roles
A node advertises a set of roles via the node.roles setting in opensearch.yml (or
-Enode.roles=... on the command line). Roles decide what work a node is allowed to do; the
cluster manager decides what it actually does.
| Role | Setting token | Responsibility |
|---|---|---|
| Cluster manager (formerly master) | cluster_manager | Eligible to be elected to own and publish cluster state |
| Data | data | Holds shards; runs indexing, search, recovery, merges |
| Ingest | ingest | Runs ingest pipelines (processors) before indexing |
| Coordinating only | (empty node.roles: []) | Routes/reduces requests; holds no data, never elected |
| Search | search | Serves searchable-snapshot / remote-backed read traffic (newer role) |
| Remote cluster client | remote_cluster_client | Connects to remote clusters for cross-cluster search/replication |
| ML | ml | Runs ml-commons model inference (when the plugin is installed) |
| Warm | warm | Tiered storage warm nodes (newer, tiering feature) |
Terminology — cluster manager vs master: OpenSearch renamed the "master" role and APIs to cluster manager for inclusive language. The old
masterrole and settings likecluster.initial_master_nodesand?master_timeoutstill work as deprecated aliases ofcluster_manager/cluster.initial_cluster_manager_nodes/?cluster_manager_timeout. New code usescluster_manager. You will see both spellings throughout the codebase; first reference in this curriculum always notes both.
Every node is also implicitly a coordinating node: any node can accept a client request, fan it
out to the nodes that hold the relevant shards, and reduce the results. A node with node.roles: []
is only a coordinator — useful as a load-balancing tier.
Find the role definitions yourself:
grep -rn "class DiscoveryNodeRole" server/src/main/java/org/opensearch/cluster/node/
grep -rn "CLUSTER_MANAGER_ROLE\|DATA_ROLE\|INGEST_ROLE\|roleMap" \
server/src/main/java/org/opensearch/cluster/node/DiscoveryNodeRole.java
REST vs Transport: Two Ports, Two Protocols
| REST layer | Transport layer | |
|---|---|---|
| Default port | 9200 | 9300 |
| Audience | External clients (curl, Dashboards, SDKs) | Node-to-node, and the Java NodeClient |
| Protocol | HTTP/1.1 (+ HTTP/2 path), JSON body | Custom binary framing |
| Payloads | RestRequest → ActionRequest; JSON via XContent | TransportRequest/TransportResponse, Writeable |
| Entry class | HttpServerTransport → RestController | TransportService → Transport (Netty4Transport) |
| Serialization | XContent (ToXContent / XContentParser) | StreamInput/StreamOutput, NamedWriteableRegistry |
A single user request usually uses both: HTTP on 9200 to reach the coordinating node, then binary transport on 9300 to reach the data nodes that hold the shards. A search hitting 10 shards on 3 nodes is one HTTP request and many transport round-trips.
The transport payload contract is Writeable: a type implements writeTo(StreamOutput) and a
StreamInput-taking constructor. Polymorphic types (e.g., which QueryBuilder is on the wire)
resolve through NamedWriteableRegistry. Getting this wire format right is the heart of backward
compatibility — see Level 9 and the
transport layer deep dive.
Thread Pools
OpenSearch never blocks a transport or HTTP I/O thread on real work. Work is dispatched onto named
thread pools owned by ThreadPool. Picking the wrong pool (or blocking on GENERIC) is a
classic contributor bug.
ThreadPool.Names | Type | Used for |
|---|---|---|
SEARCH | fixed (sized to CPUs) | Query and fetch phases |
SEARCH_THROTTLED | fixed (small) | Searches on throttled/searchable-snapshot indices |
WRITE | fixed | Indexing, bulk, delete (the write path) |
GET | fixed | Realtime _doc GETs |
MANAGEMENT | scaling | Cluster/management housekeeping tasks |
GENERIC | scaling (unbounded-ish) | Misc. background work; never block here forever |
SNAPSHOT | scaling | Snapshot/restore I/O |
REFRESH | scaling | Periodic shard refreshes |
FLUSH | scaling | Lucene commits / flushes |
CLUSTER_MANAGER_SERVICE (a.k.a. MASTER_SERVICE) | fixed (1) | Computing cluster state updates, single-threaded |
Inspect them on a live node:
curl -s 'localhost:9200/_cat/thread_pool?v&h=node_name,name,active,queue,rejected'
Find the constants and defaults in source:
grep -n "public static final String" server/src/main/java/org/opensearch/threadpool/ThreadPool.java | head -40
grep -n "ThreadPool.Names" server/src/main/java/org/opensearch/action/search/TransportSearchAction.java | head
The single-threaded CLUSTER_MANAGER_SERVICE pool is why cluster state updates are serialized — a
detail you will revisit in Level 4.
Source Code Areas to Inspect
Read these before and after the labs. You are not modifying anything yet.
REST layer (server/src/main/java/org/opensearch/rest/)
| File | Why |
|---|---|
RestController.java | The router. Read registerHandler(...) and dispatchRequest(...). |
BaseRestHandler.java | Base for all handlers. Read prepareRequest(...) and handleRequest(...). |
RestHandler.java | The interface. routes() declares the (method, path) it owns. |
action/cat/RestNodesAction.java | A readable example of a _cat handler. |
action/admin/cluster/RestClusterHealthAction.java | Worked example in Lab 3.1. |
action/document/RestIndexAction.java | The write-path REST entry; worked example in Lab 3.1. |
Action framework (server/src/main/java/org/opensearch/action/)
| File | Why |
|---|---|
ActionModule.java | Registers every action and REST handler. The wiring catalog. |
support/TransportAction.java | Base class. Read execute(...) and the ActionFilters loop. |
support/HandledTransportAction.java | The common base for "do it on this node" actions. |
ActionType.java | The typed key that maps a request to its transport action. |
support/ActionFilters.java / ActionFilter.java | The pre/post interception chain. |
admin/cluster/health/TransportClusterHealthAction.java | Worked example in Lab 3.1. |
bulk/TransportBulkAction.java + bulk/TransportShardBulkAction.java | The write path; worked in Lab 3.1. |
Transport layer (server/ + modules/transport-netty4/)
| File | Why |
|---|---|
server/.../transport/TransportService.java | Registers request handlers; sendRequest(...). |
server/.../transport/TransportRequest.java / TransportResponse.java | Wire message bases. |
libs/core/.../io/stream/Writeable.java | The serialization contract. |
libs/core/.../io/stream/StreamInput.java / StreamOutput.java | Read/write primitives. |
server/.../common/io/stream/NamedWriteableRegistry.java | Polymorphic type resolution. |
modules/transport-netty4/.../transport/Netty4Transport.java | The default transport impl. |
Node bootstrap & thread pools (server/)
| File | Why |
|---|---|
node/Node.java | The big constructor that wires every service. Skim new Node(...). |
threadpool/ThreadPool.java | Thread pool names, types, and default sizing. |
client/node/NodeClient.java | execute(ActionType, ...) — the bridge from REST to action. |
Key Classes Quick Reference
| Class | Package | Role |
|---|---|---|
RestController | org.opensearch.rest | Routes HTTP (method, path) to a RestHandler |
BaseRestHandler | org.opensearch.rest | Base class; parses request, returns a channel consumer |
NodeClient | org.opensearch.client.node | execute(ActionType, req, listener) → transport action |
ActionModule | org.opensearch.action | Registers all actions + REST handlers (the wiring) |
ActionType<Response> | org.opensearch.action | Typed key mapping a request to its transport action |
TransportAction | org.opensearch.action.support | Base server-side action; runs the ActionFilters chain |
HandledTransportAction | org.opensearch.action.support | "Run on this node" action base |
TransportService | org.opensearch.transport | Sends/receives TransportRequest/Response between nodes |
ThreadPool | org.opensearch.threadpool | Named executor pools (SEARCH, WRITE, …) |
Node | org.opensearch.node | A running node; constructs and wires all services |
The Labs
| Lab | Title | Type |
|---|---|---|
| 3.1 | Trace a REST Request from HTTP to TransportAction | Code-reading trace |
| 3.2 | Node Roles, Discovery, and the Transport Layer | Hands-on + reading |
| 3.3 | Build It — A Custom REST Action Plugin | Build-it project |
Deliverables
You must demonstrate all of the following before advancing to Level 4:
-
A reading-log artifact tracing
GET /_cluster/healthfromRestControllertoTransportClusterHealthAction(Lab 3.1). -
A reading-log artifact tracing
POST /<index>/_docfromRestIndexActiontoTransportShardBulkAction(Lab 3.1). -
A 3-node local cluster you started, with
_cat/nodesshowing distinct roles (Lab 3.2). - A one-paragraph explanation of REST (9200) vs transport (9300), naming the entry class for each.
- A working plugin that adds a new REST endpoint and transport action, installed into a local distro and curled successfully (Lab 3.3).
- From memory: name the thread pool that runs a search, a bulk index, and a cluster state update.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Thinking a RestHandler contains the business logic | You read the wrong class for a bug | The handler only builds an ActionRequest; logic is in the TransportAction |
| Confusing OpenSearch index with Lucene index | Misread the engine code | Index = many shards; one shard = one Lucene index |
| Assuming "master" is gone | You miss deprecated aliases still in the code | master is a deprecated alias of cluster_manager; both appear |
| Looking for the action registration in the handler | You can't find how an ActionType resolves | Registration is centralized in ActionModule |
| Blocking on a transport/HTTP I/O thread | Cluster stalls under load | Dispatch real work onto a ThreadPool executor |
| Curling the transport port (9300) | Connection garbage / errors | REST is 9200; 9300 is binary node-to-node only |
Reading ActionModule top to bottom | Overwhelm — thousands of registrations | grep for the one action you care about |
How to Verify Success
# 1. Cluster is up and you can read its roles
curl -s 'localhost:9200/_cat/nodes?v&h=name,node.role,cluster_manager'
# 2. You can find any hop in the request path by grep, not by memory of line numbers
grep -rn "registerHandler" server/src/main/java/org/opensearch/rest/RestController.java | head
grep -rn "register(ClusterHealthAction" server/src/main/java/org/opensearch/action/ActionModule.java
# 3. Thread pools are visible
curl -s 'localhost:9200/_cat/thread_pool/search,write?v'
When you can open the codebase cold and point at the handler, the action, and the thread pool for any request, you are ready for Level 4: Cluster Coordination and State.