The Action Framework
The action framework is the dispatch backbone of OpenSearch. Between the REST
edge (rest-layer.md) and the transport wire
(transport-layer.md) sits a uniform abstraction: every
operation — index, get, search, create-index, cluster-health, your custom plugin
action — is an ActionType with a request and a response, served by a
TransportAction, registered in ActionModule, and invoked through the
NodeClient/Client. This uniformity is what lets the same machinery route a
request to the elected cluster manager, fan it out to all shards, or replicate it
to a primary-then-replica without each feature reinventing routing. This chapter
explains the action contracts, the family of TransportAction base classes
(which encode the common routing patterns), registration, filters, and the
ActionListener async model. It then contrasts the write path with the read path.
After this chapter you should be able to: pick the right TransportAction base
class for a new action; register an ActionType end-to-end; explain
ActionListener and ActionFilters; and trace a write through
TransportReplicationAction (primary→replica) vs a read through
TransportSingleShardAction.
Note:
TransportClusterManagerNodeActionis the renamedTransportMasterNodeAction. Both names appear in the tree; they route an action to the elected cluster manager.
The four contracts
find server -name "ActionType.java" -path "*core*" -o -name "ActionType.java"
find server libs -name "ActionRequest.java" -o -name "ActionResponse.java" -o -name "ActionListener.java"
grep -n "class ActionType\|String name()\|Writeable.Reader" \
server/src/main/java/org/opensearch/action/ActionType.java 2>/dev/null
| Contract | Role |
|---|---|
ActionType<Response> | The typed identity of an action. Carries the action name (e.g. "indices:data/read/search") and the response reader. The key both clients and the registry use. |
ActionRequest | Base for request payloads. Implements Writeable, and validate() for input checks. |
ActionResponse | Base for response payloads. Implements Writeable. |
ActionListener<Response> | The async callback: onResponse(Response) / onFailure(Exception). Everything is async. |
ActionListener deserves emphasis: OpenSearch is callback-driven, not
blocking-call-driven. A request returns immediately and the result arrives later
on onResponse/onFailure. There are rich combinators:
grep -n "static .*wrap\|static .*map\|delegateFailure\|runAfter\|runBefore" \
server/src/main/java/org/opensearch/core/action/ActionListener.java 2>/dev/null \
|| grep -rn "wrap\|map\|delegateFailure\|runAfter" libs/core/src/main/java/org/opensearch/core/action/ActionListener.java
| Combinator | Use |
|---|---|
ActionListener.wrap(onResp, onFail) | Build a listener from two lambdas. |
map(fn) | Transform the response before passing it on. |
delegateFailure(...) | Reuse the same failure path while customizing success. |
runAfter(...) / runBefore(...) | Run cleanup regardless of outcome. |
Warning: Every code path must call the listener exactly once — never zero (a hung request) and never twice (a double-response error). This is the most common bug in new actions. Audit every branch, including exception handlers, to guarantee exactly-once completion.
The TransportAction family
The base classes encode the routing pattern so individual actions don't:
find server -name "TransportAction.java" -o -name "HandledTransportAction.java" \
-o -name "TransportSingleShardAction.java" -o -name "TransportBroadcastAction.java" \
-o -name "TransportReplicationAction.java" -o -name "TransportClusterManagerNodeAction.java" \
-o -name "TransportMasterNodeAction.java"
| Base class | Routing pattern | Example actions |
|---|---|---|
HandledTransportAction | Runs locally on the receiving (usually coordinating) node; no special routing. | many admin / simple actions |
TransportClusterManagerNodeAction (was TransportMasterNodeAction) | Route to the elected cluster manager; it owns cluster-state changes. | create/delete index, put mapping, update settings |
TransportSingleShardAction | Route to one shard copy (primary or any replica) that holds the doc. | get, explain |
TransportBroadcastAction (and TransportBroadcastByNodeAction) | Fan out to all relevant shards, gather, reduce. | refresh, _stats, validate query |
TransportReplicationAction | Write path: route to the primary, then replicate to in-sync replicas. | index, delete, bulk-shard |
flowchart TD
Req[ActionRequest] --> Pick{routing pattern}
Pick -->|cluster-state change| CM[TransportClusterManagerNodeAction -> elected cluster manager]
Pick -->|single doc read| SS[TransportSingleShardAction -> one shard copy]
Pick -->|all shards| BC[TransportBroadcastAction -> fan out + reduce]
Pick -->|write| REP[TransportReplicationAction -> primary then replicas]
Pick -->|local/simple| HT[HandledTransportAction -> here]
Choosing the base class correctly is most of the design work for a new action.
Picking HandledTransportAction for something that mutates cluster state, for
instance, would skip the required routing to the cluster manager and corrupt
state on a non-manager node.
Registration: ActionModule
Actions are wired in ActionModule, which builds the map from ActionType →
TransportAction and registers REST handlers. Plugins extend it via
ActionPlugin.getActions().
grep -n "registerAction\|ActionHandler\|setupActions\|getActions" \
server/src/main/java/org/opensearch/action/ActionModule.java
grep -n "getActions\|class ActionHandler" \
server/src/main/java/org/opensearch/plugins/ActionPlugin.java
// Core: pairs the ActionType with its TransportAction implementation.
actions.register(SearchAction.INSTANCE, TransportSearchAction.class);
// Plugin:
@Override
public List<ActionHandler<?, ?>> getActions() {
return List.of(new ActionHandler<>(MyAction.INSTANCE, TransportMyAction.class));
}
flowchart LR
AT["ActionType (name)"] --> AM[ActionModule registry]
TA[TransportAction impl] --> AM
AM -->|"NodeClient.execute(AT, req)"| Lookup[lookup by AT]
Lookup --> Run[run the TransportAction]
ActionFilters: the interception chain
Before a TransportAction runs, the request passes through an ordered chain of
ActionFilters. This is the cross-cutting hook used for security
authorization/authentication (the out-of-repo security plugin), request
auditing, and rate limiting.
find server -name "ActionFilters.java" -o -name "ActionFilter.java"
grep -n "interface ActionFilter\|apply\|order()\|class ActionFilterChain" \
server/src/main/java/org/opensearch/action/support/ActionFilter.java
flowchart LR
C[client.execute] --> F1["ActionFilter 1 (e.g. security)"]
F1 --> F2[ActionFilter 2]
F2 --> TA[TransportAction.doExecute]
TA --> Resp[response back up the chain]
Filters are ordered by order() and can short-circuit (deny) or modify the
request/response. This is exactly where the security plugin enforces access
control without core knowing anything about it.
The client: NodeClient and Client
find server -name "NodeClient.java" -o -name "Client.java"
grep -n "public .* execute\|doExecute\|executeLocally" \
server/src/main/java/org/opensearch/client/node/NodeClient.java
| Type | Role |
|---|---|
Client | The interface callers use. Has convenience methods (index(...), search(...), get(...)) and the generic execute(ActionType, request, listener). |
NodeClient | The in-node implementation. execute looks up the TransportAction for the ActionType and runs it locally (which may itself send transport requests to other nodes). |
REST handlers receive a NodeClient and call client.execute(ActionType, req, listener). That is the single seam between the REST layer and the action layer.
The write path: TransportReplicationAction
A write (index/delete/bulk-shard) is the canonical replication action. The flow:
grep -n "shardOperationOnPrimary\|shardOperationOnReplica\|ReplicationOperation\|PrimaryShardReference\|class TransportReplicationAction" \
server/src/main/java/org/opensearch/action/support/replication/TransportReplicationAction.java
sequenceDiagram
participant Co as Coordinating node
participant P as Primary shard node
participant R1 as Replica node 1
participant R2 as Replica node 2
Co->>P: route to PRIMARY (per routing table)
P->>P: shardOperationOnPrimary -> IndexShard.applyIndexOperationOnPrimary
Note over P: writes to Lucene + translog
par replicate to in-sync copies
P->>R1: replica request
R1->>R1: shardOperationOnReplica -> applyIndexOperationOnReplica
P->>R2: replica request
R2->>R2: shardOperationOnReplica
end
R1-->>P: ok
R2-->>P: ok
P-->>Co: success when enough copies ack
Key facts (detailed in replication.md and index-shard-lifecycle.md):
- The request is routed to the primary first; the primary is the serialization point for the document.
- The primary applies the op (
IndexShard.applyIndexOperationOnPrimary→InternalEngine), then fans out to the in-sync replica copies. - Sequencing uses primary terms and sequence numbers (
ReplicationTracker). wait_for_active_shardscontrols how many copies must be available before the op proceeds.
The read path: TransportSingleShardAction
A get reads one document from one copy:
grep -n "shardOperation\|resolveRequest\|shards(\|class TransportSingleShardAction" \
server/src/main/java/org/opensearch/action/support/single/shard/TransportSingleShardAction.java
sequenceDiagram
participant Co as Coordinating node
participant S as A shard copy (primary or replica)
Co->>Co: resolve which shard id holds the doc (routing)
Co->>S: send to one copy from the shard's iterator
S->>S: shardOperation -> IndexShard.get
alt copy fails
Co->>S2: try the next copy in the iterator
end
S-->>Co: GetResponse
The contrast is instructive:
| Aspect | Write (TransportReplicationAction) | Read (TransportSingleShardAction) |
|---|---|---|
| Target | primary, then all in-sync replicas | one copy (primary or any replica) |
| Goal | durability + replica consistency | low-latency single fetch |
| Failure | replica failure → mark out-of-sync; retry | copy failure → fall through to the next copy |
| Ordering | primary term + seq no | none needed |
A multi-shard read (search) uses neither of these directly; it has its own scatter/gather machinery — see search-execution.md.
End-to-end: client.execute to shard
flowchart TD
REST[REST handler] -->|"client.execute(ActionType, req, listener)"| NC[NodeClient]
NC --> AF[ActionFilters chain]
AF --> TA[TransportAction.doExecute]
TA -->|routing pattern| Shard["shard-level operation (primary/replica/single/broadcast)"]
Shard --> Eng["IndexShard / Engine / SearchService"]
Eng --> L["ActionListener.onResponse"]
L --> REST
Reading exercise
# 1. The base classes side by side.
for c in HandledTransportAction TransportSingleShardAction TransportBroadcastAction \
TransportReplicationAction TransportClusterManagerNodeAction; do
echo "== $c =="; find server -name "$c.java"
done
# 2. Registration.
grep -n "register\|getActions\|ActionHandler" \
server/src/main/java/org/opensearch/action/ActionModule.java | head
# 3. ActionListener combinators.
grep -n "wrap\|map\|delegateFailure\|runAfter" \
libs/core/src/main/java/org/opensearch/core/action/ActionListener.java
# 4. The write path.
grep -n "shardOperationOnPrimary\|shardOperationOnReplica" \
server/src/main/java/org/opensearch/action/bulk/TransportShardBulkAction.java
# 5. Tests.
./gradlew :server:test --tests "org.opensearch.action.support.replication.TransportReplicationActionTests"
Answer:
- Map each of these to a
TransportActionbase class and justify:PUT /idx/_mapping,GET /idx/_doc/1,POST /idx/_refresh,POST /idx/_doc. - State the exactly-once rule for
ActionListenerand describe the symptom of violating it in each direction (zero calls, two calls). - How does
ActionModuleconnect anActionTypeto aTransportAction, and how does a plugin add its own pair? - Where do
ActionFilters run in the pipeline, and what real out-of-repo plugin uses them for authorization? - In
TransportReplicationAction, identify the primary and replica operation methods. What ordering primitives keep replicas consistent? - Contrast the failure handling of a replica failing a write vs a copy failing a single-shard read.
Common bugs and symptoms
| Symptom | Root cause | Where to look |
|---|---|---|
| Request hangs forever, no response | ActionListener never called on some branch | audit every path/exception handler for exactly-once completion |
Response already sent / double completion | Listener called twice | same audit; especially in retry/fallback code |
| Cluster-state change applied on a non-manager node, corruption | Used HandledTransportAction for a metadata change instead of TransportClusterManagerNodeAction | base-class choice; route to cluster manager |
| Write succeeds on primary but replicas diverge | Replica op not idempotent / seq-no handling wrong | shardOperationOnReplica; replication.md |
Action not found: No handler for action [...] | ActionType not registered in ActionModule/getActions | registration; name mismatch |
| Security plugin not enforcing on a new action | Action bypasses the filter chain (custom dispatch) | run through NodeClient.execute; ActionFilters |
Validation: prove you understand this
- List the five
TransportActionbase classes and the routing pattern each encodes. For each, name one real OpenSearch action. - Explain the
ActionListenerasync contract and the exactly-once rule, with the failure symptom for each violation. - Write the
getActions()snippet a plugin uses to register a customActionType/TransportActionpair. - Draw the write path through
TransportReplicationActionfrom coordinating node to primary to replicas, and name the engine method the primary calls. - Draw the read path through
TransportSingleShardActionand explain the fall-through-to-next-copy behavior on failure. - Explain where
ActionFilterssit and why the security plugin relies on every action flowing throughNodeClient.execute.