File size: 9,756 Bytes
2415446
a1bab2d
 
2415446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1bab2d
2415446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
"""Task execution for claims returned by messaging tree aggregates."""
from __future__ import annotations


import asyncio
import contextlib
from collections.abc import Awaitable, Callable
from dataclasses import dataclass

from loguru import logger

from ..safe_diagnostics import format_exception_for_log
from .runtime import MessageTree
from .transitions import CancellationReason, NodeClaim, QueueEntry

NodeProcessor = Callable[[NodeClaim], Awaitable[None]]
QueueUpdateCallback = Callable[[tuple[QueueEntry, ...]], Awaitable[None]]
NodeStartedCallback = Callable[[NodeClaim], Awaitable[None]]
ClaimFailureCallback = Callable[[NodeClaim], Awaitable[None]]
ClaimFinishedCallback = Callable[[MessageTree, NodeClaim], Awaitable[None]]


@dataclass(slots=True)
class _TaskSlot:
    tree: MessageTree
    claim: NodeClaim
    task: asyncio.Task[None] | None = None
    runner_started: bool = False
    transitioned: bool = False
    recovery_task: asyncio.Task[None] | None = None
    cancellation_requested: bool = False
    cancellation_reason: CancellationReason | None = None


@dataclass(frozen=True, slots=True)
class CancelledTask:
    """Task handle plus whether the node runner owns cancellation UI."""


    task: asyncio.Task[None]
    runner_started: bool


class TreeQueueProcessor:
    """Own asyncio tasks while MessageTree owns scheduling state."""

    def __init__(
        self,
        node_processor: NodeProcessor,
        *,
        claim_failure_callback: ClaimFailureCallback,
        claim_finished_callback: ClaimFinishedCallback,
        queue_update_callback: QueueUpdateCallback | None = None,
        node_started_callback: NodeStartedCallback | None = None,
        log_messaging_error_details: bool = False,
    ) -> None:
        self._node_processor = node_processor
        self._claim_failure_callback = claim_failure_callback
        self._claim_finished_callback = claim_finished_callback
        self._queue_update_callback = queue_update_callback
        self._node_started_callback = node_started_callback
        self._log_messaging_error_details = log_messaging_error_details
        self._tasks: dict[str, _TaskSlot] = {}
        self._completion_failures: list[Exception] = []
        self._idle = asyncio.Event()
        self._idle.set()

    @staticmethod
    def _key(claim: NodeClaim) -> str:
        return claim.claim_id

    def launch(
        self,
        tree: MessageTree,
        claim: NodeClaim,
        *,
        announce_started: bool = False,
        queue: tuple[QueueEntry, ...] = (),
    ) -> None:
        """Attach a task synchronously before another coroutine can cancel it."""
        key = self._key(claim)
        if key in self._tasks:
            raise RuntimeError(f"Claim {key} already has a task")
        slot = _TaskSlot(tree=tree, claim=claim)
        self._tasks[key] = slot
        self._idle.clear()
        ownership_ready = asyncio.Event()
        claim_runner = self._run_claim(
            slot,
            ownership_ready=ownership_ready,
            announce_started=announce_started,
            queue=queue,
        )
        try:
            task = asyncio.create_task(
                claim_runner,
                name=(f"messaging-claim-{claim.identity.root_id}-{claim.claim_id[:8]}"),
            )
        except BaseException:
            claim_runner.close()
            if self._tasks.get(key) is slot:
                self._tasks.pop(key)
            if not self._tasks:
                self._idle.set()
            raise
        slot.task = task
        task.add_done_callback(lambda _task, claim_key=key: self._task_done(claim_key))
        ownership_ready.set()

    def _task_done(self, key: str) -> None:
        """Recover a claim if its task was cancelled before entering its body."""
        slot = self._tasks.get(key)
        if slot is None or slot.transitioned or slot.recovery_task is not None:
            return
        slot.recovery_task = asyncio.create_task(
            self._recover_unentered_task(slot),
            name=f"messaging-claim-recovery-{key[:8]}",
        )

    async def _recover_unentered_task(self, slot: _TaskSlot) -> None:
        task = slot.task
        if task is not None:
            with contextlib.suppress(asyncio.CancelledError, Exception):
                await task
        if not slot.transitioned:
            await self._finish_and_continue(slot)

    async def _notify_queue_updated(self, queue: tuple[QueueEntry, ...]) -> None:
        if self._queue_update_callback is None:
            return
        try:
            await self._queue_update_callback(queue)
        except Exception as exc:
            logger.warning(
                "Queue update callback failed: {}",
                format_exception_for_log(
                    exc,
                    log_full_message=self._log_messaging_error_details,
                ),
            )

    async def notify_queue_updated(self, queue: tuple[QueueEntry, ...]) -> None:
        """Publish a transition-owned queue snapshot."""
        await self._notify_queue_updated(queue)

    async def _notify_node_started(self, claim: NodeClaim) -> None:
        if self._node_started_callback is None:
            return
        try:
            await self._node_started_callback(claim)
        except Exception as exc:
            logger.warning(
                "Node started callback failed: {}",
                format_exception_for_log(
                    exc,
                    log_full_message=self._log_messaging_error_details,
                ),
            )

    async def _run_claim(
        self,
        slot: _TaskSlot,
        *,
        ownership_ready: asyncio.Event,
        announce_started: bool,
        queue: tuple[QueueEntry, ...],
    ) -> None:
        await ownership_ready.wait()
        claim = slot.claim
        try:
            if announce_started:
                await self._notify_node_started(claim)
                await self._notify_queue_updated(queue)
            if slot.cancellation_requested:
                if slot.cancellation_reason is None:
                    raise asyncio.CancelledError
                raise asyncio.CancelledError(slot.cancellation_reason)
            slot.runner_started = True
            await self._node_processor(claim)
        except asyncio.CancelledError:
            logger.info("Task for node {} was cancelled", claim.node.node_id)
            raise
        except Exception as exc:
            logger.error(
                "Error processing node {}: {}",
                claim.node.node_id,
                format_exception_for_log(
                    exc,
                    log_full_message=self._log_messaging_error_details,
                ),
            )
            await self._claim_failure_callback(claim)
        finally:
            if not slot.transitioned:
                await self._finish_and_continue(slot)

    async def _finish_and_continue(self, slot: _TaskSlot) -> None:
        current = asyncio.current_task()
        if current is not None:
            while current.cancelling():
                current.uncancel()
        try:
            while True:
                try:
                    await self._claim_finished_callback(slot.tree, slot.claim)
                    slot.transitioned = True
                    break
                except asyncio.CancelledError:
                    if current is not None:
                        while current.cancelling():
                            current.uncancel()
                    continue
        except Exception as exc:
            self._completion_failures.append(exc)
            logger.error(
                "Claim completion callback failed for node {}: {}",
                slot.claim.node.node_id,
                format_exception_for_log(
                    exc,
                    log_full_message=self._log_messaging_error_details,
                ),
            )
        finally:
            key = self._key(slot.claim)
            if self._tasks.get(key) is slot:
                self._tasks.pop(key)
            if not self._tasks:
                self._idle.set()

    def cancel(
        self,
        claim: NodeClaim,
        reason: CancellationReason | None,
    ) -> CancelledTask | None:
        """Cancel exactly the task bound to one aggregate claim."""
        slot = self._tasks.get(self._key(claim))
        if slot is None:
            return None
        slot.cancellation_requested = True
        slot.cancellation_reason = reason
        task = slot.task
        if task is None or task.done():
            return None
        if reason is None:
            task.cancel()
        else:
            task.cancel(reason)

        if slot.runner_started:
            return CancelledTask(task=task, runner_started=True)

        if slot.recovery_task is None:
            slot.recovery_task = asyncio.create_task(
                self._recover_unentered_task(slot),
                name=f"messaging-claim-recovery-{claim.claim_id[:8]}",
            )
        return CancelledTask(task=slot.recovery_task, runner_started=False)

    def task_count(self) -> int:
        """Return the number of attached claims for observability."""
        return len(self._tasks)

    async def wait_idle(self) -> None:
        """Wait for every task and hand completion failures to the caller once."""
        await self._idle.wait()
        if not self._completion_failures:
            return
        failures = self._completion_failures
        self._completion_failures = []
        if len(failures) == 1:
            raise failures[0]
        raise ExceptionGroup("Messaging claim completion failures", failures)


__all__ = ["CancelledTask", "TreeQueueProcessor"]