First Steps¶
By the end of this page you will have killed a running workflow with Ctrl-C, restarted it, and
watched the task that had already finished get skipped. That is the whole product in one sitting.
If you have not installed Satay yet, the tutorial index has the one line you need.
Create It¶
Put this in checkout.py:
import asyncio
import sys
import satay
@satay.task()
async def charge(cents: int) -> str:
print(" charge: running for real")
return f"receipt-{cents}"
@satay.task()
async def email_receipt(receipt: str) -> str:
print(" email_receipt: running for real (sleeping 20s, press Ctrl-C now)")
await asyncio.sleep(20)
return f"emailed {receipt}"
@satay.workflow
async def checkout(cents: int) -> str:
receipt = await charge(cents)
return await email_receipt(receipt)
async def main() -> None:
run_id = sys.argv[1] if len(sys.argv) > 1 else None
handle = satay.start(checkout, 1999, run_id=run_id)
print(f"run_id: {handle.run_id}")
print(f"result: {await handle.result()}")
asyncio.run(main())
Three things are doing work.
@satay.task() marks a function whose result is worth recording. Call it from inside a
workflow and it becomes a durable call: the runtime checks the journal first and executes
the body only if there is no recorded result. Call it from anywhere else and it is an ordinary
async function, which is what keeps tasks easy to unit-test.
The parentheses are required
Write @satay.task(), not @satay.task. It is a decorator factory, because it takes
retries, timeout, side_effect, and idempotent. @satay.workflow is the opposite:
it takes no arguments, so write it bare.
@satay.workflow marks the coroutine that orchestrates those calls. Its body is what gets
re-executed on every resume.
satay.start(...) creates or looks up a run and returns a RunHandle. It is a plain
function, not a coroutine, so there is no await on it. Nothing runs until you
await handle.result(). Passing run_id= for an existing unfinished run resumes it instead of
starting a new one.
The twenty-second asyncio.sleep is only there to give you a window to press Ctrl-C in. In a
workflow body it would be a bug, and The Determinism Rule explains why. Here
it is inside a task, where sleeping is fine.
Run It, and Interrupt It¶
$ python checkout.py
run_id: 1d3015a1f1514d13b8aa930f445e7def
charge: running for real
email_receipt: running for real (sleeping 20s, press Ctrl-C now)
^C
Traceback (most recent call last):
...
KeyboardInterrupt
Python dumps a KeyboardInterrupt traceback, which is what you want. The process is gone with
the workflow half done. Copy the run_id off the first line.
A ./.satay/ directory has appeared next to your script. That is the journal: one satay.db
SQLite file in WAL mode, plus a blobs/ directory for large payloads. Set SATAY_DATA_DIR or
pass --data-dir to put it somewhere else.
Resume It¶
Same script, same run id:
$ python checkout.py 1d3015a1f1514d13b8aa930f445e7def
run_id: 1d3015a1f1514d13b8aa930f445e7def
email_receipt: running for real (sleeping 20s, press Ctrl-C now)
result: emailed receipt-1999
charge: running for real is missing, and that absence is the entire feature. The workflow body
ran from its first line again, but when it reached await charge(1999) the engine found a
recorded TaskCompleted at that position and returned the recorded value without calling your
function. email_receipt had no recorded result, so it ran for real, finished this time, and the
run completed.
If a task charges a credit card, this is the difference between charging once and charging twice.
Read the Journal¶
The core CLI prints the timeline as text:
$ satay runs show 1d3015a1f1514d13b8aa930f445e7def
Run 1d3015a1f1514d13b8aa930f445e7def — 10 event(s)
1 2026-07-31T07:45:17.981713+00:00 WorkflowCreated workflow=checkout code_version=src:d76ca379f15909ae
2 2026-07-31T07:45:17.986717+00:00 TaskScheduled task=charge ordinal=0
3 2026-07-31T07:45:17.991449+00:00 TaskAttemptStarted task=charge ordinal=0 attempt=1
4 2026-07-31T07:45:17.995764+00:00 TaskCompleted task=charge ordinal=0
5 2026-07-31T07:45:18.000502+00:00 TaskScheduled task=email_receipt ordinal=0
6 2026-07-31T07:45:18.006617+00:00 TaskAttemptStarted task=email_receipt ordinal=0 attempt=1
⚡ 7 2026-07-31T07:45:20.934322+00:00 WorkflowResumed
8 2026-07-31T07:45:20.945649+00:00 TaskAttemptStarted task=email_receipt ordinal=0 attempt=2
9 2026-07-31T07:45:40.957327+00:00 TaskCompleted task=email_receipt ordinal=0
10 2026-07-31T07:45:40.965280+00:00 WorkflowCompleted
Read it top to bottom and the story is all there.
chargewas scheduled, attempted once, and completed.email_receiptwas scheduled and attempted, and then the log stops. That is where you pressedCtrl-C.- The
⚡marksWorkflowResumed, the event that says a run came back from an interruption rather than waking up gracefully from a timer. email_receiptthen getsattempt=2on the sameordinal=0, because it is the same logical task making a second physical attempt.chargenever appears again.
satay runs show is deliberately small
It renders the events you see above and prints later ones (timers, external events, cancellation, fork) as bare type lines with no payload summary. Studio is the surface that renders everything. See Limits.
Recap¶
You now know the shape of every Satay program:
@satay.task()on the functions whose results should survive a crash, with the parentheses.@satay.workflowon the coroutine that calls them, bare.satay.start(wf, arg)to get a handle,await handle.result()to drive it.- The same
run_idto resume, which replays the body and reuses recorded results. satay runs show <run_id>to see what happened.
Want the same demo without typing it?
The repository ships it as a script, with the crash simulated rather than pressed by hand, so it is repeatable. See Crash recovery in the Cookbook.
Next¶
Concepts explains what ordinal=0 means, why the same task name appearing twice
does not collide, and what the journal is actually storing.