An onboarding run can send mail immediately, then wait days for the user to confirm. A signal carries that external confirmation into the run. Durable drivers retain accepted signals and waits in engine state; the memory driver keeps them only while its process lives.
These examples use arkoren.dev/core/service/workflow and the overview’s
User and SendWelcome. Client-side sending is
covered by Signaling Runs.
Declaring Signals
Declare a shared name and payload type for senders and waiters:
var EmailConfirmed = workflow.NewSignal[string]("email-confirmed")
The signal carries a string token. A sender and waiter using the same name with incompatible types is an application error. Names follow the workflow identifier rule: 1–64 lowercase letters, digits, or inner hyphens. An invalid signal name panics at declaration, so declare signals during startup rather than from input.
A signal resolves once per run. The first value is remembered: later Wait or Select calls for that name return it immediately, even if more values were sent. Signals accepted before the workflow reaches its wait are retained too. This is not a message stream; use separate runs or another messaging capability when every event needs processing. Workflow signals are also distinct from the payload-less signals on the event bus.
Awaiting Confirmation
Replace the overview’s short workflow body with this function when confirmation
belongs to the process. The function uses errors and time from the standard
library:
func onboard(c *workflow.Context, user User) (string, error) {
if _, err := c.Step(SendWelcome, user); err != nil {
return "", err
}
token, err := c.Wait(EmailConfirmed, workflow.Timeout(72*time.Hour))
if errors.Is(err, workflow.ErrTimeout) && c.Err() == nil {
return "expired", nil
}
return token, err
}
Publish that function under the same definition shape, retaining a maximum that exceeds the confirmation wait:
var Onboard = workflow.New("onboard", onboard, func(definition *workflow.Builder) {
definition.MaxDuration(96 * time.Hour)
})
After the step succeeds, a confirmation returns its token. If only the 72-hour
wait expires, the result is "expired". The c.Err() == nil check prevents a
whole-run timeout from being mistaken for that ordinary expiry branch: both
use workflow.ErrTimeout. Cancellation or a run limit still ends the operation.
No Timeout, zero, or a negative Timeout disables only the per-wait limit. The
run’s MaxDuration and cancellation remain in force. A remembered signal returns
without scheduling another wait timer.
Keep engine history and signal state for the full interval during which the run must recover. Finished-run retention controls later inspection and duplicate protection, not permanent delivery. See Drivers for provider retention and queue limits; the memory driver cannot survive process loss.
Racing Alternatives
If the deadline should trigger another action rather than return a timeout, select between the confirmation and a timer inside your workflow function:
var (
token string
expired bool
)
err := c.Select(
workflow.On(EmailConfirmed, func(value string) {
token = value
}),
workflow.After(72*time.Hour, func() {
expired = true
}),
)
On success, exactly the chosen callback runs. Use expired to choose your next
step, or token to continue confirmation. Handle err before using either;
cancellation, decoding failures, or the whole-run timeout can prevent selection.
Callbacks run synchronously on the coordination path and must follow the same
replay rules as the workflow body. Set local state in
them; perform external effects in subsequent steps.
An already remembered signal wins immediately, in case order if several are
remembered. Otherwise the runtime chooses the signal or timer that fires; do
not infer a portable ordering for simultaneous arrivals. A nonpositive After
delay is immediately eligible, not guaranteed to beat every available signal.
Select without cases returns workflow.ErrNoCases; On and After panic for nil
callbacks.
Pausing Work
When no external message is needed, use a durable sleep instead of time.Sleep:
err := c.Sleep(24 * time.Hour)
An engine can suspend the run during that pause. Sleep returns early when the run is canceled or times out. Memory uses process-local timers instead.
Do not block on your own channel or on <-c.Done() inside coordination code.
Temporal cannot schedule a native Go channel block and reports a deadlock.
Use Sleep, Wait, or Select to block, and inspect c.Err() when you need the run’s
cancellation reason. Error Handling explains how that reason
differs from a caller abandoning a result wait.
For declarations and options, see the
workflow API reference.