RPC
RPC stands for “Remote Procedure Call”. It allows external systems to interact with a Flow execution.
A Client invokes an RPC. Dex Server sends it to a Worker. The Worker runs it, then returns the result to Dex Server to persist. Dex Server then returns the output to the Client.
Define and Invoke an RPC
RPC can read and write durable state through Attributes, publish Channels, and trigger a brand-new Step to run.
Each RPC invocation is a commit boundary. Dex Server stages the Attribute writes and Channel publications from the RPC, then commits them together with the RPC result only after the method returns successfully. If the RPC fails, none of those durable changes are committed. External API calls are outside this atomic boundary.
@rpc
def trigger(self, context: Context, input: str) -> RPCResult[str]:
self.data.set(context, input)
self.example_ch.publish(context, None)
return RPCResult(input, next_steps=(StepMovement.of(Example, input),))
Java and Kotlin
Client.newRpcStub creates a subclass of the Flow. A Java Flow class and its RPC methods must not be final. Kotlin makes both final by default, so declare the Flow class and its RPC methods with open.
TypeScript codecs
TypeScript uses JSON when inputCodec or outputCodec is omitted. Provide a codec when the input or output is not JSON, including scalar values such as strings, numbers, booleans, and bigints.
Then invoke the RPC like below example:
result = await client.invoke_rpc(flow.trigger, flow_id, message)
Read-only RPCs and terminal Flows
To read stored Attribute values from outside the Flow, an application does not need a read-only RPC. The Client's GetAttributes reads committed values directly, without a Worker. See Read Attributes. Use a read-only RPC when the read needs Flow-side logic, such as validation, authorization, Channel messages, or a derived view.
An RPC without Attribute locks and without transactional execution starts with a read of the Flow. If its handler returns only an output and no durable effects, Dex returns that output without changing the Flow. That read can reach a retained terminal execution. A successful read-only RPC therefore does not prove that the Flow is active.
Attribute locks and explicit transactional execution use an active execution. A non-transactional handler that returns Attribute writes, Channel operations, events, or Step movements also needs an active execution because Dex commits those effects to the Flow after the handler returns. A terminal Flow rejects that commit even though the Worker handler may already have run. A Server can also be configured to run every RPC transactionally, which requires an active execution.
Use a Flow lifecycle API when the response needs current execution status. An application snapshot RPC reads the durable application state captured by the Flow; it does not infer terminal status from RPC success. FlowNotActiveOrNotFoundError means the target was not found, or the selected operation cannot use its closed execution. Java uses FlowNotActiveOrNotFoundException; Rust uses SdkError::FlowNotActiveOrNotFound. The error alone does not distinguish missing from closed Flows.
For a confirmed read-only RPC with no locks, transactionality, durable effects, or Server-forced transactional execution, a retained terminal execution remains readable. At that read boundary, handle this error directly as no readable target found. Preserve the application's missing or retention-unavailable outcome. Do not add a lifecycle lookup, WaitForFlow with a short timeout, a retry, or historical Step-output decoding solely to distinguish missing from closed. Keep other service and Worker failures distinct.
For mutations and active-only RPCs, the same error may mean the Flow is closed or missing. Apply the operation's business policy; the error does not prove that the requested action succeeded.
Callers do not need to search for a Run ID before a normal read-only RPC. When the SDK exposes an optional Run ID, omit it to let the Server resolve and pin the current execution for that invocation. Supply one only when the application must address an exact execution across Flow ID reuse.
Selective RPC state loading
Every RPC receives regular Attribute values and Channel size metadata. Reading AttributeMap entries or pending Channel messages requires an explicit load. This keeps large collections out of RPC requests that do not use them.
Load a whole AttributeMap or ChannelMap when the handler needs all current instances. Load exact map instances when it needs only known keys. Load a Channel when the handler needs its pending message IDs or Values. Channel size and ChannelMap keys and sizes remain available without loading pending messages.
Every SDK declares loads with the RPC. Go places them in RPCOptions passed to DefineRPC from GetRPCs. Dex Server validates each requested definition against the Flow persistence schema and returns the loaded scope with the RPC snapshot. The SDK checks that scope when the handler reads a selectively loaded collection.
For an AttributeMap entry or instance enumeration outside that scope, the SDK raises AttributeMapNotLoadedError. For pending messages outside that scope, it raises ChannelMessagesNotLoadedError. Java uses AttributeMapNotLoadedException and ChannelMessagesNotLoadedException. For Go, dexcli visualize reports an RPC that reads unselected state. See State loads.
An explicitly loaded collection can be empty. That is not an error. An empty pending-message snapshot returns an empty list. An absent AttributeMap entry uses the SDK's normal absent-value behavior. Loading creates one input snapshot. It does not consume messages, make the RPC transactional, or isolate the handler from concurrent work.
Runtime-selected map instances
Use the RPC definition options for fixed behavior shared by every invocation: timeout, ordinary Attribute locks, transactionality, whole-map loads, singleton Channel loads, and fixed exact-instance loads. Go names this type RPCOptions.
Use RPCInvokeOptions when the application adapter chooses an AttributeMap or ChannelMap instance from request data. Rust uses RpcInvokeOptions. Invocation options can add exact AttributeMap loads, exact ChannelMap loads, and AttributeMap instance locks. Dex unions, sorts, and deduplicates them with the registered definition. They cannot replace or clear registered settings.
Loading and locking are independent. A read-only lookup needs only the exact load. A read-modify-write RPC must add both the load and the lock for the same AttributeMap instance. The SDK validates the Flow ownership, definition kind, and non-empty slash-free instance before making the network call.
options=RPCInvokeOptions(
lock_attribute_map_instances=(
flow.customer_profiles_by_email_partition.lock(partition_name),
),
load_attribute_map_instances=(
flow.customer_profiles_by_email_partition.load(partition_name),
),
)
Example: examples/python/dex_examples/patterns/hash-partitioned-attribute-map/controller.py
See Hash-Partitioned AttributeMap for the complete lookup and upsert example, and Sequentially Chunked AttributeMap for dynamic pagination loads.
Transactional reads and writes
A transactional RPC performs its reads and writes as one atomic operation.
An RPC that holds an Attribute lock is already transactional implicitly. You can also make an RPC explicitly transactional by setting is_transactional=true. This is especially useful for Channel message deletion because Dex validates that the message is still pending before committing any writes.
The caller passes only a pending message ID. The RPC loads the source Channel, reads the original Value from its snapshot, then stages the deletion and destination publication. A concurrent consumer makes deletion validation fail, so Dex commits neither effect:
@rpc(is_transactional=True, load_channels=(queued_messages,))
def move_queued_message_to_prioritized_messages(
self, context: Context, queued_message: QueuedMessageReference
) -> None:
message_to_prioritize = self.queued_messages.find_pending_message(
context, queued_message.message_id
)
self.queued_messages.delete(context, queued_message.message_id)
if message_to_prioritize is not None:
self.prioritized_messages.publish(context, message_to_prioritize.value)
Example: examples/python/dex_examples/primitives/channel/channel_flow.py
A transactional commit does not isolate the handler while it runs. If a decision depends on the whole loaded Channel, ChannelMap, or AttributeMap remaining unchanged, every cooperating Step and RPC writer must use the same Attribute lock. The lock provides that isolation and implicitly makes the RPC transactional; it does not load collection contents.
A non-transactional RPC treats a missing deletion as a no-op and still commits its other successful effects.
RPC timeout
An RPC timeout limits one Worker handler invocation.
Every SDK declares the timeout with the RPC. Go places it in the static RPCOptions passed to DefineRPC. A Client invocation cannot override it, although the caller's context can stop waiting sooner. When the application does not set a timeout, Dex Server uses its default of 60 seconds, set by api.defaultRpcTimeout. An explicit timeout has no maximum.
@rpc(timeout=timedelta(seconds=30))
Attribute locks
See Locking for Attribute locks.