Durable waits
Waiting is what a durable engine is for. A workflow can pause for seconds or weeks, and AutoFlow keeps it alive across restarts without keeping anything running. This guide covers waiting for time and the limits around it; waiting for a result is Futures and gather.
Durations
A duration is either a multiple of a time constant, such as 5 * time.second or 2 * time.hour, or a string in Go syntax like "90s" where a duration is expected. A bare integer is rejected: sleep(5) fails, sleep(5 * time.second) does not.
time.now() returns the workflow’s time, taken from its history, not the wall clock. It advances as results are consumed, so it reads the same on every replay.
sleep
sleep(duration) blocks the workflow for the duration and returns None.
def main(w):
print("pausing")
sleep(10 * time.minute)
print("ten minutes later, in workflow time")timer
timer(duration) returns a future that resolves to None when the duration elapses. Because it does not block, the workflow can do other work first and wait later with gather.
load("module:gitlab", "call_api")
def main(w, project_id, token):
cooldown = timer(15 * time.minute)
status, _, _, err = gather(call_api(
"GET",
"/api/v4/projects/%d" % project_id,
headers = {"Authorization": "Bearer " + token},
))
gather(cooldown)
return statusgather on a timer returns None once it fires; with a timeout shorter than the timer it returns timeout_value instead. Gathering in general, with lists, timeouts and channels, is covered in Futures and gather.
select with a timeout
select(cases, timeout=None, timeout_value=None) waits until one of several futures or channels is ready and returns the name of that case, or timeout_value once the timeout fires. Combined with a timer, it expresses a reminder or an escalation while a longer wait continues:
def main(w, reply):
reminder = timer(30 * time.minute)
ready = select(
{"reply": reply, "reminder": reminder},
timeout = 2 * time.hour,
timeout_value = "gave up",
)
if ready == "reply":
return gather(reply)
if ready == "reminder":
print("still waiting after 30 minutes")
return gather(reply, timeout = 90 * time.minute)
fail("no answer after two hours")When several cases are ready in the same round, select picks one deterministically, but which one is unspecified, so do not encode priority in the order of the dict. A timeout never wins over a case that is ready. select consumes nothing, so the winning case is still gathered afterwards.
Why waiting is free
Every operation that leaves the interpreter is recorded in the workflow’s history. When a workflow blocks on a timer, a channel or an action, the workflow goroutine is suspended, and after about a minute of inactivity it is evicted entirely. Nothing about the workflow stays in memory: no goroutine, no connection, no interpreter state.
A timer is a history event plus a row in the workflow database’s scheduled-task table. When it comes due, AutoFlow loads the history, replays the workflow up to the point where it blocked, and lets it continue. A value sent into a channel is stored the same way and promoted into history when the workflow next runs.
So a workflow that waits three days for an approval costs a few rows for three days. Ten thousand of them cost ten thousand times a few rows, not ten thousand goroutines.
Limits
| Limit | Value |
|---|---|
Workflow lifetime (schedule_to_complete_timeout) | 30 days by default, 60 days at most; the workflow ends TIMED_OUT |
| Data retention | 90 days, always on; it exceeds the longest possible workflow by design |
| Values received over all channels of one workflow | 10,000; later sends succeed but the values are dropped |
| Single channel value | 64 KiB marshaled |
Design a workflow definition so that no single wait, and no whole workflow, needs more than the lifetime the caller set. Child workflows inherit that deadline, so a process that must run longer returns and lets the caller start a new workflow.
CancelWorkflow only records the request; a blocked workflow observes it when it next awaits, and a workflow that never awaits again never observes it.