Skip to main content

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(ExampleStep, input),))

Example: examples/python/dex_examples/primitives/rpc

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)

Example: examples/python/dex_examples/primitives/rpc

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.

Python, Java, TypeScript, and Rust declare loads with the RPC. Go supplies them in InvokeOptions. 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.

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.

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. Cadence does not provide the same atomic guarantee for Channel deletion validation and writes. Design Cadence callers to tolerate that race.

RPC timeout

An RPC timeout limits one Worker handler invocation.

Python, Java, TypeScript, and Rust declare the timeout with the RPC handler. Go passes it in InvokeOptions for each Client invocation. When the application does not set a timeout, Dex Server uses its default. The default maximum is 60 seconds.

@rpc(timeout=timedelta(seconds=30))

Example: examples/python/dex_examples/primitives/rpc

Attribute locks

See Locking for Attribute locks.