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: TransportClusterManagerNodeAction is the renamed TransportMasterNodeAction. 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
ContractRole
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.
ActionRequestBase for request payloads. Implements Writeable, and validate() for input checks.
ActionResponseBase 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
CombinatorUse
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 classRouting patternExample actions
HandledTransportActionRuns 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
TransportSingleShardActionRoute 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
TransportReplicationActionWrite 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 ActionTypeTransportAction 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
TypeRole
ClientThe interface callers use. Has convenience methods (index(...), search(...), get(...)) and the generic execute(ActionType, request, listener).
NodeClientThe 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.applyIndexOperationOnPrimaryInternalEngine), then fans out to the in-sync replica copies.
  • Sequencing uses primary terms and sequence numbers (ReplicationTracker).
  • wait_for_active_shards controls 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:

AspectWrite (TransportReplicationAction)Read (TransportSingleShardAction)
Targetprimary, then all in-sync replicasone copy (primary or any replica)
Goaldurability + replica consistencylow-latency single fetch
Failurereplica failure → mark out-of-sync; retrycopy failure → fall through to the next copy
Orderingprimary term + seq nonone 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:

  1. Map each of these to a TransportAction base class and justify: PUT /idx/_mapping, GET /idx/_doc/1, POST /idx/_refresh, POST /idx/_doc.
  2. State the exactly-once rule for ActionListener and describe the symptom of violating it in each direction (zero calls, two calls).
  3. How does ActionModule connect an ActionType to a TransportAction, and how does a plugin add its own pair?
  4. Where do ActionFilters run in the pipeline, and what real out-of-repo plugin uses them for authorization?
  5. In TransportReplicationAction, identify the primary and replica operation methods. What ordering primitives keep replicas consistent?
  6. Contrast the failure handling of a replica failing a write vs a copy failing a single-shard read.

Common bugs and symptoms

SymptomRoot causeWhere to look
Request hangs forever, no responseActionListener never called on some branchaudit every path/exception handler for exactly-once completion
Response already sent / double completionListener called twicesame audit; especially in retry/fallback code
Cluster-state change applied on a non-manager node, corruptionUsed HandledTransportAction for a metadata change instead of TransportClusterManagerNodeActionbase-class choice; route to cluster manager
Write succeeds on primary but replicas divergeReplica op not idempotent / seq-no handling wrongshardOperationOnReplica; replication.md
Action not found: No handler for action [...]ActionType not registered in ActionModule/getActionsregistration; name mismatch
Security plugin not enforcing on a new actionAction bypasses the filter chain (custom dispatch)run through NodeClient.execute; ActionFilters

Validation: prove you understand this

  1. List the five TransportAction base classes and the routing pattern each encodes. For each, name one real OpenSearch action.
  2. Explain the ActionListener async contract and the exactly-once rule, with the failure symptom for each violation.
  3. Write the getActions() snippet a plugin uses to register a custom ActionType/TransportAction pair.
  4. Draw the write path through TransportReplicationAction from coordinating node to primary to replicas, and name the engine method the primary calls.
  5. Draw the read path through TransportSingleShardAction and explain the fall-through-to-next-copy behavior on failure.
  6. Explain where ActionFilters sit and why the security plugin relies on every action flowing through NodeClient.execute.