Lab 7: Cancellation and Compensation
Objective
Why: Make a multi-step agent undo completed effects when a later step fails.
By the end: Observe the compensation order after a crash and test how cancellation reaches a running Activity.
Before you run
- Mechanism: A saga stores enough information to compensate completed effects in reverse order. Cancellation is a request that Workflow code handles; an Activity notices it through heartbeats.
- Predict: Predict which undo runs first when step four fails, and whether a non-heartbeating Activity stops immediately on cancel.
- Look for: Read the compensation order in history, then compare cancel with terminate and observe whether cleanup actually ran.
- Terminal route: From this lab directory, run
source ../.venv/bin/activatebefore copying anypython ...command below. The browser action cards select the Python environment for you.
Same program as lab 6 plus, in workflows.py: gpu_lane() (four Activities, each success
appending its undo to self.compensations), rollback() (the list in reverse, run from finally),
and a crunch tool that is deliberately long. Reading page: "Cancellation and Compensation".
Open this lab in the browser workspace
Complete the one-time setup in Lab 1, then open
Lab 07 on your local lab server (use the port
lab_server.py printed if it is not 3000). The Temporal server, Agent Runtime, and lab
server can stay running between labs.
Click Prepare this lab, then use this lab's action cards to start Workers and
clients, follow Output, and crash or stop processes. The page selects the lab directory, Python
environment, and Task Queue for you. For a variation from the exercise, paste one
python ... command into the command field. The instructions below also describe the
terminal route if you prefer it.
The workspace adds a short suffix to this lab's Task Queue, and in some labs to its Workflow ID
(lab-05-3f2a and agent-42-3f2a rather than lab-05 and
agent-42), so a Worker or run left over from another tab cannot interfere. Read the
names in the text below with that suffix; the Output panel shows the real ones.
Goal: make AgentRun undo what it did when step four of four fails, prove the undo list survives a
Worker death, and then find out what handle.cancel() does — and does not do — to an Activity
that is already running.
Setup
cd 07-cancellation-and-sagas # from the temporal-course-labs folder
export TASK_QUEUE=lab-07
python worker.py # terminal 1 — the Activities narrate what they do; read this terminal
python starter.py # terminal 2 — starts agent-42 with the deploy_model tool
starter.py --tool crunch --cancel-after 4 [--heartbeat] [--shield] is 7.3. --id/WORKFLOW_ID
if agent-42 is taken. The starter waits for the run to close, then prints the annotated history.
7.1 The saga: register_endpoint fails, three undos run in reverse
plan() turns a goal that starts with "deploy" into the deploy_model tool, and gpu_lane() runs
reserve_gpu → allocate_sandbox → start_model → register_endpoint. The last one raises an
ApplicationError every time; its RetryPolicy(maximum_attempts=3) ends it. Run the starter
(the status() lines quoted in this lab are trimmed to the keys each step is about):
execution closed with ActivityError: Activity task failed
close status: FAILED
final status(): {'compensations': [], 'rolled_back': ['stop_model', 'release_sandbox', 'release_gpu'], 'status': 'failed', 'step': 1}
11 ACTIVITY_TASK_SCHEDULED reserve_gpu
13 ACTIVITY_TASK_COMPLETED (scheduled by event 11)
17 ACTIVITY_TASK_SCHEDULED allocate_sandbox
19 ACTIVITY_TASK_COMPLETED (scheduled by event 17)
23 ACTIVITY_TASK_SCHEDULED start_model
25 ACTIVITY_TASK_COMPLETED (scheduled by event 23)
29 ACTIVITY_TASK_SCHEDULED register_endpoint
30 ACTIVITY_TASK_STARTED attempt=3
31 ACTIVITY_TASK_FAILED endpoint registry rejected the name for model-in-sandbox-on-gpu-for-agent-42
35 ACTIVITY_TASK_SCHEDULED stop_model
41 ACTIVITY_TASK_SCHEDULED release_sandbox
47 ACTIVITY_TASK_SCHEDULED release_gpu
53 WORKFLOW_EXECUTION_FAILED Activity task failed
Read it against the code:
- Three successes, one
ACTIVITY_TASK_FAILED. Attempts 1 and 2 are not Events —ACTIVITY_TASK_STARTEDis written only when the Activity closes, and it saysattempt=3. The Worker terminal has all three:register_endpoint attempt 1 ... rejected,attempt 2,attempt 3. Retry was configuration; nothing in the Workflow ran between attempts. - Then the undos, in reverse:
stop_model,release_sandbox,release_gpu. Nounregister_endpoint— it was never appended, because its forward step never succeeded. That is the whole Saga: append after success, run backwards on failure. - The run closed Failed with the original
ActivityError, after the rollback.rollback()runs fromfinally, which is why it also runs for a cancel (7.3). - The undos take their identifiers from what the forward steps returned (
gpu-for-agent-42,sandbox-on-gpu-for-agent-42) — the key a second attempt ofrelease_gpuwould reuse.
7.2 Kill the Worker during the rollback
Each undo takes 3 s. Start a fresh run and watch the Worker terminal; when it prints
release_sandbox ...: attempt 1, starting (the second undo), kill -9 the Worker. In the browser
workspace, whose Output refreshes every 3 s — as long as one undo — click Crash Worker as soon as
stop_model ... done appears; waiting for the release_sandbox line usually means missing it. Wait ten seconds
— nothing moves; the UI shows release_sandbox as a pending Activity of a Running Workflow — then
start the Worker again. From the run we recorded (old pid 3800398, new pid 3800679):
old Worker: 09:01:25 stop_model model-in-sandbox-on-gpu-for-agent-42: attempt 1, starting
09:01:28 stop_model ...: done
09:01:28 release_sandbox sandbox-on-gpu-for-agent-42: attempt 1, starting <- kill -9 here
new Worker: 09:01:39 release_sandbox sandbox-on-gpu-for-agent-42: attempt 2, starting
09:01:42 release_sandbox ...: done
09:01:42 release_gpu gpu-for-agent-42: attempt 1, starting
09:01:45 release_gpu ...: done
35 ACTIVITY_TASK_SCHEDULED stop_model
37 ACTIVITY_TASK_COMPLETED (scheduled by event 35)
41 ACTIVITY_TASK_SCHEDULED release_sandbox
42 ACTIVITY_TASK_STARTED attempt=2
43 ACTIVITY_TASK_COMPLETED (scheduled by event 41)
45 WORKFLOW_TASK_STARTED identity=3800679@...
47 ACTIVITY_TASK_SCHEDULED release_gpu
53 WORKFLOW_EXECUTION_FAILED Activity task failed
What happened: the new Worker replayed the history and rebuilt self.compensations from the three
recorded successes, then resumed rollback() at the first undo with no result in history —
release_sandbox, whose Event 41 already existed. It did not re-run stop_model; that result was
in Event 37. The eleven-second gap is STEP_TIMEOUT (10 s: the Service can only learn the first
attempt died when its Start-To-Close expires) plus the 1 s retry interval, and attempt=2 is the
at-least-once execution the reading warned about: release_sandbox must tolerate running twice.
A saga does not guarantee rollback.
rollback()is ordinary durable work: each undo is an Activity with its own retry policy, and an undo that exhausts its retries raises out ofrollback()— which this Workflow calls fromfinally. Two consequences worth designing for before you ship one. The undos after the failing one never run (release_gpuis still owed whilerelease_sandboxis stuck), and the exception from the rollback replaces the forward failure that caused it, so the reason you were rolling back is the one you lose. A production saga keeps both failures, retries an undo far longer than a forward step, records "compensation incomplete" as a first-class state an operator can list, and lets the independent undos proceed rather than stopping at the first one that will not. Lab 11's capstone asks you to decide this forAgentRun.
7.3 The nasty one: cancel a tool that does not heartbeat
The crunch tool runs 20 chunks of one second inside a sandbox (allocate_sandbox first, so there
is one undo on the list). --cancel-after 4 makes the starter call handle.cancel() four seconds
in. Three runs, and read the Worker terminal as much as the history each time.
a. No heartbeats: python starter.py --tool crunch --cancel-after 4
status before cancel: {'compensations': ['release_sandbox'], 'in_flight': 'crunch', 'status': 'running', 'step': 1}
handle.cancel() sent — a WorkflowExecutionCancelRequested Event; the Workflow sees it at its next await
execution closed with CancelledError: Workflow cancelled
close status: CANCELED
final status(): {'compensations': [], 'in_flight': 'crunch', 'rolled_back': ['release_sandbox'], 'status': 'cancelled', 'step': 1}
17 ACTIVITY_TASK_SCHEDULED execute_tool
18 WORKFLOW_EXECUTION_CANCEL_REQUESTED
22 ACTIVITY_TASK_CANCEL_REQUESTED (scheduled by event 17)
23 ACTIVITY_TASK_SCHEDULED release_sandbox
25 ACTIVITY_TASK_COMPLETED (scheduled by event 23)
29 WORKFLOW_EXECUTION_CANCELED
The Workflow did everything right: it saw the request at its next await (the tool), asked the
Service to cancel the Activity (22), released the sandbox from finally (23–25) — no shield, the
one cancellation had already been delivered — and closed Canceled. Now the Worker terminal:
09:02:27 execute_tool crunch: chunk 1/20
09:02:28 execute_tool crunch: chunk 2/20
09:02:29 release_sandbox sandbox-on-agent-42:1:crunch: attempt 1, starting
09:02:29 execute_tool crunch: chunk 3/20
...
09:02:32 release_sandbox ...: done
09:02:32 execute_tool crunch: chunk 6/20
...
09:02:46 execute_tool crunch: chunk 20/20
09:02:46 execute_tool crunch: finished all 20 chunks
WARN temporalio_sdk_core::worker::activities: Activity not found on completion. This may happen if the
activity has already been cancelled but completed anyway. ... "workflow execution already completed"
The tool ran all twenty chunks after the cancel, past the sandbox it was running in being
released, and its result was thrown away. There is no ACTIVITY_TASK_CANCELED in history because
nothing ever told the Activity: it never spoke to the Service while it ran, and cancellation only
travels on heartbeat responses. You paid for sixteen seconds of a tool nobody wanted.
b. Heartbeats: python starter.py --tool crunch --cancel-after 4 --heartbeat
The diff is two lines: activity.heartbeat(i) in the chunk loop, and heartbeat_timeout=3 s on
the call (which also sets the heartbeat send interval to 0.8 × 3 s).
18 WORKFLOW_EXECUTION_CANCEL_REQUESTED
22 ACTIVITY_TASK_CANCEL_REQUESTED (scheduled by event 17)
23 ACTIVITY_TASK_SCHEDULED release_sandbox
25 ACTIVITY_TASK_CANCELED (scheduled by event 17)
30 ACTIVITY_TASK_COMPLETED (scheduled by event 23)
34 WORKFLOW_EXECUTION_CANCELED
09:10:02 release_sandbox ...: attempt 1, starting
09:10:04 execute_tool crunch: chunk 4/20
09:10:05 execute_tool crunch: cancelled at chunk 5/20
09:10:05 release_sandbox ...: done
Now there is an ACTIVITY_TASK_CANCELED (25): the request rode back on a heartbeat, the SDK
raised CancelledError inside the Activity at its next await, the except logged and
re-raised. The tool stopped two to three seconds after the cancel — within one heartbeat interval — and
its acknowledgement landed while the rollback was still running. Same Workflow code as (a).
c. Shield the in-flight call: python starter.py --tool crunch --cancel-after 4 --heartbeat --shield
cpu_lane() now awaits asyncio.shield(tool) and, on CancelledError, awaits the tool handle
itself before letting the cancel through.
17 ACTIVITY_TASK_SCHEDULED execute_tool
18 WORKFLOW_EXECUTION_CANCEL_REQUESTED
22 ACTIVITY_TASK_STARTED attempt=1
23 ACTIVITY_TASK_COMPLETED (scheduled by event 17)
27 ACTIVITY_TASK_SCHEDULED release_sandbox
29 ACTIVITY_TASK_COMPLETED (scheduled by event 27)
33 WORKFLOW_EXECUTION_CANCELED
09:09:49 execute_tool crunch: finished all 20 chunks
09:09:49 release_sandbox ...: attempt 1, starting
No ACTIVITY_TASK_CANCEL_REQUESTED at all: the cancel reached the await, not the Activity. The
tool completed (23) sixteen seconds after the request (18), then the sandbox was released,
then the run closed Canceled. Compare the three histories and say which one you want for a
40-minute fine-tune that is 90% done, and which for a runaway web crawl.
Two details in run() worth reading before you move on. The cancel that lands while an Activity
is awaited arrives as ActivityError whose cause is CancelledError — the in-flight Activity's
own cancellation — not as a bare asyncio.CancelledError; run() classifies both as
"cancelled", and re-raising either closes the execution as Canceled. And the shield awaits the
handle (workflow.start_activity(...)), never the cancelled outer future a second time — that
one raises CancelledError immediately, which is the mistake our first draft made.
7.3b The three modes, side by side
| Activity gets a cancel request? | Workflow waits for the tool? | What happens to the tool | |
|---|---|---|---|
| No heartbeats | yes | no | runs to the end, never learns |
| Heartbeats | yes | no | sees the request on a heartbeat response, raises |
asyncio.shield |
no | yes, deliberately | finishes normally |
So "does the tool stop?" is not one question about heartbeats. It is two: does the Workflow ask for the Activity to be cancelled at all, and — if it does — does the Activity ever come back to the Service to hear the answer? Shielding answers the first no; a tool with no heartbeat answers the second no.
7.4 Cancel is cooperative. Terminate is not.
Run the crunch case once more, and this time do not cancel it. Terminate it:
python starter.py --tool crunch --no-wait # terminal 2
docker compose exec -T temporal temporal workflow terminate --workflow-id agent-42 --reason "operator hard stop"
(The temporal CLI lives in the dev server's container. In the browser workspace, terminate from
the Python console instead: from common import connect, then
await (await connect()).get_workflow_handle("agent-42").terminate(reason="operator hard stop"),
or press Terminate on the run in the Web UI.)
Measured on the labs' dev server (Temporal Server 1.31.2, 2026-09), the history simply stops:
17 ACTIVITY_TASK_SCHEDULED execute_tool
18 WORKFLOW_EXECUTION_TERMINATED
Compare that with 7.3a's cancel, which ran to WORKFLOW_EXECUTION_CANCELED at event 29 through
ACTIVITY_TASK_CANCEL_REQUESTED (22) and release_sandbox (23–25). Under terminate there is no
cancel request, no finally, no rollback — event 18 is the last thing that will ever be written
to this Workflow. Your Workflow code never runs again, so it cannot clean up; self.compensations
still listed release_sandbox and nothing released it.
Then read the Worker terminal: execute_tool crunch: chunk 6/20, 7/20, and on. The Activity is a
process on a Worker, and nobody told it anything. The sandbox stays allocated until something
outside this Workflow reclaims it.
- Cancel when the Workflow should clean up after itself: it is a request the code observes at its
next await, and everything in this lab's
finallydepends on that. - Terminate when you need it stopped now and accept that nothing cooperative happens — then go and reclaim the resources yourself. For an agent runtime holding GPUs, that is an operational decision, not a keyboard shortcut.
The recurring question
If every Worker process dies right now: self.compensations is Workflow state, so a returning
Worker rebuilds it from the recorded successes and resumes the rollback at the first undo without a
result (7.2). A cancel request already in history is delivered once, at the first await after
replay — it is not lost with the Worker. The Activity that was in flight is a Worker-side process:
without heartbeats the Service only notices it is gone when Start-To-Close expires; with them, one
Heartbeat Timeout later. Keep that apart from cancellation delivery: a cancel request is not
waiting on a timeout at all — it rides back on the next heartbeat round-trip, which is why a tool
that never heartbeats never hears it. Its effect may have happened either way, which is why every
undo is keyed.
Check yourself
python -m pytest tests/ -q — the reverse-order rollback with no unregister_endpoint; a cancel
without heartbeats leaving ACTIVITY_TASK_CANCEL_REQUESTED but no ACTIVITY_TASK_CANCELED while
the tool finishes every chunk on the Worker; a cancel with heartbeats producing ACTIVITY_TASK_CANCELED
and a tool that never finishes; and the shielded tool completing before release_sandbox runs.
What you learned
- A saga records compensation decisions in Workflow state and runs completed undos in reverse order.
- Activity cancellation is cooperative; a non-heartbeating tool may keep running after a cancel request.
- Shielding an in-flight effect changes when compensation begins, and every undo still needs an idempotency key.
Questions to answer
- Why is there no
unregister_endpointcompensation after registration fails? - After killing the Worker during rollback, which recorded facts let a new Worker continue the undo list?
- Why can a cancel request appear in history while the Activity continues to do work?
- What difference do heartbeats and shielding make to the final effect and compensation order?
Files
Every file in this lab, as committed. Copy or clone; nothing here is hidden.
07-cancellation-and-sagas/starter.py
"""Start AgentRun with one tool call, optionally cancel it, wait, then print the annotated history. python starter.py # 7.1 / 7.2: goal "deploy ..." -> the GPU saga; register_endpoint fails python starter.py --tool crunch --cancel-after 4 # 7.3: a 20 s tool with no heartbeat; cancel while it runs python starter.py --tool crunch --cancel-after 4 --heartbeat python starter.py --tool crunch --cancel-after 4 --heartbeat --shield """ import argparse import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import TASK_QUEUE, connect, start_workflow # noqa: E402 from temporalio.api.enums.v1 import EventType # noqa: E402 from temporalio.client import Client, WorkflowFailureError # noqa: E402 from workflows import AgentRun, ToolOptions # noqa: E402 async def history(client: Client, workflow_id: str) -> None: handle = client.get_workflow_handle(workflow_id) async for ev in handle.fetch_history_events(): name = EventType.Name(ev.event_type).removeprefix("EVENT_TYPE_") detail = "" if ev.HasField("activity_task_scheduled_event_attributes"): detail = ev.activity_task_scheduled_event_attributes.activity_type.name elif ev.HasField("activity_task_started_event_attributes"): detail = f"attempt={ev.activity_task_started_event_attributes.attempt}" elif ev.HasField("activity_task_failed_event_attributes"): detail = ev.activity_task_failed_event_attributes.failure.message elif ev.HasField("activity_task_timed_out_event_attributes"): detail = ev.activity_task_timed_out_event_attributes.failure.message elif ev.HasField("workflow_task_started_event_attributes"): detail = f"identity={ev.workflow_task_started_event_attributes.identity}" elif ev.HasField("workflow_execution_failed_event_attributes"): detail = ev.workflow_execution_failed_event_attributes.failure.message which = ev.WhichOneof("attributes") if which in ("activity_task_completed_event_attributes", "activity_task_canceled_event_attributes", "activity_task_failed_event_attributes", "activity_task_timed_out_event_attributes", "activity_task_cancel_requested_event_attributes"): detail = f"{detail} (scheduled by event {getattr(ev, which).scheduled_event_id})".strip() print(f"{ev.event_id:4d} {name:38s} {detail}") async def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--id", default=os.environ.get("WORKFLOW_ID", "agent-42")) ap.add_argument("--tool", choices=["deploy_model", "crunch"], default="deploy_model") ap.add_argument("--heartbeat", action="store_true", help="execute_tool heartbeats every chunk") ap.add_argument("--shield", action="store_true", help="AgentRun shields the in-flight execute_tool") ap.add_argument("--chunks", type=int, default=20, help="length of the crunch tool in seconds") ap.add_argument("--cancel-after", type=float, default=0, help="seconds after start to call handle.cancel()") ap.add_argument("--no-wait", action="store_true") args = ap.parse_args() goal = "deploy the fine-tuned model" if args.tool == "deploy_model" else "crunch the eval set" options = ToolOptions(heartbeat=args.heartbeat, shield=args.shield, chunks=args.chunks) client = await connect() handle = await start_workflow(client, AgentRun.run, args=[goal, options], id=args.id, task_queue=TASK_QUEUE) print(f"started {handle.id} run={handle.result_run_id} tool={args.tool} options={options}") print(f"open http://localhost:8233/namespaces/default/workflows/{handle.id}") if args.no_wait: return if args.cancel_after > 0: await asyncio.sleep(args.cancel_after) print(f"status before cancel: {await handle.query(AgentRun.status)}") await handle.cancel() print("handle.cancel() sent — a WorkflowExecutionCancelRequested Event; the Workflow sees it at its next await") try: print("result:", await handle.result()) except WorkflowFailureError as e: print(f"execution closed with {type(e.cause).__name__}: {e.cause}") desc = await handle.describe() print(f"close status: {desc.status.name}") print(f"final status(): {await handle.query(AgentRun.status)}") print("\nhistory:") await history(client, handle.id) if __name__ == "__main__": asyncio.run(main())
07-cancellation-and-sagas/tests/test_lab.py
"""Lab 7 self-check against the time-skipping test server: python -m pytest tests/ -q Activities run in real time even under time skipping, so the fake work is scaled down (`workflows.SPEED`) and the crunch tool is a few chunks long. """ from __future__ import annotations import asyncio import os import sys import uuid import pytest HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..")) sys.path.insert(0, os.path.join(HERE, "..", "..")) import workflows # noqa: E402 from temporalio.api.enums.v1 import EventType # noqa: E402 from temporalio.client import WorkflowFailureError # noqa: E402 from temporalio.exceptions import ActivityError, CancelledError # noqa: E402 from temporalio.testing import WorkflowEnvironment # noqa: E402 from temporalio.worker import Worker # noqa: E402 from workflows import ( # noqa: E402 AgentRun, ToolOptions, allocate_sandbox, call_llm, execute_tool, register_endpoint, release_gpu, release_sandbox, reserve_gpu, start_model, stop_model, unregister_endpoint, ) ACTIVITIES = [call_llm, execute_tool, reserve_gpu, allocate_sandbox, start_model, register_endpoint, unregister_endpoint, stop_model, release_sandbox, release_gpu] @pytest.fixture(autouse=True) def fast_activities(monkeypatch): monkeypatch.setattr(workflows, "SPEED", 0.05) # 1 s of fake work -> 50 ms workflows.FINISHED_TOOLS.clear() async def _annotated(handle) -> list[str]: """['ACTIVITY_TASK_SCHEDULED reserve_gpu', 'ACTIVITY_TASK_COMPLETED', ...] in history order.""" out = [] async for ev in handle.fetch_history_events(): name = EventType.Name(ev.event_type).removeprefix("EVENT_TYPE_") if ev.HasField("activity_task_scheduled_event_attributes"): name += " " + ev.activity_task_scheduled_event_attributes.activity_type.name out.append(name) return out def _scheduled(names: list[str]) -> list[str]: return [n.split(" ", 1)[1] for n in names if n.startswith("ACTIVITY_TASK_SCHEDULED ")] def test_saga_runs_compensations_in_reverse_when_register_endpoint_fails() -> None: async def go() -> None: async with await WorkflowEnvironment.start_time_skipping() as env: tq = f"lab-07-test-{uuid.uuid4()}" async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=ACTIVITIES): handle = await env.client.start_workflow( AgentRun.run, args=["deploy the fine-tuned model", ToolOptions()], id=f"lab07-test-{uuid.uuid4()}", task_queue=tq) with pytest.raises(WorkflowFailureError) as exc: await handle.result() assert isinstance(exc.value.cause, ActivityError) # register_endpoint, retries spent assert (await handle.describe()).status.name == "FAILED" names = await _annotated(handle) assert _scheduled(names) == [ "call_llm", "reserve_gpu", "allocate_sandbox", "start_model", "register_endpoint", "stop_model", "release_sandbox", "release_gpu", # reverse order; no unregister_endpoint ] assert names.count("ACTIVITY_TASK_FAILED") == 1 # one failure Event after 3 attempts assert names.index("ACTIVITY_TASK_FAILED") < names.index("ACTIVITY_TASK_SCHEDULED stop_model") status = await handle.query(AgentRun.status) assert status["status"] == "failed" and status["rolled_back"] == ["stop_model", "release_sandbox", "release_gpu"] asyncio.run(go()) def test_cancel_without_heartbeats_the_tool_runs_to_the_end_anyway() -> None: async def go() -> None: async with await WorkflowEnvironment.start_time_skipping() as env: tq = f"lab-07-test-{uuid.uuid4()}" async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=ACTIVITIES): handle = await env.client.start_workflow( AgentRun.run, args=["crunch the eval set", ToolOptions(heartbeat=False, chunks=30)], id=f"lab07-test-{uuid.uuid4()}", task_queue=tq) while (await handle.query(AgentRun.status))["in_flight"] != "crunch": await asyncio.sleep(0.05) await handle.cancel() with pytest.raises(WorkflowFailureError) as exc: await handle.result() assert isinstance(exc.value.cause, CancelledError) assert (await handle.describe()).status.name == "CANCELED" names = await _annotated(handle) assert "ACTIVITY_TASK_CANCEL_REQUESTED" in names assert "ACTIVITY_TASK_CANCELED" not in names # nobody could tell the Activity assert _scheduled(names)[-1] == "release_sandbox" # cleanup ran, unshielded, in `finally` assert (await handle.query(AgentRun.status))["rolled_back"] == ["release_sandbox"] # ... and the tool is still running on the Worker. Wait for it: it finishes every chunk. for _ in range(200): if workflows.FINISHED_TOOLS: break await asyncio.sleep(0.05) assert workflows.FINISHED_TOOLS == [f"{handle.id}:1:crunch"] asyncio.run(go()) def test_cancel_with_heartbeats_stops_the_tool_and_cleanup_still_runs(monkeypatch) -> None: # The cancel rides back on the next heartbeat, up to 0.8 x HEARTBEAT_TIMEOUT later; the run must # still be open then for the acknowledgement to land in *its* history, so the cleanup is slow here. monkeypatch.setattr(workflows, "UNDO_SECONDS", 80) # x SPEED 0.05 = 4 s async def go() -> None: async with await WorkflowEnvironment.start_time_skipping() as env: tq = f"lab-07-test-{uuid.uuid4()}" async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=ACTIVITIES): handle = await env.client.start_workflow( AgentRun.run, args=["crunch the eval set", ToolOptions(heartbeat=True, chunks=200)], id=f"lab07-test-{uuid.uuid4()}", task_queue=tq) while (await handle.query(AgentRun.status))["in_flight"] != "crunch": await asyncio.sleep(0.05) await handle.cancel() with pytest.raises(WorkflowFailureError): await handle.result() assert (await handle.describe()).status.name == "CANCELED" names = await _annotated(handle) assert "ACTIVITY_TASK_CANCEL_REQUESTED" in names assert "ACTIVITY_TASK_CANCELED" in names # the Activity acknowledged the cancel assert _scheduled(names)[-1] == "release_sandbox" assert workflows.FINISHED_TOOLS == [] # it did not run to the end asyncio.run(go()) def test_shielded_tool_finishes_first_then_the_run_closes_cancelled() -> None: async def go() -> None: async with await WorkflowEnvironment.start_time_skipping() as env: tq = f"lab-07-test-{uuid.uuid4()}" async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=ACTIVITIES): handle = await env.client.start_workflow( AgentRun.run, args=["crunch the eval set", ToolOptions(heartbeat=True, shield=True, chunks=30)], id=f"lab07-test-{uuid.uuid4()}", task_queue=tq) while (await handle.query(AgentRun.status))["in_flight"] != "crunch": await asyncio.sleep(0.05) await handle.cancel() with pytest.raises(WorkflowFailureError): await handle.result() assert (await handle.describe()).status.name == "CANCELED" names = await _annotated(handle) assert "ACTIVITY_TASK_CANCEL_REQUESTED" not in names # the shield kept the cancel off the Activity assert names.index("ACTIVITY_TASK_SCHEDULED execute_tool") < names.index("WORKFLOW_EXECUTION_CANCEL_REQUESTED") assert _scheduled(names)[-2:] == ["execute_tool", "release_sandbox"] assert names.count("ACTIVITY_TASK_COMPLETED") == 4 # llm, sandbox, the tool, the release assert workflows.FINISHED_TOOLS == [f"{handle.id}:1:crunch"] asyncio.run(go())
07-cancellation-and-sagas/worker.py
import asyncio import logging import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import TASK_QUEUE, connect # noqa: E402 from temporalio import activity # noqa: E402 from temporalio.worker import Worker # noqa: E402 from workflows import ( # noqa: E402 AgentRun, allocate_sandbox, call_llm, execute_tool, register_endpoint, release_gpu, release_sandbox, reserve_gpu, start_model, stop_model, unregister_endpoint, ) def _one_line_failures(record: logging.LogRecord) -> bool: """register_endpoint fails on purpose, three times; keep the SDK's line, drop its traceback.""" if record.getMessage().startswith("Completing activity as failed"): record.exc_info = None return True async def main() -> None: # The Activities narrate what they do; this lab is read from the Worker's terminal as much as from history. logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S") logging.getLogger("temporalio").setLevel(logging.WARNING) logging.getLogger("temporalio.activity").setLevel(logging.INFO) # our activity.logger lines logging.getLogger("temporalio.activity").addFilter(_one_line_failures) activity.logger.activity_info_on_message = False # no context dict after every line client = await connect() worker = Worker( client, task_queue=TASK_QUEUE, workflows=[AgentRun], activities=[call_llm, execute_tool, reserve_gpu, allocate_sandbox, start_model, register_endpoint, unregister_endpoint, stop_model, release_sandbox, release_gpu], ) print(f"worker pid={os.getpid()} polling {TASK_QUEUE!r}; kill -9 {os.getpid()} to crash it") await worker.run() if __name__ == "__main__": asyncio.run(main())
07-cancellation-and-sagas/workflows.py
"""Lab 7: AgentRun, stage 7 — the GPU saga, and cancellation that actually stops things. Two additions. (1) `execute_tool`'s GPU lane is four Activities orchestrated by Workflow code — `reserve_gpu -> allocate_sandbox -> start_model -> register_endpoint` — and after each success AgentRun appends the matching undo to `self.compensations`. That list is Workflow state: when a later step fails, the undos run in reverse, and a Worker that dies halfway through the rollback is replaced by one that replays the list and finishes it. (2) `handle.cancel()` is Temporal's cancellation, delivered once as `asyncio.CancelledError` at the Workflow's next await. The rollback runs in `finally`, after that delivery, with no shield. What a cancel does to the Activity that was in flight depends on two things: whether the Workflow propagates the cancellation to it at all (7.3's shield does not), and, when it does, whether the Activity heartbeats — the request rides back on a heartbeat response. """ from __future__ import annotations import asyncio import hashlib import os import sys from dataclasses import dataclass, field from datetime import timedelta from temporalio import activity, workflow from temporalio.common import RetryPolicy from temporalio.exceptions import ActivityError, ApplicationError, CancelledError sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import TASK_QUEUE # noqa: E402,F401 SPEED = 1.0 # scale for every fake Activity's sleep (tests shrink it) STEP_TIMEOUT = timedelta(seconds=10) # forward steps and undos; also how long a dead Worker's attempt stays "running" (7.2) FORWARD_RETRY = RetryPolicy(initial_interval=timedelta(seconds=1), maximum_attempts=3) UNDO_RETRY = RetryPolicy(initial_interval=timedelta(seconds=1), maximum_attempts=10) UNDO_SECONDS = 3 # each undo takes this long: the window to kill the Worker in 7.2 TOOL_CHUNKS = 20 # execute_tool works in 1-second chunks HEARTBEAT_TIMEOUT = timedelta(seconds=3) # heartbeats are throttled to 0.8x this, so a cancel lands within ~2.4 s @dataclass class ToolCall: key: str tool: str # "deploy_model" (GPU lane) | "crunch" (CPU lane) args: dict = field(default_factory=dict) @dataclass class ToolResult: key: str output: str @dataclass class ToolOptions: """Lab knobs for 7.3, passed to run() so the Workflow stays deterministic.""" heartbeat: bool = False # execute_tool calls activity.heartbeat() each chunk shield: bool = False # AgentRun wraps the in-flight execute_tool in asyncio.shield chunks: int = TOOL_CHUNKS @dataclass class Step: kind: str # "llm" | "tool" | "finish" prompt: str = "" tool: str = "" async def _work(seconds: float) -> None: await asyncio.sleep(seconds * SPEED) @activity.defn async def call_llm(prompt: str, key: str) -> str: return f"[model answer to: {prompt[:48]}]" # ---- the GPU lane, forward: each step returns the identifier its undo will need ---- @activity.defn async def reserve_gpu(key: str) -> str: await _work(1) gpu = f"gpu-for-{key.split(':')[0]}" # the reservation id; release_gpu is keyed on it activity.logger.info("reserve_gpu -> %s", gpu) return gpu @activity.defn async def allocate_sandbox(gpu: str) -> str: await _work(1) box = f"sandbox-on-{gpu}" activity.logger.info("allocate_sandbox -> %s", box) return box @activity.defn async def start_model(box: str) -> str: await _work(1) model = f"model-in-{box}" activity.logger.info("start_model -> %s", model) return model @activity.defn async def register_endpoint(model: str) -> str: await _work(0.2) activity.logger.info("register_endpoint attempt %d for %s: rejected", activity.info().attempt, model) raise ApplicationError(f"endpoint registry rejected the name for {model}") # retryable; the policy ends it # ---- the GPU lane, reverse: each undo may run more than once ---- # The stable resource id is what *lets* the external system make the undo idempotent; it does not # make it idempotent. Releasing gpu-123 twice is safe only if the allocator treats the second # release as a no-op rather than an error — or, worse, as releasing a gpu-123 that has since been # handed to someone else. These fake Activities have no external state to get wrong. async def _undo(what: str, resource: str) -> None: activity.logger.info("%s %s: attempt %d, starting", what, resource, activity.info().attempt) await _work(UNDO_SECONDS) activity.logger.info("%s %s: done", what, resource) @activity.defn async def unregister_endpoint(endpoint: str) -> None: await _undo("unregister_endpoint", endpoint) @activity.defn async def stop_model(model: str) -> None: await _undo("stop_model", model) @activity.defn async def release_sandbox(box: str) -> None: await _undo("release_sandbox", box) @activity.defn async def release_gpu(gpu: str) -> None: await _undo("release_gpu", gpu) # ---- the CPU lane: one long tool ---- FINISHED_TOOLS: list[str] = [] # in-process marker for the tests: which tool calls ran to the end @activity.defn async def execute_tool(call: ToolCall) -> ToolResult: """A long tool in 1-second chunks. With `heartbeat` off it never talks to the Service while it runs, so nothing can tell it the run was cancelled. With it on, the cancellation rides back on a heartbeat response and the SDK raises CancelledError at the next await.""" chunks = int(call.args.get("chunks", TOOL_CHUNKS)) heartbeat = bool(call.args.get("heartbeat", False)) i = 0 try: for i in range(1, chunks + 1): if heartbeat: activity.heartbeat(i) # progress out, cancellation (if any) back await _work(1) activity.logger.info("execute_tool %s: chunk %d/%d", call.tool, i, chunks) except asyncio.CancelledError: activity.logger.info("execute_tool %s: cancelled at chunk %d/%d", call.tool, i, chunks) raise # re-raise, or the Activity does not appear cancelled FINISHED_TOOLS.append(call.key) activity.logger.info("execute_tool %s: finished all %d chunks", call.tool, chunks) return ToolResult(key=call.key, output=f"{call.tool}: {chunks} chunks done") @workflow.defn class AgentRun: def __init__(self) -> None: self.step = 0 self.status = "running" # running | done | failed | cancelled self.goal = "" self.context_summary = "" self.attempts = 0 self.compensations: list = [] # [(undo_activity, arg)] — Workflow state, so replay rebuilds it self.options = ToolOptions() self.in_flight = "" self.rolled_back: list[str] = [] def plan(self) -> Step: if self.step == 0: return Step("llm", prompt=f"Plan one tool call for: {self.goal}") if self.step == 1: return Step("tool", tool="deploy_model" if self.goal.startswith("deploy") else "crunch") return Step("finish") @workflow.run async def run(self, goal: str, options: ToolOptions | None = None) -> str: self.goal = goal self.options = options or ToolOptions() try: while self.status == "running": step = self.plan() if step.kind == "finish": self.status = "done" break if step.kind == "llm": key = f"{workflow.info().workflow_id}:{self.step}:{hashlib.sha256(step.prompt.encode()).hexdigest()[:12]}" self.context_summary = await workflow.execute_activity( call_llm, args=[step.prompt, key], start_to_close_timeout=timedelta(seconds=10) ) else: call = ToolCall(key=f"{workflow.info().workflow_id}:{self.step}:{step.tool}", tool=step.tool, args={"heartbeat": self.options.heartbeat, "chunks": self.options.chunks}) result = await (self.gpu_lane(call) if step.tool == "deploy_model" else self.cpu_lane(call)) self.context_summary += f"\n{result.output}" self.step += 1 except asyncio.CancelledError: self.status = "cancelled" # Temporal's cancel, delivered once at the await above raise # ... after `finally` has run the rollback except ActivityError as e: if isinstance(e.cause, CancelledError): self.status = "cancelled" # the same cancel, but it landed while an Activity was in else: # flight: it arrives wrapped as that Activity's cancellation self.status = "failed" # a step failed for good (its retries are spent) raise finally: if self.status in ("cancelled", "failed"): await self.rollback() # runs normally: the one cancellation was already delivered return self.context_summary async def gpu_lane(self, call: ToolCall) -> ToolResult: """Four steps, each recorded; the undo list grows with every success.""" self.compensations = [] gpu = await workflow.execute_activity(reserve_gpu, call.key, start_to_close_timeout=STEP_TIMEOUT) self.compensations.append((release_gpu, gpu)) box = await workflow.execute_activity(allocate_sandbox, gpu, start_to_close_timeout=STEP_TIMEOUT) self.compensations.append((release_sandbox, box)) model = await workflow.execute_activity(start_model, box, start_to_close_timeout=STEP_TIMEOUT) self.compensations.append((stop_model, model)) endpoint = await workflow.execute_activity( # fails after FORWARD_RETRY is spent register_endpoint, model, start_to_close_timeout=STEP_TIMEOUT, retry_policy=FORWARD_RETRY ) self.compensations.append((unregister_endpoint, endpoint)) return ToolResult(key=call.key, output=f"deployed at {endpoint}") async def cpu_lane(self, call: ToolCall) -> ToolResult: """A sandbox, then one long tool inside it. The sandbox is the resource a cancel must give back.""" self.compensations = [] box = await workflow.execute_activity(allocate_sandbox, call.key, start_to_close_timeout=STEP_TIMEOUT) self.compensations.append((release_sandbox, box)) self.in_flight = call.tool tool = workflow.start_activity( # a handle: the Activity is scheduled now, awaited below execute_tool, call, start_to_close_timeout=timedelta(seconds=self.options.chunks + 30), heartbeat_timeout=HEARTBEAT_TIMEOUT if self.options.heartbeat else None, retry_policy=RetryPolicy(maximum_attempts=1), ) if self.options.shield: try: result = await asyncio.shield(tool) # the cancel reaches this await, not the Activity except asyncio.CancelledError: result = await tool # let the in-flight work finish, then close as cancelled self.context_summary += f"\n{result.output}" raise else: result = await tool # cancelling this await cancels the Activity too self.in_flight = "" return result async def rollback(self) -> None: """The saga's reverse path. Each undo is an Activity with its own retry policy.""" for undo, arg in reversed(self.compensations): await workflow.execute_activity(undo, arg, start_to_close_timeout=STEP_TIMEOUT, retry_policy=UNDO_RETRY) self.rolled_back.append(undo.__name__) self.compensations = [] @workflow.query def status(self) -> dict: return { "step": self.step, "status": self.status, "in_flight": self.in_flight, "compensations": [undo.__name__ for undo, _ in self.compensations], "rolled_back": self.rolled_back, }
Lab source: labs/07-cancellation-and-sagas in the course repository. Verified against temporal server start-dev with the Python SDK 1.32 on 2026-09-11.