[Bug] TaskGroup cancellation of mixed Temporal operations can break replay

Ouverte
#2,069 0 commentaires 0 réactions 1 personne assignée Voir sur GitHub

Les mainteneurs répondent en général sous 1 jour

@VegetarianOrc y travaille déjà.

Depuis le 3/11/2026.

  • #2086 par @VegetarianOrc — ouverte

É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 main at d488ff59 (temporalio.__version__ is 1.35.0; built from source with poe build-develop; sdk-core f49305f1), Python 3.12.3, dev server from WorkflowEnvironment.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 temporalio 1.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 TaskGroup for 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 for asyncio.TaskGroup, like the ones for asyncio.wait() and asyncio.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

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Autres issues de temporalio/sdk-python

Toutes les issues de temporalio/sdk-python

Issues similaires

Plus d'issues Python

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.