Remote actions (FaaS — Lambda / HTTP functions)¶
An action’s implementation can run as a remote function — an AWS Lambda, or any HTTP-triggered
function (OpenFaaS, Fermyon Spin, Cloudflare Workers, Knative, GCF/Azure) — instead of an in-process
Python callable. The statechart engine orchestrates; the side-effecting work runs serverless. The
DSL does not change at all: a handler binds to a remote impl exactly like a local one. And because
the whole thing reduces to one invoke(payload) -> {context, result} seam, a transport harel
doesn’t ship is a few lines, not a new integration.
Why this needs no engine change¶
An action is already a pure function of serializable data: its signature is
(stm, event, **inputs), the only state it touches is stm.execution_ctx (the Execution’s
context dict, already JSON — it round-trips through every store backend), and its return value
is consumed only by a selector to route. The binding seam already accepts
callables (actions={"charge": fn}). So a remote action is just a callable bound the normal way —
nothing in core.py changes.
harel.faas (the faas extra) ships both sides of the wire:
pip install "harel[faas]" # boto3 (Lambda) + requests (OpenFaaS)
Write the action once¶
The same (stm, event, **inputs) function runs in-process or as the function body. On the
function side, handler(fn) adapts it to a FaaS entrypoint — it reconstructs the stm from the
incoming JSON, runs your action, and returns {context, result}:
from harel import handler
def charge(stm, event, **inputs):
"""An ordinary action: mutates context, returns a routing result."""
stm.execution_ctx["charged"] = inputs["amount"]
return "ok"
# the Lambda / OpenFaaS entrypoint — the same `charge` you'd bind locally
entrypoint = handler(charge)
# what the runtime hands it (client -> function JSON) and what it returns:
out = entrypoint(
{
"context": {},
"event": {"kind": "Pay", "data": {}},
"inputs": {"amount": 42},
"idempotency_key": "exe:1:0",
}
)
assert out == {"context": {"charged": 42}, "result": "ok"}
Deploy that entrypoint as the Lambda handler (or the OpenFaaS function body); bind the same
charge locally with actions={"charge": charge} for tests. One source of truth.
Bind the remote impl on the client¶
On the orchestrating side, bind a remote stub like any implementation — in actions={...} or via
in-DSL bind. Two transports ship:
lambda_action(function_name, ...)— AWS Lambda (boto3,RequestResponse).http_action(url, ...)— any HTTP-triggered function. The same transport covers OpenFaaS, Fermyon Spin, Cloudflare Workers, Knative,faasd, and Google Cloud Functions / Azure Functions with an HTTP trigger — only the URL differs, so there is no per-provider factory.
# docs-test: skip
from harel import definition_from_dsl_file, http_action, lambda_action
defn = definition_from_dsl_file(
"charge.stm",
actions={
"charge": lambda_action("charge-fn", region="us-east-1"), # AWS Lambda
# "charge": http_action("http://gateway:8080/function/charge"), # OpenFaaS
# "charge": http_action("http://spin.local/charge"), # Fermyon Spin
# "charge": http_action("https://charge.acme.workers.dev"), # Cloudflare Workers
},
)
The stub serializes {context, event, inputs, idempotency_key}, invokes the function
synchronously (Lambda RequestResponse / an HTTP POST), applies the returned context to the
Execution, and returns result (the selector’s routing key). The DSL stays transport-agnostic —
a bind { charge = ... } in the .stm is unaware whether the impl is local or remote.
Any other transport — the remote_action seam¶
Both factories are just remote_action(invoke) over a specific synchronous invoke(payload) -> {context, result}. For a transport harel doesn’t ship — gRPC, NATS, a queue, a non-HTTP runtime —
write that one function against the wire contract and bind the result.
Adding a provider is zero new framework code:
# docs-test: skip
from harel import remote_action
def invoke(payload): # your transport: payload in, {context, result} out
resp = my_grpc_stub.Run(payload)
return {"context": resp.context, "result": resp.result}
actions = {"charge": remote_action(invoke)}
The wire contract¶
client -> function: { "context": {...}, "event": {"kind", "data"}, "inputs": {...}, "idempotency_key": "..." }
function -> client: { "context": {...}, "result": <any> }
Full context crosses each way (not a patch): there is a single writer per Execution, and the context is small. Switch to a patch only if a context ever grows large.
Idempotency across the at-least-once window¶
Delivery is at least once (see Durability): if the worker crashes after the
remote invoke but before the commit, the event is redelivered and the action — local or remote —
runs again. The payload carries idempotency_key (the same stable
stm.idempotency_key = {execution_id}:{version}:{index}) so the function can dedupe its side
effect against the callee’s native idempotency (Stripe’s idempotency key, a DynamoDB conditional
put). harel records nothing extra — a harel-side record would roll back with the failed commit, so
it could not survive the crash window. This is the B approach; see Durability
for the full reasoning and the idempotent() helper for local actions.
Errors¶
A 5xx / Lambda runtime error propagates out of the stub → the driver’s action-error policy fails
the Execution terminally (status=FAILED, the dead-letter). A modelled failure is different: the
function returns {"result": "failed"} and a selector routes on it — an expected outcome, not an
exception.
Considerations¶
Sync / blocking. The remote invoke blocks the worker until the result, like a slow local action. Keep long-running work modelled as a state with a
timeout, not a 10-minute action; LambdaRequestResponsefits a bounded call.Cold start. A cold function adds latency to the blocking effect — use provisioned concurrency / keep-warm for latency-sensitive machines.
Testing¶
Three tiers, mirroring the other backends:
Unit, no Docker (
test/unit/faas/, default): a fakeinvokerunshandler(fn)in-process and returns the JSON — covers serialization, the context apply, routing, theidempotency_key, andremote_actionover an arbitrary transport.Real HTTP server, no Docker (
test/unit/faas/, default): anhttp.serverin a thread mountshandler(fn), driven throughhttp_actionover a real socket. This is the contract every HTTP-triggered FaaS shares (OpenFaaS / Spin / Cloudflare / Knative), so it covers them all deterministically.Real Lambda on LocalStack (
test/integration/test_faas_lambda.py, markedstack): invokes a zip-packaged Lambda through real boto3 — the one transport a fake can’t fully cover. Seedeploy/README.mdfor the build+run commands. Skips unless the stack is up.