Signals
Signals let the outside world talk to a running workflow: a human approval, a webhook, a payment confirmation. The workflow parks until the signal arrives, and signals are persisted so nothing is lost across restarts.
Waiting for a signal
Section titled “Waiting for a signal”[Workflow("approval")]public sealed class ApprovalWorkflow{ [WorkflowRun] public async ValueTask<string> RunAsync( IWorkflowContext ctx, string request, CancellationToken cancellationToken ) { var decision = await ctx.WaitForSignal<ApprovalDecision>("approval", cancellationToken); return decision.Approved ? $"{request} approved." : $"{request} rejected."; }}Sending a signal
Section titled “Sending a signal”Send through the handle you got when starting the workflow, or through the client with a run id:
await handle.SignalAsync( "approval", new ApprovalDecision(Approved: true, UserId: "era", Comment: "Ship it"), idempotencyKey: $"approval:{handle.WorkflowRunId:N}");More commonly the signal comes from a different process, like an HTTP endpoint handling the approval click. Any process with the run id can signal through the client:
app.MapPost( "/approvals/{runId:guid}", async (Guid runId, ApprovalDecision decision, IPgWorkflowClient workflows) => { await workflows.SignalAsync(runId, "approval", decision); return Results.Accepted(); });Delivery semantics
Section titled “Delivery semantics”Every signal is a persisted row in Postgres:
- Buffered. Delivering before the workflow reaches its
WaitForSignalis fine; the payload waits in Postgres until the workflow asks for it. - FIFO per name. Multiple signals with the same name are consumed in delivery order,
one per
WaitForSignalcall. - Idempotent with a key. Pass an
idempotencyKeyso a retried sender doesn’t deliver the same signal twice; a redelivery with a known key buffers nothing. - Terminal runs reject signals. Signalling a run that already succeeded or failed throws, so a lost decision surfaces at the sender.
- Parking races are closed transactionally. Signal delivery and parking serialize through the workflow-run row. Parking rechecks for a buffered matching signal, so delivery immediately before the wait is recorded cannot leave the run asleep forever.
Like ctx.Sleep, waiting parks the run by throwing an internal control-flow exception,
so don’t wrap ctx.WaitForSignal in a broad catch that would swallow the park.