[Bug] TaskGroup cancellation of mixed Temporal operations can break replay
Les mainteneurs répondent en général sous 1 jour
Évaluation
Cette issue n'a pas encore été évaluée.
Description
What are you really trying to do?
Check that a workflow that catches the error of an asyncio.TaskGroup and carries on can still be replayed, for example after a worker restart or a cache eviction.
Describe the bug
All recording workflows in the main reproduction completed successfully; replaying their histories then produced nondeterminism errors. Whether a replay failed depended on what the aborted TaskGroup held (20 histories per row, each replayed 3 times in the same process):
| tasks in the group (two of each kind, plus one task that raises) | histories failing replay | failing replays |
|---|---|---|
| timer + activity + child workflow | 20 of 20 | 60 of 60 |
| timer + activity | 20 of 20 | 48 of 60 |
| timer + child workflow | 19 of 20 | 50 of 60 |
| activity + child workflow | 20 of 20 | 52 of 60 |
| timers only, activities only, or child workflows only | 0 of 20 (each) | 0 of 60 (each) |
The single-kind groups tested here replayed; this does not show that every single-kind group is safe. Example error: [TMPRL1100] Nondeterminism error: Cancel external WF machine does not handle this event: HistoryEvent(id: 21, ActivityTaskCancelRequested).
When a task in an asyncio.TaskGroup raises, TaskGroup._abort() cancels the others by iterating self._tasks, a set of tasks (for t in self._tasks: if not t.done(): t.cancel(), with self._tasks = set() in __init__; the same in CPython 3.11 to 3.14). The workflow event loop creates plain asyncio.Task objects (temporalio/worker/_workflow_instance.py:3139-3152), and on CPython 3.12.3 asyncio.Task.__hash__ is object.__hash__, so the iteration order is not fixed. The cancel commands follow that order (trace below), so a replay can walk the group in another order than the run that recorded the history. The Temporal reproductions ran on Python 3.12. The Python 3.11-3.14 checks inspected TaskGroup and exercised cancellation order in plain asyncio.
The README says workflow code must be deterministic, "This means no set iteration" (README.md:730), and names asyncio.as_completed() and asyncio.wait() as the asyncio utilities that use set() and have deterministic workflow alternatives (README.md:881-886); the sandbox warns for those two (temporalio/worker/workflow_sandbox/_restrictions.py:575-596). asyncio.TaskGroup is in neither place, and the workflow below contains no set.
Minimal Reproduction
Python 3.11+. python tgreplay_repro.py starts a dev server with WorkflowEnvironment.start_local() (arguments: histories per row, replays per history). It is a stress reproduction, not a deterministic test: the replay order is not controlled.
tgreplay_repro.py
"""An asyncio.TaskGroup that is aborted while it holds several kinds of Temporal operation: does the recorded history replay?
Needs Python 3.11+ (asyncio.TaskGroup). It starts a local dev server with WorkflowEnvironment.start_local().
python tgreplay_repro.py [HISTORIES] [REPLAYS_PER_HISTORY]
"""
import asyncio
import sys
import uuid
from datetime import timedelta
import temporalio
from temporalio import activity, workflow
from temporalio.api.enums.v1 import EventType
from temporalio.client import Client
from temporalio.common import RetryPolicy
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Replayer, Worker
@activity.defn
async def wait_forever_activity() -> None:
while True:
activity.heartbeat()
await asyncio.sleep(0.1)
@workflow.defn
class WaitingChild:
@workflow.run
async def run(self) -> None:
await asyncio.Future()
@workflow.defn
class TaskGroupAbort:
@workflow.run
async def run(self, kinds: list[str]) -> None:
async def fail_soon() -> None:
await workflow.sleep(0.3)
raise ValueError("planned abort")
try:
async with asyncio.TaskGroup() as group:
for index in range(2):
if "timer" in kinds:
group.create_task(workflow.sleep(1000))
if "activity" in kinds:
group.create_task(
workflow.execute_activity(
wait_forever_activity,
start_to_close_timeout=timedelta(seconds=60),
heartbeat_timeout=timedelta(seconds=10),
retry_policy=RetryPolicy(maximum_attempts=1),
)
)
if "child" in kinds:
group.create_task(
workflow.execute_child_workflow(
WaitingChild.run,
id=f"{workflow.info().workflow_id}-child-{index}",
)
)
group.create_task(fail_soon())
except Exception:
pass # the workflow handles the group's error and carries on
await workflow.sleep(1)
# Which command the server recorded for each cancelled operation, in history order.
CANCEL_EVENTS = {
EventType.EVENT_TYPE_TIMER_CANCELED: "T",
EventType.EVENT_TYPE_ACTIVITY_TASK_CANCEL_REQUESTED: "A",
EventType.EVENT_TYPE_REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION_INITIATED: "C",
}
async def check(client: Client, kinds: list[str], histories: int, replays: int) -> None:
task_queue = str(uuid.uuid4())
async with Worker(
client,
task_queue=task_queue,
workflows=[TaskGroupAbort, WaitingChild],
activities=[wait_forever_activity],
):
handles = [
await client.start_workflow(
TaskGroupAbort.run,
kinds,
id=str(uuid.uuid4()),
task_queue=task_queue,
)
for _ in range(histories)
]
await asyncio.wait_for(
asyncio.gather(*(handle.result() for handle in handles)), 120
)
recorded = [await handle.fetch_history() for handle in handles]
replayer = Replayer(workflows=[TaskGroupAbort, WaitingChild])
failed_histories = failed_replays = 0
first_failure = None
for history in recorded:
failures = []
for _ in range(replays):
result = await replayer.replay_workflow(
history, raise_on_replay_failure=False
)
if result.replay_failure:
failures.append(result.replay_failure)
failed_histories += bool(failures)
failed_replays += len(failures)
first_failure = first_failure or (failures[0] if failures else None)
orders = {
"".join(CANCEL_EVENTS[e.event_type] for e in h.events if e.event_type in CANCEL_EVENTS)
for h in recorded
}
print(
f"kinds={'+'.join(kinds):<22} {histories} workflows completed; "
f"histories failing replay: {failed_histories} of {histories}; "
f"replays failing: {failed_replays} of {histories * replays}; "
f"distinct recorded cancel orders: {len(orders)}"
)
if first_failure:
print(f" first failure: {type(first_failure).__name__}: {str(first_failure)[:220]}")
async def main() -> None:
histories = int(sys.argv[1]) if len(sys.argv) > 1 else 20
replays = int(sys.argv[2]) if len(sys.argv) > 2 else 3
print(f"temporalio {temporalio.__version__}, python {sys.version.split()[0]}")
async with await WorkflowEnvironment.start_local() as env:
for kinds in (
["timer", "activity", "child"], # mixed: the report
["timer", "activity"],
["timer", "child"],
["activity", "child"],
["timer"], # single kind: controls
["activity"],
["child"],
):
await check(env.client, kinds, histories, replays)
if __name__ == "__main__":
asyncio.run(main())
Environment/Versions
- OS and processor: Linux x86-64 (Ubuntu 24.04.5)
- Temporal Version: SDK
mainatd488ff59(temporalio.__version__is 1.35.0; built from source withpoe build-develop; sdk-coref49305f1), Python 3.12.3, dev server fromWorkflowEnvironment.start_local()(Temporal CLI 1.10.0, server 1.32.2) - Are you using Docker or Kubernetes or building Temporal from source? No Docker or Kubernetes; the SDK is built from source.
Additional context
- #1020 concerns ExceptionGroup failure classification, and #393 concerns ExceptionGroup traceback representation. This reproduction catches the group and demonstrates cancellation-order mismatches during replay. #1578 reports a separate local-activity replay-ordering problem.
- The same script on the released
temporalio1.35.0 and 1.30.0 (Python 3.12.15) gives 20 of 20 histories with at least one failing replay for the three-kind group on both (59 of 60 and 60 of 60 replays), and 0 of 20 for each single kind. - Ideas for the fix, none of them prototyped: an ordered
TaskGroupfor the workflow event loop that cancels its tasks in creation order; an explicit, deterministic cancellation order for tasks created on the workflow loop (this needs an explicit ordering design); as a mitigation only, a sandbox warning forasyncio.TaskGroup, like the ones forasyncio.wait()andasyncio.as_completed(), which would not help histories that are already recorded. - An ordering change needs a replay-compatibility design, such as a workflow logic flag (
_WorkflowLogicFlag,_workflow_instance.py:4195). A flag can retain the previous implementation for old histories, but cannot reconstruct their original arbitrary cancellation order.
Supporting checks: named _abort trace, interpreter survey, new processes, restart variant
Named trace (children named s0 to s5: s0 and s3 are timers, s1 and s4 activities, s2 and s5 child workflows; 3 histories, 4 replays each, with a wrapper around TaskGroup._abort that prints [t.get_name() for t in self._tasks]). In all 3 histories the recorded cancel events followed the live _abort() iteration order; all 12 replays walked a different order and all 12 failed. First history:
history 0: cancel events recorded in order CATTAC (T timer, A activity, C child)
live _abort() iteration order: ['s5', 's1', 's3', 's0', 's4', 's2']
replay 0 _abort() iteration order: ['s0', 's4', 's1', 's3', 's5', 's2'] -> FAILS (nondeterminism)
replay 1 _abort() iteration order: ['s2', 's5', 's1', 's4', 's0', 's3'] -> FAILS (nondeterminism)
Plain asyncio, no Temporal: 200 aborts of the same six child tasks, three runs per interpreter, gave 18, 14, 14 distinct cancellation orders on CPython 3.11.17; 19, 19, 18 on 3.12.3; 15, 15, 15 on 3.12.15; 7, 6, 6 on 3.13.17; and 48, 18, 18 on 3.14.9.
New processes: replaying 20 other saved histories of the three-kind group, 19 of 20 failed in each of three new processes.
In a variant that waits 8 s after the abort and does not set the activity retry policy (10 workflows per case): After restart, 9/10 result waiters remained pending after 30 seconds, with 18 recorded workflow-task failures. With caching disabled, 10/10 remained pending, with 29 failures. All single-kind control waiters finished within the observation window. The 47 failing-workflow-task log lines of that run (18 + 29) all carry force_cause: NonDeterministicError.
AI disclosure: this was prepared with AI assistance (Claude Opus 5.5 and Claude Sonnet 5.5).
- Langage dominant
- Python
- Étoiles
- 1.2k
- Forks
- 285
- Merge moyen
- 1 j 23 h
- PR mergées (30 j)
- 89
Préparer son environnement
- Aucun Dockerfile ni fichier Docker Compose
- Aucun modèle de pull request
- Lire le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Autres issues de temporalio/sdk-python
-
Difficulté 4/5 3-5 jours Accessibilité débutants 18/100
temporalio/sdk-python#2080 ·
Les mainteneurs répondent en général sous 1 jour
-
[Bug] Sandboxed datetime hints can fail decoding and drop signalsPeut-être pris @VegetarianOrc l’a pris il y a 1 jour. Ouverte
temporalio/sdk-python#2072 · 1 personne assignée ·
Les mainteneurs répondent en général sous 1 jour
-
sdk-python
Difficulté 4/5 3-5 jours Accessibilité débutants 22/100
temporalio/sdk-python#2067 · 2 commentaires ·
Les mainteneurs répondent en général sous 1 jour
-
[Bug] lambda_worker otel.apply_defaults leaks a span exporter thread and gRPC channel on every warm invocationPeut-être pris @avanish-garg l’a pris il y a 6 jours. Ouvertebug sdk-python
Difficulté 4/5 1-2 jours Accessibilité débutants 30/100
temporalio/sdk-python#2040 ·
Les mainteneurs répondent en général sous 1 jour
-
Cloud CI Skips Nexus TestsPeut-être à nouveau libre @tconley1428 l’a pris il y a 85 jours, et aucune pull request n’est ouverte. Ouverte
temporalio/sdk-python#1704 · 1 personne assignée ·
Les mainteneurs répondent en général sous 1 jour
Toutes les issues de temporalio/sdk-python
Issues similaires
-
curriculum documentation quality
Difficulté 2/5 1-3 heures Accessibilité débutants 77/100
githubnext/gh-aw-workshop#5367 ·
Les mainteneurs répondent en général sous 3 jours
-
Create Noise Latents ignores randomize_seed and produces the same noise every runPeut-être pris @griptapeops l’a pris aujourd’hui. Ouverte
Difficulté 2/5 1-3 heures Accessibilité débutants 68/100
griptape-ai/griptape-nodes-library-diffusers#127 ·
Les mainteneurs répondent en général sous 1 jour
-
Jenkins capacity report: metrics history is lost when archiving a month failsPeut-être pris @RaphaelFakhri l’a pris aujourd’hui. Ouverte
Difficulté 2/5 1-3 heures Accessibilité débutants 78/100
adoptium/infrastructure#4568 ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 2/5 1-3 heures Accessibilité débutants 72/100
huggingface/deep-rl-class#723 ·
-
premises-missing
Difficulté 2/5 1-3 heures Accessibilité débutants 78/100
WingedGuardian/GENesis-AGI#3928 ·
Les mainteneurs répondent en général sous 1 jour