Skip to content

Signals and Waits ​

Use a wait node to park the workflow until work outside the worker is done. Examples are a payment webhook, a human approval, a cooldown, or a reminder date.

A parked wait does not hold a worker. The wait is state in Postgres until a signal arrives or a deadline lapses.

Wait for a Signal ​

This workflow charges an order. It then waits for a paid webhook. Fulfillment starts after that signal.

go
type Receipt struct {
    PaymentID string `json:"payment_id"`
    Amount    int64  `json:"amount"`
}

var Fulfill = cyclone.NewAction("fulfill",
    func(ctx cyclone.Context, receipt cyclone.SignalResult[Receipt]) error {
        if receipt.TimedOut {
            return fmt.Errorf("payment signal timed out")
        }
        _, err := ctx.CompletionTx().Exec(ctx,
            `UPDATE orders SET paid_at = now(), payment_id = $1
             WHERE run_id = $2`,
            receipt.Value.PaymentID, ctx.RunID())
        return err
    })

var OrderWorkflow = cyclone.Define("order", func(b *cyclone.Builder, in OrderInput) error {
    charged := cyclone.Run(b, ChargeCard, cyclone.Lit(in))
    receipt := cyclone.WaitSignal[Receipt](b, "paid", cyclone.After(charged))
    cyclone.Run(b, Fulfill, cyclone.From(receipt))
    return nil
})

When the webhook arrives, deliver it to the run:

go
err := client.Signal(ctx, runID, "paid", Receipt{
    PaymentID: "pi_123",
    Amount:    9900,
})

WaitSignal[T] completes with cyclone.SignalResult[T]. The run stays parked when the signal does not arrive. Add a hard cap to stop that wait.

Add a Hard Cap ​

Use WithMaxWait to stop the wait when the signal does not arrive. The workflow then chooses the next action.

go
receipt := cyclone.WaitSignal[Receipt](b, "paid",
    cyclone.After(charged),
    cyclone.WithMaxWait(6*time.Hour),
)

When paid does not arrive within six hours, the wait node completes with TimedOut: true. The next step can fail, escalate, or continue.

Set a limit for the whole client:

go
client, err := cyclone.NewClient(pool, mux,
    cyclone.WithWorkers(8),
    cyclone.WithMaxSignalWait(24*time.Hour),
)

Wait or Continue ​

Use WaitSignal with WithMaxWait when silence is a normal branch.

go
type Approval struct {
    Reviewer string `json:"reviewer"`
}

var HandleApproval = cyclone.NewAction("handle_approval",
    func(ctx cyclone.Context, in cyclone.SignalResult[Approval]) error {
        if in.TimedOut {
            return escalateToManager(ctx)
        }
        return markApproved(ctx, in.Value.Reviewer)
    })

var ApprovalWorkflow = cyclone.Define("approval", func(b *cyclone.Builder, in Request) error {
    approval := cyclone.WaitSignal[Approval](b, "approved", cyclone.WithMaxWait(48*time.Hour))
    cyclone.Run(b, HandleApproval, cyclone.From(approval))
    return nil
})

WaitSignal[T] returns cyclone.SignalResult[T]. When the signal arrives, TimedOut is false. Value contains the payload. When the duration elapses, TimedOut is true. The run continues.

Wait for Time ​

Use WaitTimer for a delay with no payload. The workflow continues after the delay.

go
var ReminderWorkflow = cyclone.Define("invoice_reminder", func(b *cyclone.Builder, in Invoice) error {
    delay := cyclone.WaitTimer(b, 7*24*time.Hour)
    cyclone.Run(b, SendReminder, cyclone.Lit(in), cyclone.After(delay))
    return nil
})

WaitTimer returns a *Node. It does not return a *Future. Use it with After.

Gather a Quorum of Signals ​

Use WaitN when the node continues only after N signals of the same name. Examples are a fixed number of approvals, acknowledgements, or worker results. The node completes with the payloads as a typed slice. The slice is in arrival order.

go
var ReleaseWorkflow = cyclone.Define("release", func(b *cyclone.Builder, in Release) error {
    // Wait for three sign-offs before shipping.
    approvals := cyclone.WaitN[Approval](b, "signoff", 3)
    cyclone.Run(b, Ship, cyclone.From(approvals))
    return nil
})

Deliver each signal with Client.Signal(ctx, runID, "signoff", approval). The node parks until the third signal arrives. The node then completes with []Approval of length three.

Add WithMaxWait to limit the wait. When the limit elapses before N signals arrive, the node completes. The slice can be shorter than N. The node does not fail the run. The next step decides what a partial quorum means.

go
approvals := cyclone.WaitN[Approval](b, "signoff", 3, cyclone.WithMaxWait(48*time.Hour))
// approvals may hold fewer than 3 elements if the deadline won.

WaitN[T] returns a *Future[[]T]. Cyclone stores a signal that arrives before the node parks. The wait reads those signals when the node starts to wait. WaitSignal does the same.

Wait for the First of Several Signals ​

Use WaitAny for the first of signal A, signal B, or a deadline. The node parks on several signal names. The node completes with the first signal that arrives. The result names that signal.

go
var ReviewWorkflow = cyclone.Define("review", func(b *cyclone.Builder, in Doc) error {
    // Whichever verdict lands first wins.
    verdict := cyclone.WaitAny[Vote](b, []string{"approved", "rejected"},
        cyclone.WithMaxWait(48*time.Hour))
    cyclone.Run(b, Finalize, cyclone.From(verdict))
    return nil
})

WaitAny[T] returns a *Future[AnyResult[T]]:

go
type AnyResult[T any] struct {
    Fired    string // the signal name that arrived first; "" on timeout
    Value    T      // its payload; zero on timeout
    TimedOut bool   // true if WithMaxWait elapsed before any candidate
}

All candidates share the payload type T. Add WithMaxWait to include a deadline branch. When the limit elapses before a candidate arrives, the node completes with TimedOut true. Fired is empty. The candidate that arrived first wins. Before the node parks, the mailbox keeps only the rows that the wait can use. WaitSignal keeps one row. WaitAny keeps one row. WaitN keeps N rows. A delivery after the wait completes changes nothing while the run stays active. The call is idempotent. A delivery after the run is terminal returns ErrAlreadyTerminal.

Each candidate name must be distinct. No other wait node in the workflow can use that name. The submit check rejects a name that two waits use. One delivered signal then maps to exactly one node.

Signals Can Arrive Early ​

You can call Client.Signal before the wait node parks. Cyclone stores the signal row. The wait scanner reads that row when the node reaches waiting_signal.

This matters for a flow with many webhooks. A payment provider can call back while an earlier step is still in progress. Cyclone stores the payload. Cyclone does not drop the payload.

Cyclone limits duplicate deliveries. Before the wait starts, Cyclone keeps at most the capacity that the wait declared. Cyclone does not store a delivery after that wait completes. Duplicate webhooks cannot grow the mailbox with no limit. When a duplicate payload has a business meaning, the handler must still be idempotent.

Signals to a Paused Run ​

Signal on a paused run returns nil. Cyclone stores the signal. The wait does not complete during the pause. The WithMaxWait limit stops. The timers stop. The mailbox keeps only the signals that the wait can use. A WaitN keeps the payloads that it gathered before the pause. After Resume, the wait scanner delivers the stored signals in arrival order. See Pause and Resume Runs.

Names and States ​

A workflow can declare each signal name once per run. Submit fails when two waits use the name paid. Client.Signal(ctx, runID, "paid", payload) then maps to one node.

Wait nodes appear in normal status and stats:

StateMeaning
waiting_signalParked until a named signal arrives or the max wait elapses
waiting_timerParked until the timer ends
pausedThe run is paused. The wait does not move until Resume

The lifecycle events are signalled, wait_lapsed, and timer_fired.

Testing Waits ​

cyclonetest can move a wait with no real-time delay.

go
env.RunUntilIdle()
env.RequireStepState(runID, "wait_signal:paid", cyclone.StepWaitingSignal)

err := env.Client().Signal(ctx, runID, "paid", Receipt{PaymentID: "pi_123"})
require.NoError(t, err)

env.RunUntilComplete(runID)
env.RequireCompleted(runID)

For a timer or a bounded wait, move the stored deadline into the past:

go
env.ElapseWait(runID, "wait_timer")
env.RunUntilComplete(runID)

Read the node wait_signal:paid with cyclone.OutputNode to check the SignalResult payload. See Testing with cyclonetest.

Next ​

Cyclone stores each run in Postgres.