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

未關閉
#2,069 0 則留言 0 個 reaction 已指派 1 人 在 GitHub 檢視

維護者通常 1 天內回覆

@VegetarianOrc 已經在處理了。

開始於 2026年11月3日。

  • #2086 來自 @VegetarianOrc —— 未關閉

評估

這個 Issue 還沒有評估資料。

描述

sdk-python
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).

主要語言
Python
星號
1.2k
分支
285
平均合併
1 天 23 小時
30 天內合併 PR
80

環境準備

  • 沒有 Dockerfile 或 Docker Compose 檔案
  • 沒有 Pull Request 範本
  • 閱讀貢獻指南

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

temporalio/sdk-python 的其他 Issue

查看 temporalio/sdk-python 的全部 Issue

相似的 Issue

更多 Python Issue

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。