Skip to content

Commit 44dd5cf

Browse files
committed
Fix duplicate activity cancellation commands
1 parent f937aed commit 44dd5cf

3 files changed

Lines changed: 108 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ to include examples, links to docs, or any other relevant information.
4040

4141
### Fixed
4242

43+
- Cancelling an activity from a signal while the workflow itself is cancelled
44+
no longer causes a nondeterminism error from duplicate activity-cancellation
45+
commands.
4346
- `StrandsPlugin` now disables Botocore retries for its default Bedrock model so
4447
model request retries are handled exclusively by Temporal.
4548
- `temporalio.contrib.openai_agents` now honors the `retry-after-ms` and

temporalio/worker/_workflow_instance.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2035,7 +2035,7 @@ async def run_activity() -> Any:
20352035
try:
20362036
return await self._await_temporal_operation(
20372037
handle._result_fut,
2038-
lambda _err, command: handle._apply_cancel_command(command),
2038+
lambda _err: handle._request_cancel(),
20392039
completed_cancellation_flag=_WorkflowLogicFlag.RAISE_ON_CANCELLING_COMPLETED_ACTIVITY,
20402040
)
20412041
except _ActivityDoBackoffError as err:
@@ -2106,8 +2106,8 @@ async def _outbound_start_child_workflow(
21062106
# Common code for handling cancel for start and run
21072107
def apply_child_cancel_error(
21082108
err: asyncio.CancelledError,
2109-
cancel_command: temporalio.bridge.proto.workflow_commands.WorkflowCommand,
21102109
) -> None:
2110+
cancel_command = self._add_command()
21112111
# Send a cancel request to the child, forwarding the msg passed to
21122112
# Task.cancel(msg) (if any) as the cancellation reason.
21132113
reason = err.args[0] if err.args and isinstance(err.args[0], str) else ""
@@ -2171,7 +2171,7 @@ async def operation_handle_fn() -> OutputT:
21712171
OutputT,
21722172
await self._await_temporal_operation(
21732173
handle._result_fut,
2174-
lambda _err, command: handle._apply_cancel_command(command),
2174+
lambda _err: handle._apply_cancel_command(self._add_command()),
21752175
),
21762176
)
21772177

@@ -2194,7 +2194,7 @@ async def operation_handle_fn() -> OutputT:
21942194

21952195
await self._await_temporal_operation(
21962196
handle._start_fut,
2197-
lambda _err, command: handle._apply_cancel_command(command),
2197+
lambda _err: handle._apply_cancel_command(self._add_command()),
21982198
reraise_on_workflow_cancellation=True,
21992199
)
22002200
return handle
@@ -2252,10 +2252,7 @@ async def _await_temporal_operation(
22522252
self,
22532253
fut: asyncio.Future[_T],
22542254
apply_cancel: Callable[
2255-
[
2256-
asyncio.CancelledError,
2257-
temporalio.bridge.proto.workflow_commands.WorkflowCommand,
2258-
],
2255+
[asyncio.CancelledError],
22592256
None,
22602257
],
22612258
*,
@@ -2283,7 +2280,7 @@ async def _await_temporal_operation(
22832280
)
22842281
raise
22852282

2286-
apply_cancel(err, self._add_command())
2283+
apply_cancel(err)
22872284

22882285
# Clear the cancellation counter on Python 3.11+ so the next
22892286
# await does not immediately re-raise CancelledError.
@@ -2798,8 +2795,8 @@ async def _signal_external_workflow(
27982795

27992796
def apply_cancel(
28002797
_err: asyncio.CancelledError,
2801-
command: temporalio.bridge.proto.workflow_commands.WorkflowCommand,
28022798
) -> None:
2799+
command = self._add_command()
28032800
command.cancel_signal_workflow.seq = seq
28042801

28052802
# Wait until completed or cancelled
@@ -3281,6 +3278,7 @@ def __init__(
32813278
self._input = input
32823279
self._result_fut = instance.create_future()
32833280
self._started = False
3281+
self._cancel_command_seq: int | None = None
32843282
instance._register_task(self, name=f"activity: {input.activity}")
32853283
self._payload_converter = self._instance._payload_converter_with_context(
32863284
temporalio.converter.ActivitySerializationContext(
@@ -3307,9 +3305,15 @@ def cancel(self, msg: Any | None = None) -> bool:
33073305
# to send a cancel command because the async function won't run to trap
33083306
# the cancel (i.e. cancelled before started)
33093307
if not self._started and not self.done():
3310-
self._apply_cancel_command(self._instance._add_command())
3308+
self._request_cancel()
33113309
return super().cancel(msg)
33123310

3311+
def _request_cancel(self) -> None:
3312+
if self._cancel_command_seq == self._seq:
3313+
return
3314+
self._cancel_command_seq = self._seq
3315+
self._apply_cancel_command(self._instance._add_command())
3316+
33133317
def _resolve_success(self, result: Any) -> None:
33143318
# We intentionally let this error if already done
33153319
self._result_fut.set_result(result)

tests/worker/test_workflow.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,96 @@ async def activity_result() -> str:
10451045
await activity_inst.wait_cancel_complete.wait()
10461046

10471047

1048+
@workflow.defn
1049+
class CancelActivityDuringWorkflowCancellationWorkflow:
1050+
def __init__(self) -> None:
1051+
self._activity_started = False
1052+
self._cancel_activity = False
1053+
1054+
@workflow.run
1055+
async def run(self) -> str:
1056+
handle = workflow.start_activity(
1057+
wait_cancel,
1058+
start_to_close_timeout=timedelta(minutes=1),
1059+
heartbeat_timeout=timedelta(seconds=1),
1060+
)
1061+
self._activity_started = True
1062+
1063+
async def cancel_activity() -> None:
1064+
await workflow.wait_condition(lambda: self._cancel_activity)
1065+
handle.cancel()
1066+
1067+
cancel_task = asyncio.create_task(cancel_activity())
1068+
try:
1069+
await handle
1070+
except ActivityError:
1071+
pass
1072+
finally:
1073+
cancel_task.cancel()
1074+
return "activity cancelled"
1075+
1076+
@workflow.signal
1077+
def cancel_activity(self) -> None:
1078+
self._cancel_activity = True
1079+
1080+
@workflow.query
1081+
def activity_started(self) -> bool:
1082+
return self._activity_started
1083+
1084+
1085+
async def test_workflow_cancel_activity_while_workflow_cancelled(client: Client):
1086+
task_queue = str(uuid.uuid4())
1087+
runner = CustomWorkflowRunner()
1088+
handle = await client.start_workflow(
1089+
CancelActivityDuringWorkflowCancellationWorkflow.run,
1090+
id=f"workflow-{uuid.uuid4()}",
1091+
task_queue=task_queue,
1092+
)
1093+
1094+
async with new_worker(client, activities=[wait_cancel], task_queue=task_queue):
1095+
async with new_worker(
1096+
client,
1097+
CancelActivityDuringWorkflowCancellationWorkflow,
1098+
task_queue=task_queue,
1099+
workflow_runner=runner,
1100+
max_cached_workflows=0,
1101+
):
1102+
1103+
async def activity_started() -> bool:
1104+
return await handle.query(
1105+
CancelActivityDuringWorkflowCancellationWorkflow.activity_started
1106+
)
1107+
1108+
await assert_eq_eventually(True, activity_started)
1109+
1110+
# Keep the workflow worker offline so the signal and cancellation are
1111+
# delivered in the same activation when it resumes.
1112+
await handle.signal(
1113+
CancelActivityDuringWorkflowCancellationWorkflow.cancel_activity
1114+
)
1115+
await handle.cancel()
1116+
1117+
async with new_worker(
1118+
client,
1119+
CancelActivityDuringWorkflowCancellationWorkflow,
1120+
task_queue=task_queue,
1121+
workflow_runner=runner,
1122+
):
1123+
assert await handle.result() == "activity cancelled"
1124+
1125+
assert not [
1126+
event
1127+
async for event in handle.fetch_history_events()
1128+
if event.HasField("workflow_task_failed_event_attributes")
1129+
]
1130+
assert any(
1131+
{"signal_workflow", "cancel_workflow"}.issubset(
1132+
{job.WhichOneof("variant") for job in activation.jobs}
1133+
)
1134+
for activation, _ in runner._pairs
1135+
)
1136+
1137+
10481138
@workflow.defn
10491139
class SimpleChildWorkflow:
10501140
@workflow.run

0 commit comments

Comments
 (0)