Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Signals and queries

Signals and queries connect a running execution to the outside world. A signal is a typed message that a person or another system sends to an execution, such as a reviewer’s decision; an await step waits for it durably. A query is a typed, read-only view of an execution’s state that anyone can evaluate at any time, such as “where does this order stand?”. Both are declared in the workflow, so the compiler checks every use of them, and the runtime validates every signal payload before it is recorded. This page covers declarations, await steps, buffering and ordering, timeouts, sending and querying from the CLI, HTTP and the SDKs, typing rules, errors and history events, and ends with a complete example.

Overview

ConstructSyntaxPurpose
Signal declarationsignal review { approved: Bool, reviewer: String }A message type the workflow accepts. Its fields are the payload.
await stepstep decision { await review timeout 2d }Waits for the next review signal. The payload is the step’s output.
Query declarationquery status = { approved: decision.approved }An expression evaluated on demand against the current state.
workflow Approval {
    version "1.0.0"
    input { request_id: String }

    signal review { approved: Bool, reviewer: String, comment: String? }

    step decision {
        await review
        timeout 2d
    }

    query status = {
        decided: !is_null(decision)
        approved: decision.approved
    }

    return { approved: decision.approved, reviewer: decision.reviewer }
}

Sending a signal and evaluating a query need an execution id and the declared name:

$ orbit signal <execution-id> review --data '{"approved": true, "reviewer": "dana"}'
$ orbit query <execution-id> status

Declaring a signal

signal <name> { <fields> }

A signal declaration is a workflow member. <name> is an identifier, unique among the workflow’s signals (E0317). The body is a record, written exactly like a workflow input block: fields with types, optional fields (T?) and constant defaults. Field types can be schemas, lists and maps:

schema Reviewer {
    name: String
    team: String = "operations"
}

workflow Exceptions {
    version "1.0.0"
    timeout 30d

    signal review {
        approved: Bool
        by: Reviewer
        comment: String?
        tags: List<String> = []
    }

    step decision { await review }

    return { approved: decision.approved, team: decision.by.team }
}

Signal names and step names are separate namespaces, and so are query names. A workflow can declare any number of signals. A signal that no step awaits is warning W0605 (see Static safety checks).

How the runtime validates a payload

When a signal arrives, the runtime:

  1. applies the declared defaults to missing fields, including nested schema fields,
  2. checks the payload against the declared type and rejects it with 422 SIGNAL_PAYLOAD_INVALID if a required field is missing or a value has the wrong type; the message names the path, for example payload for signal `review` is invalid at $.approved: expected Bool, found a string,
  3. records the validated payload in the history.

Fields that the declaration does not list are accepted and kept in the recorded payload. (Workflow inputs are stricter: an execution input with an undeclared field is rejected.) An optional field that is not sent stays absent from the payload; reading it gives null.

Waiting for a signal: await

step <name> { await <signal> [timeout DURATION] [after ...] [when ...] }

await <signal> is a step action, like worker or sleep. It names a signal declared in the same workflow; an unknown name is E0315, with a suggestion for a close misspelling. When the step becomes eligible, the runtime records SignalAwaitStarted and the unit is WAITING. When a signal of that name is available, the runtime records SignalDelivered and the step succeeds with the signal’s payload as its output.

The step’s output type is the signal’s record type, so later steps read the payload fields directly:

task "refunds.issue" {
    input { order_id: String, amount: Float, note: String }
    output { refund_id: String }
}

workflow RefundRequest {
    version "1.0.0"
    input { order_id: String, amount: Float }

    signal decision { approved: Bool, note: String = "" }

    step review {
        await decision
        timeout 3d
    }

    step refund {
        worker "refunds.issue"
        when review.approved
        with { order_id: input.order_id, amount: input.amount, note: review.note }
    }

    return { approved: review.approved, refund_id: refund.refund_id }
}

Members of an await step

An await step needs no worker, so most members do not apply:

MemberAllowedMeaning
timeoutyesThe longest the step waits, from SignalAwaitStarted. See Timeouts.
afteryesStart waiting only after these steps. Signals that arrive earlier are buffered.
whenyesWait only if the condition is true; otherwise the step is SKIPPED and its output is null.
for, max_parallelno (E0307)An await step receives one signal. Use several steps to receive several signals.
with, using, retry, queue_timeout, idempotency_keyno (E0307)There is no task to call.
compensateno (E0308)Receiving a signal has nothing to undo.
workflow Approvals {
    version "1.0.0"
    input { reviewers: List<String> }
    signal review { approved: Bool }
    step decision {
        await review
        timeout 2d
        retry { attempts: 3 }
        with { reviewer: "dana" }
    }
}
error[E0307]: `retry` is not allowed in an `await` step
 --> approvals.orbit:8:9
  |
8 |         retry { attempts: 3 }
  |         ^^^^^^^^^^^^^^^^^^^^^

error[E0307]: `with` is not allowed in an `await` step
 --> approvals.orbit:9:14
  |
9 |         with { reviewer: "dana" }
  |              ^^^^^^^^^^^^^^^^^^^^

error: compilation failed: 2 error(s), 0 warning(s)

Output type

StepType of name
await sthe record type of signal s
await s with whenthe record type, optional ({ ... }?)

Reading a field of an optional record gives an optional value, so decision.approved has type Bool? when decision has a when condition. Use coalesce(decision.approved, false) where a Bool is required.

Buffering and ordering

Signals are buffered per execution. The runtime accepts a signal at any time while the execution is not terminal, whether or not a step is waiting for it:

  • A signal that arrives before its await step starts is kept in the buffer. When the step starts waiting, it takes the buffered signal right away.
  • Each await step takes the oldest signal of its name that no step has taken yet. Signals of one name are therefore delivered in the order they were received.
  • Each signal is delivered to at most one step. Two steps that wait for the same signal at the same time never receive the same signal.
  • A signal that no step takes stays in the buffer, undelivered. It is ignored when the execution finishes.

The buffer is part of the execution state, in the signals array of GET /v1/executions/{id} and orbit inspect <id> (see Executions and history).

In this workflow, both signals are sent while the first step is still sleeping:

workflow Batch {
    version "1.0.0"
    timeout 1h

    signal item { sku: String, quantity: Int = 1 }

    step pause { sleep 2s }
    step first { await item after pause }
    step second { await item after first }

    return { skus: [first.sku, second.sku], total: first.quantity + second.quantity }
}
$ orbit run batch.orbit --id batch-1 --no-wait
$ orbit signal batch-1 item --data '{"sku": "A-1"}'
signal `item` recorded for batch-1 (history #4); the next `await item` step takes it
$ orbit signal batch-1 item --data '{"sku": "B-2", "quantity": 3}'
signal `item` recorded for batch-1 (history #5); the next `await item` step takes it
$ orbit status batch-1
COMPLETED batch-1  Batch@1.0.0
  elapsed: 2.0s

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  first                        SUCCEEDED          1      0ms  
  pause                        SUCCEEDED          1     2.0s  
  second                       SUCCEEDED          1      0ms  

  output: {"skus":["A-1","B-2"],"total":4}
$ orbit logs batch-1
+     0ms   #1  WorkflowCreated           
+     0ms   #2  WorkflowStarted           
+     1ms   #3  TimerStarted              step=pause
+    13ms   #4  SignalReceived            name=item
+    24ms   #5  SignalReceived            name=item
+    2.0s   #6  TimerFired                step=pause
+    2.0s   #7  SignalAwaitStarted        step=first signal=item
+    2.0s   #8  SignalDelivered           step=first signal_seq=4
+    2.0s   #9  SignalAwaitStarted        step=second signal=item
+    2.0s  #10  SignalDelivered           step=second signal_seq=5
+    2.0s  #11  WorkflowCompleted         

first received the first signal and second the second one. The default quantity: Int = 1 was applied to the first payload.

orbit signal reports success (signal ... recorded for <id>) as soon as the runtime has recorded and buffered the signal, whether or not a step is waiting for it. The SignalDelivered event shows when a step took it.

Timeouts

timeout on an await step bounds the wait. If no signal of that name is available when the timeout passes, the step fails with the non-retryable error SIGNAL_TIMEOUT, and the execution aborts like after any other step failure: work that has not started is withdrawn and succeeded steps are compensated, newest first. See Compensation.

task "payments.charge" {
    input { order_id: String, amount: Float }
    output { transaction_id: String }
}

task "payments.refund" {
    input { transaction_id: String, amount: Float }
    output { refunded: Bool }
}

workflow ConfirmCharge {
    version "1.0.0"
    input { order_id: String, amount: Float }

    signal confirm { ok: Bool }

    step charge {
        worker "payments.charge"
        with { order_id: input.order_id, amount: input.amount }
        compensate {
            worker "payments.refund"
            with { transaction_id: charge.transaction_id, amount: input.amount }
        }
    }

    step confirmation {
        await confirm
        after charge
        timeout 2s
    }
}

With no confirm signal sent:

$ orbit logs confirm-1
+     0ms   #1  WorkflowCreated           
+     0ms   #2  WorkflowStarted           
+     1ms   #3  StepScheduled             step=charge attempt=1
+     3ms   #4  StepDispatched            step=charge attempt=1 worker_id=orders-python
+    12ms   #5  StepSucceeded             step=charge attempt=1
+    14ms   #6  SignalAwaitStarted        step=confirmation signal=confirm
+    2.0s   #7  StepFailed                step=confirmation error=SIGNAL_TIMEOUT no `confirm` signal arrived before the step's timeout
+    2.0s   #8  CompensationStarted       reason=step `confirmation` failed: no `confirm` signal arrived before the step's timeout
+    2.0s   #9  StepScheduled             step=compensate:charge attempt=1
+    2.0s  #10  StepDispatched            step=compensate:charge attempt=1 worker_id=orders-python
+    2.0s  #11  StepSucceeded             step=compensate:charge attempt=1
+    2.0s  #12  CompensationCompleted     
+    2.0s  #13  WorkflowFailed            error=SIGNAL_TIMEOUT step `confirmation` failed: no `confirm` signal arrived before the step's timeout

The deadline is recorded in SignalAwaitStarted (deadline_ms) and in the unit’s wake_at_ms, so it survives runtime restarts. A deadline that passed while the runtime was down is handled right after startup.

Without a step timeout, the step waits until a signal arrives, the workflow timeout passes, or the execution is cancelled. An await step with neither a step timeout nor a workflow timeout is warning W0606, because the execution could wait forever.

Cancellation and other aborts

An await step that is waiting when the execution is cancelled, times out, or fails elsewhere is withdrawn: the runtime records StepCancelled and the unit ends CANCELLED. Signals sent during an abort are still validated and recorded, but no step takes them. Once the execution is terminal, sending a signal fails with 409 EXECUTION_FINISHED.

$ orbit cancel order-A-103 --reason "order withdrawn"
cancellation requested; execution is CANCELLED
$ orbit logs order-A-103 | tail -5
+   312ms   #5  CancelRequested           reason=order withdrawn
+   313ms   #6  StepCancelled             step=decision
+   313ms   #7  StepCancelled             step=charge
+   313ms   #8  StepCancelled             step=notify
+   313ms   #9  WorkflowCancelled         

Sending a signal

Signals are sent by name to one execution. Sending requires the admin credential when authentication is enabled, like every other execution endpoint.

CLI

orbit signal <EXECUTION_ID> <NAME> [--data <JSON> | --data @<file>]

--data is the payload as JSON (default {}); @path reads it from a file. On success the command prints the history sequence number of the recorded SignalReceived event:

$ orbit signal order-A-100 review --data '{"approved": true, "reviewer": "dana"}'
signal `review` recorded for order-A-100 (history #5); the next `await review` step takes it
$ orbit signal order-A-100 review --data @review.json

See CLI: orbit signal.

HTTP

POST /v1/executions/{id}/signals/{name}
content-type: application/json

{"approved": true, "reviewer": "dana"}

The body is the payload. A request without a body sends {}. Response 200:

{"execution_id": "order-A-100", "signal": "review", "accepted": true, "seq": 5}

See HTTP API.

Python and TypeScript

import os

from orbit_sdk import Client

client = Client(token=os.environ["ORBIT_ADMIN_TOKEN"])
seq = client.signal("order-A-100", "review", {"approved": True, "reviewer": "dana"})
import { Client } from "orbit-lang";

const client = new Client({ token: process.env.ORBIT_ADMIN_TOKEN });
const seq = await client.signal("order-A-100", "review", { approved: true, reviewer: "dana" });

Both return the sequence number and raise (ApiError) on an error response. See Python and TypeScript.

Errors

StatusCodeWhen
404NOT_FOUNDThe execution does not exist.
404UNKNOWN_SIGNALThe execution’s workflow does not declare this signal. The message lists the declared ones: OrderApproval@1.0.0 has no signal `reveiw`; declared: review, or OrderApproval@1.0.0 declares no signals.
409EXECUTION_FINISHEDThe execution is already terminal: execution is already COMPLETED. Checked before the signal name.
422SIGNAL_PAYLOAD_INVALIDThe payload does not match the declaration. Nothing is recorded.

Signals are checked against the definition the execution is pinned to, not against the latest registered version of the workflow.

Queries

query <name> = <expression>

A query declaration is a workflow member. <name> is an identifier, unique among the workflow’s queries (E0318). The expression can read input, execution and the output of any step:

workflow Progress {
    version "1.0.0"
    input { items: List<String> }

    step count { compute len(input.items) }
    step pause { sleep 1h after count }

    query summary = {
        id: execution.id
        items: coalesce(count, 0)
        paused: !is_null(pause)
    }
}

Typing rules

A query can be evaluated at any moment, so any step may not have run yet. The compiler therefore types every step output as optional in a query: a step whose output is T is T? there (and a step that is already optional stays optional). The query’s result type is the type of the expression under that rule, and it is recorded in the compiled definition.

workflow Progress {
    version "1.0.0"
    input { items: List<String> }
    step count { compute len(input.items) }
    query remaining = count + 1
}
error[E0406]: operator `+` cannot be applied to `Int?` and `Int`
 --> progress.orbit:5:23
  |
5 |     query remaining = count + 1
  |                       ^^^^^^^^^
  |                       ----- `Int?`
  |                               - `Int`
  = help: one operand may be `null`; use `coalesce(value, default)` first

error: compilation failed: 1 error(s), 0 warning(s)

Use coalesce and is_null to handle steps that have not run. Field access on an optional record is allowed and gives null when the record is null, so decision.approved is null until decision succeeds.

Queries are not steps: they add no dependencies, cannot be read by steps or return, and do not appear in the step graph.

Evaluation

The runtime evaluates a query when it is asked, against the execution’s current state:

  • input is the execution input, with defaults applied.
  • A step that has succeeded reads as its output. A step that has not succeeded (pending, waiting, running, skipped, failed or cancelled) reads as null.
  • execution.id, execution.workflow and execution.version are available as in other expressions.

Evaluating a query is read-only. It records nothing in the history and does not change the execution. It works on running and on finished executions, so you can still read a query after the execution completes. An expression error at evaluation time, such as a division by zero, returns 422 QUERY_FAILED with the cause; nothing is recorded.

Reading a query

$ orbit query order-A-100 status
{
  "needs_approval": true,
  "approved": null,
  "reviewer": null,
  "charged": false
}
GET /v1/executions/{id}/queries/{name}
{"execution_id":"order-A-100","query":"status","value":{"needs_approval":true,"approved":true,"reviewer":"dana","charged":true}}
state = client.query("order-A-100", "status")
const state = await client.query<{ approved: boolean | null }>("order-A-100", "status");

orbit query prints the value only. The SDK methods return the value.

StatusCodeWhen
404NOT_FOUNDThe execution does not exist.
404UNKNOWN_QUERYThe workflow does not declare this query: OrderApproval@1.0.0 has no query `stat`; declared: status, or Batch@1.0.0 declares no queries.
422QUERY_FAILEDEvaluating the expression failed: evaluating query `<name>`: <cause>.

History events

EventFieldsMeaning
SignalReceivedname, payloadA signal was accepted, validated and buffered. payload has defaults applied. Its seq identifies the signal.
SignalAwaitStartedstep, signal, deadline_ms?An await step started waiting. deadline_ms is set when the step has a timeout.
SignalDeliveredstep, signal_seqThe step took the signal recorded at history position signal_seq. The step succeeded with its payload as output.

A timed-out wait is recorded as StepFailed with SIGNAL_TIMEOUT, and a withdrawn wait as StepCancelled. Queries record no events. See Executions and history.

Complete example

examples/approval.orbit charges an order. Orders of 1000 or more wait for a reviewer’s review signal; the status query reports where an order stands. The assess step is a compute step, evaluated by the runtime. It runs with the example Python and TypeScript workers.

// Human-in-the-loop approval with typed signals, queries and compute steps.
//
// Orders of 1000 or more wait for a reviewer's decision, sent as a typed
// `review` signal (`orbit signal <id> review --data '{...}'`). Anyone can ask
// where an order stands with the `status` query (`orbit query <id> status`).
//
// Workers: examples/workers/python/order_workers.py and
// examples/workers/typescript/order_workers.ts.

import "contracts.orbit"

workflow OrderApproval {
    version "1.0.0"
    description "Charge an order, with a reviewer's approval above a threshold"
    input { order_id: String, customer_id: String, amount: Float, currency: String = "USD" }
    output { status: String, transaction_id: String? }

    signal review { approved: Bool, reviewer: String, comment: String? }

    // Deterministic logic, evaluated by the runtime: no worker needed.
    step assess {
        compute {
            needs_approval: input.amount >= 1000.0
            rounded: float(int(input.amount * 100.0)) / 100.0
        }
    }

    step decision {
        await review
        when assess.needs_approval
        timeout 2d
    }

    step charge {
        worker "payments.charge"
        using python
        when !assess.needs_approval || coalesce(decision.approved, false)
        with { order_id: input.order_id, amount: assess.rounded, currency: input.currency }
        idempotency_key: "charge-" + input.order_id
        compensate {
            worker "payments.refund"
            with { transaction_id: charge.transaction_id, amount: assess.rounded }
        }
    }

    step notify {
        worker "notifications.send"
        after charge
        with {
            customer_id: input.customer_id
            message: if is_null(charge) then "Your order was not approved" else "Your order is confirmed"
        }
    }

    query status = {
        needs_approval: assess.needs_approval
        approved: decision.approved
        reviewer: decision.reviewer
        charged: !is_null(charge)
    }

    return {
        status: if is_null(charge) then "rejected" else "charged"
        transaction_id: charge.transaction_id
    }
}

decision has a when condition, so its output is optional: charge reads coalesce(decision.approved, false). When charge is skipped, notify still runs (a skipped dependency counts as done), and is_null(charge) selects the message.

Start the runtime and the example workers:

$ orbit server --db orbit.db
$ PYTHONPATH=sdks/python python3 examples/workers/python/order_workers.py
$ node examples/workers/typescript/order_workers.ts

Start an order that needs approval, and look at it while it waits:

$ orbit run examples/approval.orbit --id order-A-100 --input '{"order_id": "A-100", "customer_id": "c-7", "amount": 2500.0}' --no-wait
deployed OrderApproval@1.0.0 (sha256:939fd564b818)
started execution order-A-100
order-A-100
$ orbit status order-A-100
RUNNING order-A-100  OrderApproval@1.0.0
  elapsed: 1ms

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  assess                       SUCCEEDED          1           
  decision                     WAITING            1           
$ orbit query order-A-100 status
{
  "needs_approval": true,
  "approved": null,
  "reviewer": null,
  "charged": false
}

A payload with the wrong type is rejected and nothing is recorded:

$ orbit signal order-A-100 review --data '{"approved": "yes", "reviewer": "dana"}'
error: SIGNAL_PAYLOAD_INVALID (422): payload for signal `review` is invalid at $.approved: expected Bool, found a string

Send the decision:

$ orbit signal order-A-100 review --data '{"approved": true, "reviewer": "dana", "comment": "verified by phone"}'
signal `review` recorded for order-A-100 (history #5); the next `await review` step takes it
$ orbit status order-A-100
COMPLETED order-A-100  OrderApproval@1.0.0
  elapsed: 569ms

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  assess                       SUCCEEDED          1           
  decision                     SUCCEEDED          1    539ms  
  charge                       SUCCEEDED          1      9ms  worker orders-python
  notify                       SUCCEEDED          1      6ms  worker orders-typescript

  output: {"status":"charged","transaction_id":"tx-A-100"}
$ orbit query order-A-100 status
{
  "needs_approval": true,
  "approved": true,
  "reviewer": "dana",
  "charged": true
}

The history shows the wait, the signal and its delivery. assess is a compute step, so it succeeds without being scheduled or dispatched:

$ orbit logs order-A-100
+     0ms   #1  WorkflowCreated           
+     0ms   #2  WorkflowStarted           
+     1ms   #3  StepSucceeded             step=assess attempt=1
+     1ms   #4  SignalAwaitStarted        step=decision signal=review
+   538ms   #5  SignalReceived            name=review
+   540ms   #6  SignalDelivered           step=decision signal_seq=5
+   540ms   #7  StepScheduled             step=charge attempt=1
+   547ms   #8  StepDispatched            step=charge attempt=1 worker_id=orders-python
+   556ms   #9  StepSucceeded             step=charge attempt=1
+   557ms  #10  StepScheduled             step=notify attempt=1
+   562ms  #11  StepDispatched            step=notify attempt=1 worker_id=orders-typescript
+   568ms  #12  StepSucceeded             step=notify attempt=1
+   569ms  #13  WorkflowCompleted         
$ orbit logs order-A-100 --json | grep Signal
{"seq":4,"timestamp_ms":1794746714132,"type":"SignalAwaitStarted","step":"decision","signal":"review","deadline_ms":1794919514132}
{"seq":5,"timestamp_ms":1794746714669,"type":"SignalReceived","name":"review","payload":{"approved":true,"reviewer":"dana","comment":"verified by phone"}}
{"seq":6,"timestamp_ms":1794746714671,"type":"SignalDelivered","step":"decision","signal_seq":5}

A rejection skips the charge:

$ orbit run OrderApproval --id order-A-101 --input '{"order_id": "A-101", "customer_id": "c-8", "amount": 4000.0}' --no-wait
$ orbit signal order-A-101 review --data '{"approved": false, "reviewer": "lee", "comment": "customer could not be reached"}'
signal `review` recorded for order-A-101 (history #5); the next `await review` step takes it
$ orbit status order-A-101
COMPLETED order-A-101  OrderApproval@1.0.0
  elapsed: 328ms

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  assess                       SUCCEEDED          1           
  charge                       SKIPPED            0           
  decision                     SUCCEEDED          1    315ms  
  notify                       SUCCEEDED          1      5ms  worker orders-typescript

  output: {"status":"rejected","transaction_id":null}

A small order needs no approval: decision is skipped and the order is charged straight away:

$ orbit run OrderApproval --id order-A-102 --input '{"order_id": "A-102", "customer_id": "c-9", "amount": 120.0}'
started execution order-A-102
  ✓ assess                         succeeded 
  ↷ decision                       skipped 
  ✓ charge                         succeeded 7ms
  ✓ notify                         succeeded 9ms

COMPLETED order-A-102  OrderApproval@1.0.0
  elapsed: 22ms
{
  "status": "charged",
  "transaction_id": "tx-A-102"
}

Changing signals and queries

Signals and queries are part of the workflow definition and its hash. Adding, removing or changing one requires a new workflow version, and executions keep the signals and queries of the version they started with. Senders and readers depend on them like callers depend on the input, so orbit compat reports a removed signal or query, a signal payload change that would reject payloads that used to be valid, and a query result change that readers may not expect. See Compatibility checks.

A workflow that declares a signal or a query compiles to orbit-ir/2. See Versioning.

See also