# "Workflows" _Path: en/temporal/workflows_ > "Define durable Temporal workflows with workflow.lua entries, activities, signals, child workflows, timers, and replay-safe operations." ## Table of Contents - Workflows ## Content # Workflows A `workflow.lua` entry defines a durable Temporal workflow that orchestrates activities and maintains state across failures and restarts. This page is an API reference with partial recipes. Entry declarations, worker registration, activity implementations, security policies, and surrounding application data are shown only where they are relevant to a specific contract. ## Definition ```yaml - name: order_workflow kind: workflow.lua source: file://order_workflow.lua method: main modules: - funcs - time - workflow meta: temporal: workflow: worker: app:worker ``` ### Metadata Fields | Field | Required | Description | |-------|----------|-------------| | `worker` | Yes | Reference to `temporal.worker` entry | | `name` | No | Custom workflow type name (defaults to entry ID) | ## Basic Implementation ```lua local funcs = require("funcs") local time = require("time") local function main(order) local payment, err = funcs.call("app:charge_payment", { amount = order.total, customer = order.customer_id }) if err then return {status = "failed", error = tostring(err)} end time.sleep("1h") local shipment, err = funcs.call("app:ship_order", { order_id = order.id, address = order.shipping_address }) if err then local _, refund_err = funcs.call("app:refund_payment", payment.id) if refund_err then return { status = "failed", error = tostring(err), compensation_error = tostring(refund_err) } end return {status = "failed", error = tostring(err)} end return { status = "completed", payment_id = payment.id, tracking = shipment.tracking_number } end return { main = main } ``` ## Workflow Module The `workflow` module provides workflow-specific operations. ### workflow.info() Get workflow execution information: ```lua local workflow = require("workflow") local info, info_err = workflow.info() if info_err then return nil, info_err end print(info.workflow_id) -- Workflow execution ID print(info.run_id) -- Current run ID print(info.workflow_type) -- Workflow type name print(info.task_queue) -- Task queue name print(info.namespace) -- Temporal namespace print(info.attempt) -- Current attempt number print(info.history_length) -- Number of history events print(info.history_size) -- History size in bytes ``` ### workflow.exec() Execute a child workflow synchronously and wait for its result: ```lua local result, err = workflow.exec("app:child_workflow", input_data) if err then return nil, err end ``` Use this form when the parent must wait for the child result inline. ### workflow.version() Handle code changes with deterministic versioning: ```lua local version, err = workflow.version("payment-v2", 1, 2) if err then return nil, err end if version == 1 then return funcs.call("app:old_payment", input) else return funcs.call("app:new_payment", input) end ``` Parameters: - `change_id` - Unique identifier for this change - `min_supported` - Minimum supported version - `max_supported` - Maximum (current) version The version number is deterministic per workflow execution. Existing in-flight workflows continue using their recorded version, while new workflows use `max_supported`. ### workflow.attrs() Update search attributes and memo: ```lua local updated, err = workflow.attrs({ search = { status = "processing", customer_id = order.customer_id, order_total = order.total }, memo = { notes = "Priority customer", source = "web" } }) if err then return nil, err end ``` Search attributes are indexed and queryable via Temporal visibility APIs. Memo is arbitrary non-indexed data attached to the workflow. ### workflow.history_length() / workflow.history_size() Monitor workflow history growth: ```lua local length, length_err = workflow.history_length() if length_err then return nil, length_err end local size, size_err = workflow.history_size() if size_err then return nil, size_err end if length > 10000 then -- Consider continue-as-new to reset history end ``` ### Basic Spawn Start a workflow from any code using `process.spawn()`: ```lua local pid, err = process.spawn( "app:order_workflow", -- workflow entry "app:worker", -- temporal worker {order_id = "123"} -- input ) if err then return nil, err end ``` The host parameter is the temporal worker (not a process host). The workflow runs durably on Temporal infrastructure. ### Spawn with Monitoring Monitor workflows to receive EXIT events when they complete: ```lua local pid, err = process.spawn_monitored( "app:order_workflow", "app:worker", {order_id = "123"} ) if err then return nil, err end local events = process.events() local event, open = events:receive() if not open then return nil, errors.new({kind = errors.INTERNAL, message = "process event channel closed"}) end if event.kind == process.event.EXIT then local result = event.result.value local error = event.result.error end ``` ### Spawn with Name Assign a name to a workflow for idempotent starts: ```lua local spawner = process .with_options({}) :with_name("order-" .. order.id) local pid, err = spawner:spawn_monitored( "app:order_workflow", "app:worker", {order_id = order.id} ) if err then return nil, err end ``` When a name is provided, Temporal uses it to deduplicate workflow starts. Spawning with the same name while a workflow is running returns the existing workflow's PID by default. ### Spawn with Explicit Workflow ID Set a specific Temporal workflow ID: ```lua local spawner = process .with_options({ ["workflow.id"] = "order-" .. order.id, }) local pid, err = spawner:spawn_monitored( "app:order_workflow", "app:worker", order ) if err then return nil, err end ``` ### ID Conflict Policies Control behavior when spawning a workflow with an ID that already exists: ```lua -- Fail if workflow already exists local spawner = process .with_options({ ["workflow.id"] = "order-123", ["workflow.id_conflict_policy"] = "fail", }) local pid, err = spawner:spawn("app:order_workflow", "app:worker", order) if err then -- Workflow already running with this ID end ``` ```lua -- Error when already started (alternative approach) local spawner = process .with_options({ ["workflow.id"] = "order-123", ["workflow.execution_error_when_already_started"] = true, }) local pid, err = spawner:spawn("app:order_workflow", "app:worker", order) if err then return nil, err end ``` ```lua -- Reuse existing (default behavior with explicit ID) local spawner = process .with_options({ ["workflow.id"] = "order-123", }) local pid, err = spawner:spawn("app:order_workflow", "app:worker", order) if err then return nil, err end -- Returns existing workflow PID if already running ``` | Policy | Behavior | |--------|----------| | `"use_existing"` | Return existing workflow PID (default with explicit ID) | | `"fail"` | Return error if workflow exists | | `"terminate_existing"` | Terminate existing and start new | ### Workflow Start Options Pass Temporal workflow options via `with_options()`: ```lua local spawner = process.with_options({ ["workflow.id"] = "order-123", ["workflow.execution_timeout"] = "24h", ["workflow.run_timeout"] = "1h", ["workflow.task_timeout"] = "30s", ["workflow.id_conflict_policy"] = "fail", ["workflow.retry_policy"] = { initial_interval = 1000, backoff_coefficient = 2.0, maximum_interval = 300000, maximum_attempts = 3, }, ["workflow.cron_schedule"] = "0 */6 * * *", ["workflow.search_attributes"] = { customer_id = "cust-123" }, ["workflow.memo"] = { source = "api" }, ["workflow.start_delay"] = "5m", ["workflow.parent_close_policy"] = "terminate", }) ``` #### Options Reference | Option | Type | Description | |--------|------|-------------| | `temporal.workflow.id` | string | Explicit workflow execution ID | | `temporal.workflow.task_queue` | string | Override task queue | | `temporal.workflow.execution_timeout` | duration | Total workflow execution timeout | | `temporal.workflow.run_timeout` | duration | Single run timeout | | `temporal.workflow.task_timeout` | duration | Workflow task processing timeout | | `temporal.workflow.id_conflict_policy` | string | `use_existing`, `fail`, `terminate_existing` | | `temporal.workflow.id_reuse_policy` | string | `allow_duplicate`, `allow_duplicate_failed_only`, `reject_duplicate` | | `temporal.workflow.execution_error_when_already_started` | boolean | Error if workflow already running | | `temporal.workflow.retry_policy` | table | Retry policy (see below) | | `temporal.workflow.cron_schedule` | string | Cron expression for recurring workflows | | `temporal.workflow.memo` | table | Non-indexed workflow metadata | | `temporal.workflow.search_attributes` | table | Indexed queryable attributes | | `temporal.workflow.enable_eager_start` | boolean | Start execution immediately | | `temporal.workflow.start_delay` | duration | Delay before workflow starts | | `temporal.workflow.parent_close_policy` | string | Child behavior on parent close | | `temporal.workflow.wait_for_cancellation` | boolean | Wait for cancellation to finish | | `temporal.workflow.namespace` | string | Temporal namespace override | | `temporal.workflow.name` | string | Workflow type name to start, when it differs from the registry ID | | `temporal.workflow.versioning_intent` | string | `compatible` (inherit the build ID) or `default` (use assignment rules) | | `temporal.workflow.priority` | table | Task priority: `priority_key` (number), `fairness_key` (string), `fairness_weight` (number) | | `workflow.summary` | string | Human-readable summary shown in the Temporal UI | | `workflow.details` | string | Human-readable details shown in the Temporal UI | | `workflow.versioning_override` | table | Worker versioning override: `mode` is `auto_upgrade`, or `pinned` with `deployment_name` and `build_id` | Every option is also accepted under its short key (`workflow.id`, `workflow.task_queue`, ...); the `temporal.workflow.` prefix is a legacy alias. `summary` and `details` have no `temporal.workflow.` alias. Duration values accept strings (`"5s"`, `"10m"`, `"1h"`) or milliseconds as numbers. Legacy `temporal.workflow.*` aliases remain accepted for compatibility. New code should use the canonical `workflow.*` names shown above. A pinned version override requires both the mode and deployment version: ```lua ["workflow.versioning_override"] = { mode = "pinned", version = { deployment_name = "orders", build_id = "orders-v2", }, } ``` Use the string `"auto_upgrade"` for an auto-upgrade override. #### Parent Close Policy Controls what happens to child workflows when the parent closes: | Policy | Behavior | |--------|----------| | `"terminate"` | Terminate child workflow | | `"abandon"` | Let child continue independently | | `"request_cancel"` | Send cancellation request to child | ### Startup Messages Queue signals with a workflow start. The first non-empty startup message is sent atomically with the start. Remaining startup messages are sent sequentially in builder order after the workflow starts, but they can interleave with signals sent concurrently by other callers: ```lua local spawner = process .with_options({}) :with_name("counter-workflow") :with_message("increment", {amount = 2}) :with_message("increment", {amount = 1}) :with_message("increment", {amount = 4}) local pid, err = spawner:spawn_monitored( "app:counter_workflow", "app:worker", {initial = 0} ) if err then return nil, err end ``` With the `use_existing` conflict policy, startup messages are still delivered when a second spawn resolves to an existing workflow: ```lua -- First spawn starts the workflow with initial messages local first = process .with_options({}) :with_name("my-counter") :with_message("increment", {amount = 3}) local pid, first_err = first:spawn("app:counter_workflow", "app:worker", {initial = 0}) if first_err then return nil, first_err end -- Second spawn reuses existing workflow and delivers new messages local second = process .with_options({}) :with_name("my-counter") :with_message("increment", {amount = 2}) local pid2, second_err = second:spawn("app:counter_workflow", "app:worker", {initial = 999}) if second_err then return nil, second_err end -- pid2 == pid (same workflow), input {initial = 999} is ignored -- But the increment message with amount=2 is delivered ``` ### Context Propagation Pass context values that are accessible inside the workflow and its activities: ```lua local spawner = process.with_context({ user_id = "user-1", tenant = "tenant-1", request_id = "req-abc", }) local pid, err = spawner:spawn_monitored( "app:order_workflow", "app:worker", order ) if err then return nil, err end ``` Inside the workflow (or any activity it calls), read context via the `ctx` module: ```lua local ctx = require("ctx") local user_id, user_err = ctx.get("user_id") -- "user-1" if user_err then return nil, user_err end local tenant, tenant_err = ctx.get("tenant") -- "tenant-1" if tenant_err then return nil, tenant_err end local all, err = ctx.all() -- {user_id="user-1", tenant="tenant-1", request_id="req-abc"} if err then return nil, err end ``` ### Security Context The actor and scope of the caller travel with the workflow, separately from `ctx` values and under stronger rules. They are carried in two Temporal headers: | Header | Content | |--------|---------| | `wippy-security` | JSON envelope: actor ID, actor metadata, policy IDs, and the audience | | `wippy-security-signature` | HMAC-SHA256 over that envelope, keyed by the client's `security_hmac_key` | The audience is the ID of the execution the header was minted for — the workflow ID for a start or a signal, the activity ID for an activity. A header replayed against a different execution fails the audience check, so a captured header cannot be reused elsewhere. Verification happens before the workflow body runs. The signature must match one of the client's keys, the audience must equal this execution's ID, and every policy named in the envelope must resolve in the local security registry. **Any of those failing fails the workflow execution** — it is not a warning and the workflow does not run with a reduced context. The same is true of an envelope that is internally inconsistent, such as an actor without a scope or policies without an actor. Configure the keys on the [`temporal.client`](temporal/overview.md#security-context-propagation) entry. Starting a workflow from a context that has an actor or a scope requires a signing key; without one the start fails rather than proceeding unsigned. #### Secured workflows reject unsigned signals A workflow running under a security context requires every incoming signal to carry a signed relay ticket — headers `wippy-relay-signal` and `wippy-relay-signal-signature` — bound to that workflow ID and that signal name. An unsigned or mis-addressed signal is rejected instead of delivered. Signals sent by Wippy processes through `process.send` are signed automatically. Signals injected from outside Wippy — the Temporal CLI, `tctl`, or another SDK — carry no ticket and therefore fail against a secured workflow. Drive a secured workflow only from Wippy. #### Deterministic child and activity IDs Under a security context, a child workflow or activity started without an explicit ID gets a derived one instead of a random one, because the ID is the audience the header is signed for and must be reproducible on replay: | Started from a secured workflow | Generated ID | |---------------------------------|--------------| | Child workflow | `--child-` | | Activity | `--activity-` | `N` counts within the workflow execution. An explicitly supplied `temporal.workflow.id` or activity ID is used as-is and becomes the audience. Without a security context, IDs are left to Temporal as before. ### From HTTP Handlers ```lua local function handler() local req, req_err = http.request() if req_err then return nil, req_err end local body, body_err = req:body() if body_err then return nil, body_err end local order, decode_err = json.decode(body) if decode_err then return nil, decode_err end local request_id, header_err = req:header("X-Request-ID") if header_err then return nil, header_err end local spawner = process .with_context({request_id = request_id}) :with_options({ ["workflow.id"] = "order-" .. order.id, ["workflow.id_conflict_policy"] = "fail", }) local pid, err = spawner:spawn( "app:order_workflow", "app:worker", order ) local res, res_err = http.response() if res_err then return nil, res_err end if err then local status_err = res:set_status(409) if status_err then return nil, status_err end local write_err = res:write_json({error = tostring(err)}) if write_err then return nil, write_err end return true end local status_err = res:set_status(202) if status_err then return nil, status_err end local write_err = res:write_json({ workflow_id = tostring(pid), status = "started" }) if write_err then return nil, write_err end return true end ``` ## Signals Workflows receive signals via the process messaging system. Signals are durable - they survive workflow replays. ### Inbox Pattern Receive all messages through the process inbox: ```lua local function main(order) local inbox = process.inbox() while true do local msg, open = inbox:receive() if not open then return nil, errors.new({kind = errors.INTERNAL, message = "workflow inbox closed"}) end local topic = msg:topic() if topic == "approve" then break elseif topic == "cancel" then local payload = msg:payload() local data if payload then local payload_err data, payload_err = payload:data() if payload_err then return nil, payload_err end end local reason = type(data) == "table" and data.reason or nil return {status = "cancelled", reason = reason} end end return process_order(order) end ``` ### Topic-Based Subscription Subscribe to specific topics using `process.listen()`: ```lua local function main(input) local results = {} local job_ch, job_err = process.listen("add_job") if job_err then return nil, job_err end local exit_ch, exit_err = process.listen("exit") if exit_err then return nil, exit_err end while true do local result = channel.select{ job_ch:case_receive(), exit_ch:case_receive() } if result.channel == exit_ch then break elseif result.channel == job_ch then if not result.ok then break end local job_data = result.value local activity_result, err = funcs.call( "app:echo_activity", {job_id = job_data.id, data = job_data} ) if err then return nil, err end table.insert(results, { job_id = job_data.id, result = activity_result }) end end return {total_jobs = #results, results = results} end ``` By default, `process.listen()` returns raw payload data. Use `{message = true}` to receive Message objects with sender information: ```lua local ch, err = process.listen("request", {message = true}) if err then return nil, err end local msg, open = ch:receive() if not open then return nil, errors.new({kind = errors.INTERNAL, message = "request channel closed"}) end local sender = msg:from() local payload = msg:payload() local data if payload then local payload_err data, payload_err = payload:data() if payload_err then return nil, payload_err end end ``` ### Serialized Signal Handling Use one `channel.select()` loop when signals mutate shared workflow state. This preserves deterministic mutation order and lets the `finish` branch return without leaving blocked handler coroutines: ```lua local function main(input) local counter = input.initial or 0 local function send_reply(pid, topic, payload) local sent, err = process.send(pid, topic, payload) if err then error(err) end return sent end local function message_data(msg) local payload = msg:payload() if not payload then return nil end return payload:data() end local increment_ch, increment_err = process.listen("increment", {message = true}) if increment_err then return nil, increment_err end local decrement_ch, decrement_err = process.listen("decrement", {message = true}) if decrement_err then return nil, decrement_err end local finish_ch, finish_err = process.listen("finish", {message = true}) if finish_err then return nil, finish_err end while true do local result = channel.select{ increment_ch:case_receive(), decrement_ch:case_receive(), finish_ch:case_receive() } if not result.ok then return nil, errors.new({kind = errors.INTERNAL, message = "signal channel closed"}) end local msg = result.value local reply_to = msg:from() if result.channel == finish_ch then send_reply(reply_to, "ack") send_reply(reply_to, "ok", {message = "finishing", value = counter}) return {final_counter = counter} end local data, payload_err = message_data(msg) if payload_err then return nil, payload_err end if type(data) ~= "table" or type(data.amount) ~= "number" then send_reply(reply_to, "nak", "amount must be a number") elseif result.channel == decrement_ch and counter - data.amount < 0 then send_reply(reply_to, "nak", "would result in negative value") else send_reply(reply_to, "ack") if result.channel == increment_ch then counter = counter + data.amount else counter = counter - data.amount end send_reply(reply_to, "ok", {value = counter}) end end end ``` ### Signal Acknowledgment Implement request-reply patterns by sending responses back to the sender: ```lua -- Workflow side local ch, err = process.listen("get_status", {message = true}) if err then return nil, err end local msg, open = ch:receive() if not open then return nil, errors.new({kind = errors.INTERNAL, message = "status channel closed"}) end local sent, send_err = process.send(msg:from(), "status_response", {status = "processing", progress = 75}) if send_err then return nil, send_err end ``` ```lua -- Caller side local response_ch, listen_err = process.listen("status_response") if listen_err then return nil, listen_err end local sent, send_err = process.send(workflow_pid, "get_status", {}) if send_err then return nil, send_err end local timeout, timeout_err = time.after("5s") if timeout_err then return nil, timeout_err end local result = channel.select{ response_ch:case_receive(), timeout:case_receive() } if result.channel == response_ch then if not result.ok then return nil, errors.new({kind = errors.INTERNAL, message = "status response channel closed"}) end return result.value end if not result.ok then return nil, errors.new({kind = errors.INTERNAL, message = "status timeout channel closed"}) end return nil, errors.new({kind = errors.TIMEOUT, message = "status request timed out", retryable = true}) ``` ### Cross-Workflow Signaling Workflows can send signals to other workflows using their PID: ```lua -- Sender workflow local function main(input) local target_pid = input.target local response_ch, listen_err = process.listen("cross_host_pong") if listen_err then return nil, listen_err end local ok, err = process.send(target_pid, "cross_host_ping", {data = "hello"}) if err then return {ok = false, error = tostring(err)} end local response, open = response_ch:receive() if not open then return {ok = false, error = "cross_host_pong channel closed"} end return {ok = true, received = response} end ``` ### Synchronous Child (workflow.exec) Execute a child workflow and wait for the result: ```lua local result, err = workflow.exec("app:child_workflow", input_data) if err then return nil, err end ``` ### Asynchronous Child (process.spawn) Spawn a child workflow without blocking, then wait for its completion via events: ```lua local events_ch = process.events() local child_pid, err = process.spawn( "app:child_workflow", "app:worker", {message = "hello from parent"} ) if err then return {status = "spawn_failed", error = tostring(err)} end -- Wait for child EXIT event local event, open = events_ch:receive() if not open then return nil, errors.new({kind = errors.INTERNAL, message = "process event channel closed"}) end if event.kind == process.event.EXIT then local child_result = event.result.value local child_error = event.result.error end ``` ### Error Propagation from Children When a child workflow returns an error, it appears in the EXIT event: ```lua local events_ch = process.events() local child_pid, err = process.spawn( "app:error_child_workflow", "app:worker" ) if err then return nil, err end local event, open = events_ch:receive() if not open then return nil, errors.new({kind = errors.INTERNAL, message = "process event channel closed"}) end if event.result.error then local child_err = event.result.error -- Error objects have kind(), retryable(), message() methods print(child_err:kind()) -- e.g. "NotFound" print(child_err:retryable()) -- false print(child_err:message()) -- error message text end ``` ### Executing Workflows Synchronously (process.exec) Run a workflow and wait for its result in one call: ```lua local result, err = process.exec( "app:hello_workflow", "app:worker", {name = "world"} ) if err then return nil, err end -- result contains the workflow return value ``` ### Post-Start Monitoring Monitor a workflow after it has already started: ```lua local pid, err = process.spawn( "app:long_workflow", "app:worker", {iterations = 100} ) if err then return nil, err end -- Monitor later local ok, monitor_err = process.monitor(pid) if monitor_err then return nil, monitor_err end local events_ch = process.events() local event, open = events_ch:receive() -- EXIT when workflow completes if not open then return nil, errors.new({kind = errors.INTERNAL, message = "process event channel closed"}) end ``` ### Post-Start Linking Link to a running workflow to receive LINK_DOWN on abnormal termination: ```lua local ok, err = process.set_options({trap_links = true}) if err then return nil, err end local pid, err = process.spawn( "app:long_workflow", "app:worker", {iterations = 100} ) if err then return nil, err end -- Link after workflow has started time.sleep("200ms") local linked, link_err = process.link(pid) if link_err then return nil, link_err end -- If workflow is terminated, receive LINK_DOWN local terminated, terminate_err = process.terminate(pid) if terminate_err then return nil, terminate_err end local events_ch = process.events() local event, open = events_ch:receive() if not open then return nil, errors.new({kind = errors.INTERNAL, message = "process event channel closed"}) end -- event.kind == process.event.LINK_DOWN ``` LINK_DOWN events require `trap_links = true` in process options. Without it, a linked process termination propagates the failure. ### Unmonitor / Unlink Remove monitoring or linking: ```lua local unmonitored, unmonitor_err = process.unmonitor(pid) if unmonitor_err then return nil, unmonitor_err end local unlinked, unlink_err = process.unlink(pid) if unlink_err then return nil, unlink_err end ``` After unmonitoring or unlinking, events for that process are no longer delivered. ### Terminate Force-terminate a running workflow: ```lua local ok, err = process.terminate(workflow_pid) ``` Monitored callers receive an EXIT event with an error. ### Cancel Request graceful cancellation with an optional reason: ```lua local ok, err = process.cancel(workflow_pid, "cancelled by operator") ``` ## Concurrent Work Use `coroutine.spawn()` and channels for parallel work inside workflows: ```lua local function main(input) local worker_count = input.workers or 3 local job_count = input.jobs or 6 local work_queue = channel.new(10) local results = channel.new(10) for w = 1, worker_count do coroutine.spawn(function() while true do local job, ok = work_queue:receive() if not ok then break end time.sleep(10 * time.MILLISECOND) results:send({worker = w, job = job, result = job * 2}) end end) end for j = 1, job_count do work_queue:send(j) end work_queue:close() local total = 0 local processed = {} for _ = 1, job_count do local r, open = results:receive() if not open then return nil, errors.new({kind = errors.INTERNAL, message = "results channel closed"}) end total = total + r.result table.insert(processed, r) end return {total = total, processed = processed} end ``` All channel operations and sleeps inside coroutines are replay-safe. ## Timers Durable timers survive restarts: ```lua local time = require("time") time.sleep("24h") time.sleep("5m") time.sleep("30s") time.sleep(100 * time.MILLISECOND) ``` Track elapsed time: ```lua local start = time.now() time.sleep("1s") local elapsed = time.now():sub(start):milliseconds() ``` ## Determinism Workflow code must be deterministic. The same inputs must produce the same sequence of commands. ### Replay-Safe Operations These operations are automatically intercepted and their results recorded. On replay, recorded values are returned: ```lua -- Activity calls local data = funcs.call("app:fetch_data", id) -- Durable sleep time.sleep("1h") -- Current time local now = time.now() -- UUID generation local id = uuid.v4() -- Crypto operations local bytes = crypto.random.bytes(32) -- Child workflows local result = workflow.exec("app:child", input) -- Versioning local v = workflow.version("change-1", 1, 2) ``` ### Non-Deterministic (Avoid) ```lua -- Don't use wall clock time local now = os.time() -- non-deterministic -- Don't use random directly local r = math.random() -- non-deterministic -- Don't do I/O in workflow code local file = io.open("data.txt") -- non-deterministic -- Don't use global mutable state counter = counter + 1 -- non-deterministic across replays ``` ### Activity Errors Activity errors carry structured metadata: ```lua local result, err = funcs.call("app:risky_activity", order) if err then print(err:kind()) -- error classification (e.g. "NotFound", "Internal") print(err:retryable()) -- whether the error is retryable print(err:message()) -- human-readable error message end ``` ### Activity Failure Modes Configure retry behavior for activity calls: ```lua local executor = funcs.new():with_options({ ["activity.retry_policy"] = { maximum_attempts = 1, } }) local result, err = executor:call("app:unreliable_activity", input) if err then local kind = err:kind() -- "Internal" for runtime errors local retryable = err:retryable() end ``` ### Child Workflow Errors Errors from child workflows (via `process.exec` or EXIT events) carry the same metadata: ```lua local result, err = process.exec("app:error_workflow", "app:worker") if err then print(err:kind()) -- e.g. "NotFound" print(err:retryable()) -- false print(err:message()) -- error details end ``` ## Compensation Pattern (Saga) ```lua local function run_compensations(compensations) local first_err for _, comp in ipairs(compensations) do local _, err = funcs.call(comp.action, comp.args) if err and not first_err then first_err = err end end if first_err then return nil, first_err end return true end local function main(order) local compensations = {} local reservation, err = funcs.call("app:reserve_inventory", order.items) if err then return {status = "failed", step = "inventory", error = tostring(err)} end table.insert(compensations, 1, { action = "app:release_inventory", args = reservation.id }) local payment, err = funcs.call("app:charge_payment", order.payment) if err then local _, compensation_err = run_compensations(compensations) if compensation_err then return {status = "failed", step = "payment", error = tostring(err), compensation_error = tostring(compensation_err)} end return {status = "failed", step = "payment", error = tostring(err)} end table.insert(compensations, 1, { action = "app:refund_payment", args = payment.id }) local shipment, err = funcs.call("app:ship_order", order.shipping) if err then local _, compensation_err = run_compensations(compensations) if compensation_err then return {status = "failed", step = "shipping", error = tostring(err), compensation_error = tostring(compensation_err)} end return {status = "failed", step = "shipping", error = tostring(err)} end return {status = "completed", tracking = shipment.tracking} end ``` Compensations run in reverse registration order. If more than one compensation fails, the workflow still attempts the remaining actions and reports the first failure through `compensation_error`. ## See Also - [Overview](temporal/overview.md) - Client and worker configuration - [Activities](temporal/activities.md) - Activity definitions and options - [Process](lua/core/process.md) - Process management API - [Functions](lua/core/funcs.md) - Function invocation - [Channels](lua/core/channel.md) - Channel operations ## Navigation Previous: "Activities" (temporal/activities) Next: "Process Host" (system/process-host)