taskwire 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskwire/__init__.py +135 -0
- taskwire/ambient.py +161 -0
- taskwire/conformance.py +474 -0
- taskwire/contrib/__init__.py +17 -0
- taskwire/contrib/asgi.py +91 -0
- taskwire/contrib/celery.py +352 -0
- taskwire/contrib/fastapi.py +82 -0
- taskwire/contrib/muxws.py +370 -0
- taskwire/contrib/redis_store.py +575 -0
- taskwire/contrib/schema.py +204 -0
- taskwire/contrib/threads.py +43 -0
- taskwire/contrib/viewsets.py +292 -0
- taskwire/decorators.py +88 -0
- taskwire/delivery.py +123 -0
- taskwire/headers.py +36 -0
- taskwire/models.py +683 -0
- taskwire/py.typed +0 -0
- taskwire/reader.py +120 -0
- taskwire/register.py +182 -0
- taskwire/reporter.py +1162 -0
- taskwire/rest.py +312 -0
- taskwire/settings.py +126 -0
- taskwire/store.py +647 -0
- taskwire/transport.py +95 -0
- taskwire-0.1.0.dist-info/METADATA +138 -0
- taskwire-0.1.0.dist-info/RECORD +28 -0
- taskwire-0.1.0.dist-info/WHEEL +4 -0
- taskwire-0.1.0.dist-info/licenses/LICENSE +21 -0
taskwire/reporter.py
ADDED
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
"""`Reporter`, `operation()` and the lifecycle writes.
|
|
2
|
+
|
|
3
|
+
Three rules govern this module:
|
|
4
|
+
|
|
5
|
+
**The throttle is inline, never a background task** (TW-THR-002). `set()` reads an injected clock
|
|
6
|
+
and commits when the window has elapsed. The natural implementation - a task holding a timer - fails
|
|
7
|
+
exactly where it matters: a tight `for row in rows: await reporter.set(...)` loop never yields, so
|
|
8
|
+
the timer task never runs, the bar reads 0 % for a whole CPU-bound phase, and that phase's final
|
|
9
|
+
value is never written at all.
|
|
10
|
+
|
|
11
|
+
**`set()` has no `state=` keyword and no back door to one** (TW-PROG-012). The state is derived from
|
|
12
|
+
lifecycle events: `mark_queued()` writes `queued`, entering `operation()` writes `running`, and
|
|
13
|
+
leaving it writes exactly one of the three terminal states. A caller that could write `state` could
|
|
14
|
+
park an operation in `waiting_input` that nothing will ever release (TW-INV-006).
|
|
15
|
+
|
|
16
|
+
**The terminal write is what ends the operation, and it ends it once** (TW-REG-007, TW-INV-021).
|
|
17
|
+
Exit commits the terminal state and publishes the terminal envelope, in that order. The write takes
|
|
18
|
+
the entry out of the register - the store does that off the document's own state - and leaves the
|
|
19
|
+
document readable at its own address for `tombstone_ttl` (TW-RET-004), which is what a client that
|
|
20
|
+
was not watching reads to learn which of the three terminal states this was.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import contextlib
|
|
27
|
+
import logging
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from .ambient import bind as ambient_bind, unbind as ambient_unbind
|
|
36
|
+
from .models import (
|
|
37
|
+
Button,
|
|
38
|
+
DialogAnswer,
|
|
39
|
+
DialogRequest,
|
|
40
|
+
DialogState,
|
|
41
|
+
encoded_size,
|
|
42
|
+
Envelope,
|
|
43
|
+
EnvelopeKind,
|
|
44
|
+
Error,
|
|
45
|
+
Input,
|
|
46
|
+
now_iso,
|
|
47
|
+
Progress,
|
|
48
|
+
ProgressState,
|
|
49
|
+
Result,
|
|
50
|
+
Text,
|
|
51
|
+
)
|
|
52
|
+
from .settings import configured, settings
|
|
53
|
+
from .store import CANCELLED_BUTTON, key as make_key
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger("taskwire.reporter")
|
|
56
|
+
|
|
57
|
+
_UNSET: Any = object()
|
|
58
|
+
"""Distinguishes "not passed" from "passed as None". `set(percent=None)` means indeterminate;
|
|
59
|
+
omitting `percent` means leave it alone (TW-PROG-003)."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OperationCancelled(Exception): # noqa: N818 - the name is specified (TW-CANCEL-005)
|
|
63
|
+
"""TW-CANCEL-005: its own type.
|
|
64
|
+
|
|
65
|
+
Never a bare `RuntimeError` - callers must be able to catch precisely this - and never a
|
|
66
|
+
subclass of `asyncio.CancelledError`, which would make every `except CancelledError` in the
|
|
67
|
+
application swallow a user-requested cancel as if the event loop had done it.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def new_token() -> str:
|
|
72
|
+
"""A UUIDv4 and nothing more. An address, never an authorization grant."""
|
|
73
|
+
return str(uuid.uuid4())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _clamp_percent(value: float | None) -> float | None:
|
|
77
|
+
"""TW-PROG-002: `[0, 100]` or `None`. Out-of-range values clamp rather than raising."""
|
|
78
|
+
if value is None:
|
|
79
|
+
return None
|
|
80
|
+
return max(0.0, min(100.0, float(value)))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _check_size(name: str, value: Any) -> None:
|
|
84
|
+
"""TW-PROG-008: cap at `max_data_bytes`, by raising and naming the size, never by truncating.
|
|
85
|
+
|
|
86
|
+
Silent truncation is worse than a raise here: the field survives, the application believes it
|
|
87
|
+
wrote what it wrote, and the missing half only shows up in a browser weeks later.
|
|
88
|
+
"""
|
|
89
|
+
size = encoded_size(value)
|
|
90
|
+
if size > settings.max_data_bytes:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"taskwire: {name} is {size} bytes encoded, above the {settings.max_data_bytes}-byte "
|
|
93
|
+
f"cap (settings.max_data_bytes). It is not truncated; reduce it or store it yourself "
|
|
94
|
+
f"and pass a reference."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _expand_buttons(buttons: list[str] | list[Button]) -> list[Button]:
|
|
99
|
+
"""TW-DLG-003: the object form on the wire, a list of strings as sugar in Python.
|
|
100
|
+
|
|
101
|
+
Each expanded label defaults to `Text(key=f"taskwire.button.{id}")` with **no** `text`: a
|
|
102
|
+
frontend with a catalogue renders its own wording, and one without shows the id, which is a
|
|
103
|
+
better failure than a hard-coded English string nobody can translate.
|
|
104
|
+
"""
|
|
105
|
+
expanded: list[Button] = []
|
|
106
|
+
for button in buttons:
|
|
107
|
+
if isinstance(button, Button):
|
|
108
|
+
expanded.append(button)
|
|
109
|
+
else:
|
|
110
|
+
expanded.append(Button(id=button, label=Text(key=f"taskwire.button.{button}")))
|
|
111
|
+
return expanded
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Reporter:
|
|
115
|
+
"""What an operation writes its commentary through.
|
|
116
|
+
|
|
117
|
+
One reporter per operation, plus the children `subtask()` and `split()` hand out. The reporter
|
|
118
|
+
never touches a transport (TW-CORE-003): it writes to the store and calls `store.publish()`.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
key: str,
|
|
125
|
+
ns: str,
|
|
126
|
+
token: str,
|
|
127
|
+
store: Any,
|
|
128
|
+
progress: Progress,
|
|
129
|
+
clock: Callable[[], float] | None = None,
|
|
130
|
+
raise_on_cancel: bool | None = None,
|
|
131
|
+
parent: Reporter | None = None,
|
|
132
|
+
notify_parent: Callable[..., Any] | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
self._key = key
|
|
135
|
+
self._ns = ns
|
|
136
|
+
self._token = token
|
|
137
|
+
self._store = store
|
|
138
|
+
self._progress = progress
|
|
139
|
+
self._clock = clock or time.monotonic
|
|
140
|
+
self._raise_on_cancel = raise_on_cancel
|
|
141
|
+
self._last_commit: float | None = None
|
|
142
|
+
self._last_yield: float | None = None
|
|
143
|
+
self._rev = 0
|
|
144
|
+
self._cancelled = False
|
|
145
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
146
|
+
|
|
147
|
+
# --- nesting ------------------------------------------------------
|
|
148
|
+
# `_parent` and `_notify_parent` are the whole of a subtask's relationship to its parent, and
|
|
149
|
+
# neither of them tells it its own range: TW-NEST-001 says the callee MUST NOT be able to
|
|
150
|
+
# learn it. A child reports a *local* 0-100 and the parent does the mapping, which is also
|
|
151
|
+
# what makes the composition in TW-NEST-002 fall out for free.
|
|
152
|
+
self._parent = parent
|
|
153
|
+
self._notify_parent = notify_parent
|
|
154
|
+
# TW-NEST-004 / TW-INV-010: one entry per child handed out, never a last-written percent.
|
|
155
|
+
# Each commit rewrites exactly the one entry. With a last-written percent, four equal
|
|
156
|
+
# siblings whose fastest finishes first pin the bar at 100% for three quarters of the work.
|
|
157
|
+
self._contribution: dict[int, float] = {}
|
|
158
|
+
self._child_seq = 0
|
|
159
|
+
self._own_percent: float | None = None
|
|
160
|
+
self._high_water: float | None = None
|
|
161
|
+
# TW-NEST-007: the map is mutated under the parent's lock, so any interleaving of concurrent
|
|
162
|
+
# siblings produces the same sum.
|
|
163
|
+
self._children_lock = asyncio.Lock()
|
|
164
|
+
self._ambient_token: Any = None
|
|
165
|
+
# How many questions this operation is waiting on, counted on the root so a subtask's `ask()`
|
|
166
|
+
# is visible to whoever handles the worker being killed (TW-DLG-006).
|
|
167
|
+
self._asking = 0
|
|
168
|
+
# TW-RES-004: a clean exit with a result already written lands in `waiting_input`
|
|
169
|
+
# rather than `done`, and `operation()`'s exit reads this to know which.
|
|
170
|
+
self._result_parked = False
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------- properties
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def token(self) -> str:
|
|
176
|
+
"""The operation's address. A UUIDv4, minted by the caller."""
|
|
177
|
+
return self._token
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def asking(self) -> bool:
|
|
181
|
+
"""Whether this operation is waiting on an answer right now, subtasks included.
|
|
182
|
+
|
|
183
|
+
Free, and local: it is what a caller handling a worker's execution limit reads to tell a
|
|
184
|
+
deadline that expired on a question from one that expired on the work itself (TW-DLG-006).
|
|
185
|
+
Reading it costs no store round trip, which matters where it is read - the worker is being
|
|
186
|
+
killed.
|
|
187
|
+
"""
|
|
188
|
+
root = self
|
|
189
|
+
while root._parent is not None:
|
|
190
|
+
root = root._parent
|
|
191
|
+
return root._asking > 0
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def cancelled(self) -> bool:
|
|
195
|
+
"""TW-CANCEL-004: the last known value, refreshed by every commit and free to poll.
|
|
196
|
+
|
|
197
|
+
Free means free - no store read. `await was_aborted()` is the one that costs a round trip.
|
|
198
|
+
"""
|
|
199
|
+
return self._cancelled
|
|
200
|
+
|
|
201
|
+
async def was_aborted(self) -> bool:
|
|
202
|
+
"""Force a store read. Available regardless of `raise_on_cancel` (TW-CANCEL-004)."""
|
|
203
|
+
self._cancelled = bool(await self._store.is_cancelled(self._key))
|
|
204
|
+
return self._cancelled
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def sync(self) -> SyncReporter:
|
|
208
|
+
"""TW-API-003: one attribute, not a duplicated method list.
|
|
209
|
+
|
|
210
|
+
A hand-written parallel surface drifts the first time a keyword is added to `set()`, and the
|
|
211
|
+
drift is silent because the sync form is exercised by different tests.
|
|
212
|
+
"""
|
|
213
|
+
return SyncReporter(self)
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------- the one mutator
|
|
216
|
+
|
|
217
|
+
async def set( # noqa: A003 - `set` is the specified name (TW-PROG-004)
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
percent: float | None = _UNSET,
|
|
221
|
+
title: Text = _UNSET,
|
|
222
|
+
label: Text = _UNSET,
|
|
223
|
+
icon: str = _UNSET,
|
|
224
|
+
data: dict = _UNSET,
|
|
225
|
+
raise_on_cancel: bool | None = None,
|
|
226
|
+
**forbidden: Any,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""The **only** mutator of the display fields (TW-PROG-004).
|
|
229
|
+
|
|
230
|
+
There is no `set_label()` / `set_percent()` family, because each would need its own
|
|
231
|
+
throttling window and its own merge semantics against this one.
|
|
232
|
+
|
|
233
|
+
An omitted keyword leaves its field untouched; `data=` replaces the bag wholesale
|
|
234
|
+
(TW-PROG-003). `raise_on_cancel=` sets this reporter's policy from here on, overriding the
|
|
235
|
+
per-action and global defaults (TW-CANCEL-003).
|
|
236
|
+
|
|
237
|
+
Raises `ValueError` on `state=`, which no caller may write (TW-PROG-012), and `TypeError` on
|
|
238
|
+
any other unrecognised keyword.
|
|
239
|
+
"""
|
|
240
|
+
if "state" in forbidden:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
"taskwire: Reporter.set() has no `state` keyword and never will (TW-PROG-012). "
|
|
243
|
+
"The state is derived from the lifecycle: mark_queued() writes `queued`, entering "
|
|
244
|
+
"operation() writes `running`, and leaving it writes exactly one terminal state. "
|
|
245
|
+
"A caller-written state can park an operation that nothing will release."
|
|
246
|
+
)
|
|
247
|
+
if forbidden:
|
|
248
|
+
raise TypeError(f"taskwire: set() got unexpected keyword arguments {sorted(forbidden)}")
|
|
249
|
+
|
|
250
|
+
if data is not _UNSET:
|
|
251
|
+
_check_size("data", data)
|
|
252
|
+
|
|
253
|
+
if percent is not _UNSET:
|
|
254
|
+
self._own_percent = _clamp_percent(percent)
|
|
255
|
+
# The display fields live on the ONE progress document a token has (TW-NEST-013). A subtask
|
|
256
|
+
# shares its root's `Progress` object, so `sub.set(label=...)` names the current step of the
|
|
257
|
+
# operation rather than of a sub-range no client can see.
|
|
258
|
+
if title is not _UNSET:
|
|
259
|
+
self._progress.title = title
|
|
260
|
+
if label is not _UNSET:
|
|
261
|
+
self._progress.label = label
|
|
262
|
+
if icon is not _UNSET:
|
|
263
|
+
self._progress.icon = icon
|
|
264
|
+
if data is not _UNSET:
|
|
265
|
+
self._progress.data = data
|
|
266
|
+
if raise_on_cancel is not None:
|
|
267
|
+
self._raise_on_cancel = raise_on_cancel
|
|
268
|
+
|
|
269
|
+
await self._propagate()
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------- nesting
|
|
272
|
+
|
|
273
|
+
def _local_percent(self) -> float | None:
|
|
274
|
+
"""TW-NEST-005: own value when childless, the contribution sum when parented, the LARGER of
|
|
275
|
+
the two when both.
|
|
276
|
+
|
|
277
|
+
"Larger" rather than "sum" or "own": a reporter that both does work itself and hands ranges
|
|
278
|
+
out would otherwise go backwards the moment its first child reports, and a bar that goes
|
|
279
|
+
backwards reads as a bug to every user who sees it.
|
|
280
|
+
"""
|
|
281
|
+
if not self._contribution:
|
|
282
|
+
return self._own_percent
|
|
283
|
+
total = sum(self._contribution.values())
|
|
284
|
+
if self._own_percent is None:
|
|
285
|
+
return total
|
|
286
|
+
return max(self._own_percent, total)
|
|
287
|
+
|
|
288
|
+
async def _propagate(self, *, force: bool = False) -> None:
|
|
289
|
+
"""Push this reporter's local percent one level up, or commit it if this is the root.
|
|
290
|
+
|
|
291
|
+
`force` travels with the propagation rather than stopping at the reporter that was
|
|
292
|
+
asked: a subtask's `flush()` has to reach the store unthrottled (TW-THR-004), and the
|
|
293
|
+
store is several levels up.
|
|
294
|
+
"""
|
|
295
|
+
local = self._local_percent()
|
|
296
|
+
if self._notify_parent is not None:
|
|
297
|
+
await self._notify_parent(local, force=force)
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
# The root, and only the root, writes to the document.
|
|
301
|
+
if local is None:
|
|
302
|
+
# TW-NEST-011: `null` reaches the committed document only when the ROOT's own local
|
|
303
|
+
# percent is None - a child reporting null freezes its contribution instead, which is
|
|
304
|
+
# handled where contributions are written.
|
|
305
|
+
self._progress.percent = None
|
|
306
|
+
else:
|
|
307
|
+
# TW-NEST-010: a monotonic clamp on the root's committed percent, as a final safety
|
|
308
|
+
# net only - it is emphatically not the correctness mechanism, the contribution map
|
|
309
|
+
# is. It nets the *nesting arithmetic*, so it applies only once ranges have been
|
|
310
|
+
# handed out: a childless root setting its own percent is not doing that arithmetic,
|
|
311
|
+
# and TW-PROG-002 entitles it to go backwards (-5 clamps to 0) when a phase restarts.
|
|
312
|
+
if self._contribution:
|
|
313
|
+
self._high_water = local if self._high_water is None else max(self._high_water, local)
|
|
314
|
+
self._progress.percent = self._high_water
|
|
315
|
+
else:
|
|
316
|
+
self._progress.percent = local
|
|
317
|
+
if force:
|
|
318
|
+
await self._commit()
|
|
319
|
+
else:
|
|
320
|
+
await self._tick()
|
|
321
|
+
|
|
322
|
+
def subtask(self, start: float, end: float) -> Reporter:
|
|
323
|
+
"""A reporter covering `[start, end]` of this one's range (TW-NEST-001).
|
|
324
|
+
|
|
325
|
+
The child has the identical API and **cannot learn its range**: it reports a local 0-100 and
|
|
326
|
+
this reporter does the mapping. That is what makes TW-NEST-002's recursive composition fall
|
|
327
|
+
out rather than needing its own arithmetic - `r.subtask(10, 25).subtask(50, 100)` covers
|
|
328
|
+
17.5-25 of `r` because each level maps only its own child's local value.
|
|
329
|
+
"""
|
|
330
|
+
self._child_seq += 1
|
|
331
|
+
child_key = self._child_seq
|
|
332
|
+
width = float(end) - float(start)
|
|
333
|
+
self._contribution[child_key] = 0.0
|
|
334
|
+
|
|
335
|
+
async def notify(local: float | None, *, force: bool = False) -> None:
|
|
336
|
+
if local is None:
|
|
337
|
+
# TW-NEST-011: freeze this child's contribution at its last known value rather than
|
|
338
|
+
# dropping it to zero. A child that goes indeterminate has not un-done its work.
|
|
339
|
+
await self._propagate(force=force)
|
|
340
|
+
return
|
|
341
|
+
async with self._children_lock:
|
|
342
|
+
# Exactly this one entry is rewritten (TW-NEST-004). TW-NEST-012 falls out: the value
|
|
343
|
+
# can never exceed `width`, so it cannot overflow into a sibling's range.
|
|
344
|
+
self._contribution[child_key] = width * max(0.0, min(100.0, local)) / 100.0
|
|
345
|
+
await self._propagate(force=force)
|
|
346
|
+
|
|
347
|
+
return Reporter(
|
|
348
|
+
key=self._key,
|
|
349
|
+
ns=self._ns,
|
|
350
|
+
token=self._token,
|
|
351
|
+
store=self._store,
|
|
352
|
+
progress=self._progress,
|
|
353
|
+
clock=self._clock,
|
|
354
|
+
raise_on_cancel=self._raise_on_cancel,
|
|
355
|
+
parent=self,
|
|
356
|
+
notify_parent=notify,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def split(self, *weights: float) -> tuple[Reporter, ...]:
|
|
360
|
+
"""TW-NEST-003: one reporter per weight, partitioning this range in proportion.
|
|
361
|
+
|
|
362
|
+
Concurrent siblings MUST each be passed their own reporter explicitly (TW-NEST-014). The
|
|
363
|
+
ambient binding must not be shared across a `gather` - see the ambient module's docstring for
|
|
364
|
+
the wrong and the right form side by side.
|
|
365
|
+
"""
|
|
366
|
+
total = float(sum(weights))
|
|
367
|
+
if total <= 0:
|
|
368
|
+
raise ValueError("taskwire: split() needs at least one positive weight")
|
|
369
|
+
children: list[Reporter] = []
|
|
370
|
+
cursor = 0.0
|
|
371
|
+
for weight in weights:
|
|
372
|
+
span = 100.0 * float(weight) / total
|
|
373
|
+
children.append(self.subtask(cursor, cursor + span))
|
|
374
|
+
cursor += span
|
|
375
|
+
return tuple(children)
|
|
376
|
+
|
|
377
|
+
def __enter__(self) -> Reporter:
|
|
378
|
+
"""Bind the ambient reporter for the duration of the block (TW-AMB-004)."""
|
|
379
|
+
from .ambient import bind
|
|
380
|
+
|
|
381
|
+
self._ambient_token = bind(self)
|
|
382
|
+
return self
|
|
383
|
+
|
|
384
|
+
def __exit__(self, *exc: Any) -> None:
|
|
385
|
+
"""Unbind via the contextvars Token, and force an unthrottled flush (TW-THR-004).
|
|
386
|
+
|
|
387
|
+
The reset goes through the `Token` rather than overwriting the variable (TW-AMB-005): a
|
|
388
|
+
raising task that merely overwrote would leak its binding to the next task on the same pooled
|
|
389
|
+
thread, and operation A's progress would land on operation B's key.
|
|
390
|
+
"""
|
|
391
|
+
from .ambient import unbind
|
|
392
|
+
|
|
393
|
+
unbind(self._ambient_token)
|
|
394
|
+
self._ambient_token = None
|
|
395
|
+
|
|
396
|
+
async def _tick(self) -> None:
|
|
397
|
+
"""The inline throttle (TW-THR-002) and the once-per-interval yield (TW-THR-003)."""
|
|
398
|
+
now = self._clock()
|
|
399
|
+
interval = settings.progress_interval
|
|
400
|
+
|
|
401
|
+
# TW-THR-003: once per interval, not once per call. Tracked on its own timestamp so the
|
|
402
|
+
# guarantee survives a window in which nothing was worth committing.
|
|
403
|
+
if self._last_yield is None or (now - self._last_yield) >= interval:
|
|
404
|
+
self._last_yield = now
|
|
405
|
+
await asyncio.sleep(0)
|
|
406
|
+
|
|
407
|
+
if self._last_commit is None or (now - self._last_commit) >= interval:
|
|
408
|
+
await self._commit()
|
|
409
|
+
|
|
410
|
+
async def _commit(self, *, state: ProgressState | None = None, ttl: float | None = None) -> int:
|
|
411
|
+
"""Write, then publish, in that order and never the other (TW-CORE-001, TW-INV-001).
|
|
412
|
+
|
|
413
|
+
A client that saw a push whose state a later read contradicts has no way to recover: it is
|
|
414
|
+
entitled to current state only (TW-CORE-005), and current state just disagreed with what it
|
|
415
|
+
was told.
|
|
416
|
+
"""
|
|
417
|
+
if state is not None:
|
|
418
|
+
self._progress.state = state
|
|
419
|
+
self._progress.updated_at = now_iso()
|
|
420
|
+
|
|
421
|
+
# One write, and both of its answers: the new `rev`, and the sticky cancel flag as that
|
|
422
|
+
# write saw it (TW-STORE-014). Asking the store a second time would be a second round trip
|
|
423
|
+
# per commit against anything that is not a dictionary.
|
|
424
|
+
written = await self._store.commit(self._key, self._progress, settings.active_ttl if ttl is None else ttl)
|
|
425
|
+
self._rev = written.rev
|
|
426
|
+
self._last_commit = self._clock()
|
|
427
|
+
self._cancelled = written.cancelled
|
|
428
|
+
self._raise_if_cancelled(state)
|
|
429
|
+
|
|
430
|
+
await self._store.publish(
|
|
431
|
+
self._ns,
|
|
432
|
+
Envelope(
|
|
433
|
+
token=self._token,
|
|
434
|
+
rev=self._rev,
|
|
435
|
+
kind=EnvelopeKind.PROGRESS,
|
|
436
|
+
body=self._progress.to_dict(),
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
return self._rev
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------- terminal writes
|
|
442
|
+
|
|
443
|
+
async def done(self) -> None:
|
|
444
|
+
"""On a root reporter this is the caller asking for a clean finish.
|
|
445
|
+
|
|
446
|
+
Only the root reporter's terminal transition may put the operation into a terminal state
|
|
447
|
+
(TW-PROG-011); in practice `operation()`'s exit is the one that calls it.
|
|
448
|
+
|
|
449
|
+
**A subtask's `done()` fills its contribution to its full width and does NOT touch the
|
|
450
|
+
operation state** (TW-NEST-008). It reports a local 100 rather than writing a width it is not
|
|
451
|
+
allowed to know (TW-NEST-001), which comes to the same number by a route that keeps the range
|
|
452
|
+
in the parent where it belongs.
|
|
453
|
+
"""
|
|
454
|
+
if self._notify_parent is not None:
|
|
455
|
+
await self._notify_parent(100.0, force=True)
|
|
456
|
+
return
|
|
457
|
+
await self._commit(state=ProgressState.DONE)
|
|
458
|
+
|
|
459
|
+
async def fail(self, code: str, message: Text, retryable: bool = False) -> None:
|
|
460
|
+
"""Write `failed` with an `Error`. Carries no stack trace (TW-PROG-005)."""
|
|
461
|
+
self._progress.error = Error(code=code, message=message, retryable=retryable)
|
|
462
|
+
await self._commit(state=ProgressState.FAILED)
|
|
463
|
+
|
|
464
|
+
def name_error(self, code: str, message: Text, retryable: bool = False) -> None:
|
|
465
|
+
"""Decide what the terminal write will say, without making that write.
|
|
466
|
+
|
|
467
|
+
For an exception on its way out of `operation()`, where the code derived from the exception's
|
|
468
|
+
class would be less true than one the caller already knows: TW-DLG-006's `dialog_timeout` is
|
|
469
|
+
the case, and the class name there is whatever the worker's execution limit happens to raise.
|
|
470
|
+
|
|
471
|
+
`fail()` is the wrong tool for that: it commits, and the exit then commits a second terminal
|
|
472
|
+
state over it - two completions where TW-API-001 allows exactly one, and a terminal envelope
|
|
473
|
+
followed by another one. This names the error and leaves the single write to the exit, which
|
|
474
|
+
withdraws the dialogs and takes the entry out of the register in the same step
|
|
475
|
+
(TW-CANCEL-011, TW-REG-007).
|
|
476
|
+
"""
|
|
477
|
+
self._progress.error = Error(code=code, message=message, retryable=retryable)
|
|
478
|
+
|
|
479
|
+
async def set_result(self, result: Result) -> None:
|
|
480
|
+
"""Park a result for later collection. Root reporter only, and at most once.
|
|
481
|
+
|
|
482
|
+
**This is emphatically not an `ask()`** (TW-RES-005). The task has finished; only the
|
|
483
|
+
*record* waits. The reporter writes the result and the state, and the worker exits. An
|
|
484
|
+
implementation that waited for collection the way `ask()` waits for a reply would pin a
|
|
485
|
+
worker overnight on a download nobody clicked.
|
|
486
|
+
|
|
487
|
+
The operation does not go to `done` when its work finishes (TW-RES-004): it lands in
|
|
488
|
+
`waiting_input` holding the result and stays there until one of the three release paths
|
|
489
|
+
takes it (TW-RES-010). The awaiting caller's promise still resolves when the *work* finishes,
|
|
490
|
+
not when the result is collected (TW-RES-006).
|
|
491
|
+
"""
|
|
492
|
+
if self._notify_parent is not None:
|
|
493
|
+
raise ValueError(
|
|
494
|
+
"taskwire: set_result() is a root reporter's, not a subtask's (TW-RES-003). One "
|
|
495
|
+
"token carries one result, however deep the tree."
|
|
496
|
+
)
|
|
497
|
+
if self._progress.result_kind is None:
|
|
498
|
+
raise ValueError(
|
|
499
|
+
"taskwire: this operation declared no `result_kind`, so it is private from start to "
|
|
500
|
+
"finish and has nothing to collect (TW-RES-001, TW-PRIV-001). There is no promotion "
|
|
501
|
+
"path - declare `result_kind` when the operation starts."
|
|
502
|
+
)
|
|
503
|
+
if self._progress.result is not None:
|
|
504
|
+
raise ValueError("taskwire: a Result is written at most once (TW-RES-001)")
|
|
505
|
+
_check_size("result", result.to_dict())
|
|
506
|
+
|
|
507
|
+
self._progress.result = result
|
|
508
|
+
# TW-RET-003: the key's TTL becomes `result_ttl` and no keepalive runs against it - nobody is
|
|
509
|
+
# running, which is the point. Set `result_ttl` no longer than your own artefact retention.
|
|
510
|
+
self._result_parked = True
|
|
511
|
+
await self._commit()
|
|
512
|
+
await self._store.touch(self._key, settings.result_ttl)
|
|
513
|
+
|
|
514
|
+
async def ask(
|
|
515
|
+
self,
|
|
516
|
+
dialog_id: str,
|
|
517
|
+
*,
|
|
518
|
+
buttons: list[str] | list[Button],
|
|
519
|
+
inputs: list[Input] | None = None,
|
|
520
|
+
params: dict | None = None,
|
|
521
|
+
title: Text | None = None,
|
|
522
|
+
text: Text | None = None,
|
|
523
|
+
) -> DialogAnswer:
|
|
524
|
+
"""Ask the user a question and **block until the store arbitrates a reply**.
|
|
525
|
+
|
|
526
|
+
**There is no timeout** (TW-DLG-005). No `timeout` parameter, no `settings.dialog_timeout`,
|
|
527
|
+
no `timeout_at`, no declared default outcome, no `DialogTimeout` and no `expired` state.
|
|
528
|
+
A deadline would need a default answer, and there is no answer a library can invent on a
|
|
529
|
+
user's behalf; a programmer who wants one implements it at whatever level suits them.
|
|
530
|
+
|
|
531
|
+
The consequence (TW-DLG-006): **an unanswered dialog blocks its worker** until Celery's own
|
|
532
|
+
execution timeout kills the task, at which point the operation becomes `failed` with code
|
|
533
|
+
`dialog_timeout` and `retryable: true` and its open dialogs are withdrawn. The general
|
|
534
|
+
problem of a blocked worker is deliberately unsolved.
|
|
535
|
+
|
|
536
|
+
`ask()` MUST NOT be called while holding a database transaction (TW-DLG-017). It waits for a
|
|
537
|
+
human. A transaction held across it holds its locks for as long as the user takes to answer,
|
|
538
|
+
which may be the rest of the afternoon.
|
|
539
|
+
|
|
540
|
+
Returns only when the store arbitrated a reply, so `DialogAnswer` carries no `timed_out`
|
|
541
|
+
flag - there is no second way out (TW-DLG-007).
|
|
542
|
+
"""
|
|
543
|
+
if params is not None:
|
|
544
|
+
_check_size("dialog params", params)
|
|
545
|
+
|
|
546
|
+
# TW-DLG-001: `id` is *this asking* - a server-minted UUID unique within the token - and is
|
|
547
|
+
# the only one of the two ever used as a key, a path segment or a store argument.
|
|
548
|
+
# `dialog_id` is *which question*, the frontend component identity, and keying by it would
|
|
549
|
+
# let two concurrent operations rendering the same component answer each other's question
|
|
550
|
+
# (TW-DLG-002, TW-INV-008).
|
|
551
|
+
did = str(uuid.uuid4())
|
|
552
|
+
request = DialogRequest(
|
|
553
|
+
id=did,
|
|
554
|
+
dialog_id=dialog_id,
|
|
555
|
+
buttons=_expand_buttons(buttons),
|
|
556
|
+
inputs=list(inputs or []),
|
|
557
|
+
params=params,
|
|
558
|
+
state=DialogState.OPEN,
|
|
559
|
+
)
|
|
560
|
+
if title is not None:
|
|
561
|
+
self._progress.title = title
|
|
562
|
+
if text is not None:
|
|
563
|
+
self._progress.label = text
|
|
564
|
+
|
|
565
|
+
# A dialog open is never throttled (TW-THR-004/005, TW-INV-005): a question the user cannot
|
|
566
|
+
# see is a worker nobody can free.
|
|
567
|
+
await self.flush()
|
|
568
|
+
rev = await self._store.put_dialog(self._key, request, settings.active_ttl)
|
|
569
|
+
await self._store.publish(
|
|
570
|
+
self._ns,
|
|
571
|
+
Envelope(token=self._token, rev=rev, kind=EnvelopeKind.DIALOG_OPEN, body=request.to_dict()),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
root = self
|
|
575
|
+
while root._parent is not None:
|
|
576
|
+
root = root._parent
|
|
577
|
+
root._asking += 1
|
|
578
|
+
reply = await self._store.await_dialog(self._key, did)
|
|
579
|
+
# The question is over the moment the store arbitrates it, answered or withdrawn. An
|
|
580
|
+
# exception on the way out of the wait leaves the count where it is, which is what the count
|
|
581
|
+
# means: the question is still open, and whoever unwinds this worker died on one.
|
|
582
|
+
root._asking -= 1
|
|
583
|
+
|
|
584
|
+
# TW-DLG-012: a dialog on a cancelled operation is flipped to `cancelled` and `ask()` raises
|
|
585
|
+
# OperationCancelled whatever `raise_on_cancel` says. A withdrawal is not an answer, and
|
|
586
|
+
# returning one would let the caller act on a choice nobody made.
|
|
587
|
+
if reply.button == CANCELLED_BUTTON:
|
|
588
|
+
self._cancelled = True
|
|
589
|
+
raise OperationCancelled(f"taskwire: operation {self._token} was cancelled while asking {dialog_id}")
|
|
590
|
+
|
|
591
|
+
snapshot = await self._store.snapshot(self._key)
|
|
592
|
+
closed = next((d for d in (snapshot.dialogs if snapshot else []) if d.id == did), None)
|
|
593
|
+
await self._store.publish(
|
|
594
|
+
self._ns,
|
|
595
|
+
Envelope(
|
|
596
|
+
token=self._token,
|
|
597
|
+
rev=snapshot.rev if snapshot else rev,
|
|
598
|
+
kind=EnvelopeKind.DIALOG_CLOSE,
|
|
599
|
+
body={
|
|
600
|
+
"id": did,
|
|
601
|
+
"state": DialogState(closed.state).value if closed else DialogState.ANSWERED.value,
|
|
602
|
+
"reply": reply.to_dict(),
|
|
603
|
+
},
|
|
604
|
+
),
|
|
605
|
+
)
|
|
606
|
+
# The state returns to `running` on resolution (TW-DLG-009) with nothing written: it was
|
|
607
|
+
# never written on the way in either. `waiting_input` is derived from the open dialogs
|
|
608
|
+
# themselves, which is what TW-PROG-010 requires and what makes the two ends agree.
|
|
609
|
+
await self.flush()
|
|
610
|
+
return DialogAnswer(button=reply.button, values=reply.values)
|
|
611
|
+
|
|
612
|
+
def _raise_if_cancelled(self, writing_state: ProgressState | None) -> None:
|
|
613
|
+
"""TW-CANCEL-002: by default the NEXT progress-reporting call raises.
|
|
614
|
+
|
|
615
|
+
This single behaviour is the whole argument for the raising default. A loop containing no
|
|
616
|
+
cancellation-handling code at all still stops, and still ends `cancelled` rather than
|
|
617
|
+
running to completion after the user pressed the button - which is what a polling API gives
|
|
618
|
+
you when somebody forgets the poll, and somebody always forgets the poll.
|
|
619
|
+
|
|
620
|
+
Not while writing a terminal state: `operation()`'s exit is what turns the raise into the
|
|
621
|
+
`cancelled` state, and raising there would leave the operation with no terminal write at all.
|
|
622
|
+
"""
|
|
623
|
+
if not self._cancelled:
|
|
624
|
+
return
|
|
625
|
+
if writing_state is not None:
|
|
626
|
+
return
|
|
627
|
+
resolved = self._raise_on_cancel
|
|
628
|
+
if resolved is None:
|
|
629
|
+
resolved = settings.raise_on_cancel
|
|
630
|
+
if resolved:
|
|
631
|
+
raise OperationCancelled(f"taskwire: operation {self._token} was cancelled")
|
|
632
|
+
|
|
633
|
+
async def flush(self) -> None:
|
|
634
|
+
"""A subtask has nothing of its own to flush; it forces the root's (TW-THR-004)."""
|
|
635
|
+
if self._notify_parent is not None:
|
|
636
|
+
await self._propagate(force=True)
|
|
637
|
+
return
|
|
638
|
+
await self._commit()
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class InertReporter(Reporter):
|
|
642
|
+
"""TW-AMB-010 / TW-AMB-009: live-looking but writes nothing, so callers never branch.
|
|
643
|
+
|
|
644
|
+
Two situations produce one: `operation(token=None)`, and a `session_resolver` that returned
|
|
645
|
+
`None` - an application opting a request out of taskwire entirely. Neither is an error, and
|
|
646
|
+
neither should force `if reporter is not None:` through the calling code.
|
|
647
|
+
|
|
648
|
+
The display fields, the nesting and the terminal writes are inert. `ask()` and `set_result()`
|
|
649
|
+
are not: an inert reporter has no store, so there is nobody to answer a question and nothing to
|
|
650
|
+
park a result on, and both raise `ValueError`.
|
|
651
|
+
"""
|
|
652
|
+
|
|
653
|
+
def __init__(self) -> None:
|
|
654
|
+
super().__init__(
|
|
655
|
+
key="",
|
|
656
|
+
ns="",
|
|
657
|
+
token="",
|
|
658
|
+
store=None,
|
|
659
|
+
progress=Progress(),
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
async def set(self, **kwargs: Any) -> None: # noqa: A003
|
|
663
|
+
if "state" in kwargs:
|
|
664
|
+
# Still an error. An inert reporter is silent, not permissive: code that would be
|
|
665
|
+
# rejected in production must not pass merely because it ran with no namespace.
|
|
666
|
+
raise ValueError("taskwire: Reporter.set() has no `state` keyword (TW-PROG-012)")
|
|
667
|
+
return None
|
|
668
|
+
|
|
669
|
+
async def ask(self, dialog_id: str, **kwargs: Any) -> DialogAnswer: # noqa: ARG002
|
|
670
|
+
"""A question with nobody to arbitrate it, named as such.
|
|
671
|
+
|
|
672
|
+
Progress an inert reporter drops is progress nobody asked for; a question it dropped would
|
|
673
|
+
be a worker waiting for an answer that no store can ever record.
|
|
674
|
+
"""
|
|
675
|
+
raise ValueError(f"taskwire: ask({dialog_id!r}) needs an operation, and this reporter has none")
|
|
676
|
+
|
|
677
|
+
async def _tick(self) -> None:
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
async def _propagate(self, *, force: bool = False) -> None: # noqa: ARG002
|
|
681
|
+
return None
|
|
682
|
+
|
|
683
|
+
def subtask(self, start: float, end: float) -> Reporter: # noqa: ARG002
|
|
684
|
+
"""An inert reporter's children are inert too, so `with progress.subtask(...)` still works."""
|
|
685
|
+
return InertReporter()
|
|
686
|
+
|
|
687
|
+
def split(self, *weights: float) -> tuple[Reporter, ...]:
|
|
688
|
+
"""One inert reporter per weight, so a split reads the same with no namespace bound."""
|
|
689
|
+
return tuple(InertReporter() for _ in weights)
|
|
690
|
+
|
|
691
|
+
def __enter__(self) -> Reporter:
|
|
692
|
+
return self
|
|
693
|
+
|
|
694
|
+
def __exit__(self, *exc: Any) -> None:
|
|
695
|
+
return None
|
|
696
|
+
|
|
697
|
+
async def _commit(self, *, state: ProgressState | None = None) -> int: # noqa: ARG002
|
|
698
|
+
return 0
|
|
699
|
+
|
|
700
|
+
async def flush(self) -> None:
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
async def done(self) -> None:
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
async def fail(self, code: str, message: Text, retryable: bool = False) -> None: # noqa: ARG002
|
|
707
|
+
return None
|
|
708
|
+
|
|
709
|
+
async def was_aborted(self) -> bool:
|
|
710
|
+
return False
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
class SyncReporter:
|
|
714
|
+
"""TW-API-003: the identical member names, each run on the reporter's owning loop.
|
|
715
|
+
|
|
716
|
+
Resolved dynamically rather than written out. A hand-maintained parallel list drifts the moment
|
|
717
|
+
`set()` grows a keyword, and drifts silently.
|
|
718
|
+
"""
|
|
719
|
+
|
|
720
|
+
def __init__(self, reporter: Reporter) -> None:
|
|
721
|
+
self._reporter = reporter
|
|
722
|
+
|
|
723
|
+
def __getattr__(self, name: str) -> Any:
|
|
724
|
+
attribute = getattr(self._reporter, name)
|
|
725
|
+
if not callable(attribute):
|
|
726
|
+
return attribute
|
|
727
|
+
|
|
728
|
+
def call(*args: Any, **kwargs: Any) -> Any:
|
|
729
|
+
result = attribute(*args, **kwargs)
|
|
730
|
+
if not asyncio.iscoroutine(result):
|
|
731
|
+
return result
|
|
732
|
+
loop = self._reporter._loop
|
|
733
|
+
if loop is None or not loop.is_running():
|
|
734
|
+
return asyncio.run(result)
|
|
735
|
+
return asyncio.run_coroutine_threadsafe(result, loop).result()
|
|
736
|
+
|
|
737
|
+
return call
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _opening_progress(
|
|
741
|
+
*,
|
|
742
|
+
state: ProgressState,
|
|
743
|
+
session: str,
|
|
744
|
+
title: Text | None,
|
|
745
|
+
icon: str | None,
|
|
746
|
+
data: dict | None,
|
|
747
|
+
result_kind: str | None,
|
|
748
|
+
connection: str | None,
|
|
749
|
+
) -> Progress:
|
|
750
|
+
"""The one write that fixes an operation's identity.
|
|
751
|
+
|
|
752
|
+
`result_kind`, `origin_session` and `origin_connection` are set here and never rewritten
|
|
753
|
+
(TW-PROG-006, TW-INV-009). Declaring a `result_kind` is the whole of being shared; declaring
|
|
754
|
+
none makes the operation private from start to finish, with no promotion path (TW-PRIV-001).
|
|
755
|
+
"""
|
|
756
|
+
if data is not None:
|
|
757
|
+
_check_size("data", data)
|
|
758
|
+
stamp = now_iso()
|
|
759
|
+
return Progress(
|
|
760
|
+
state=state,
|
|
761
|
+
percent=None,
|
|
762
|
+
title=title,
|
|
763
|
+
icon=icon,
|
|
764
|
+
data=data or {},
|
|
765
|
+
result_kind=result_kind,
|
|
766
|
+
origin_session=session,
|
|
767
|
+
origin_connection=connection,
|
|
768
|
+
created_at=stamp,
|
|
769
|
+
updated_at=stamp,
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
class OperationScope:
|
|
774
|
+
"""One object implementing all four dunders (TW-API-001, TW-API-002).
|
|
775
|
+
|
|
776
|
+
The asynchronous form is the primary one. The synchronous form runs a private event loop in a
|
|
777
|
+
thread it owns, which is what lets `reporter.sync` and the keepalive both work from code that
|
|
778
|
+
has no loop of its own - a Celery worker, for instance.
|
|
779
|
+
"""
|
|
780
|
+
|
|
781
|
+
def __init__(
|
|
782
|
+
self,
|
|
783
|
+
token: str | None,
|
|
784
|
+
*,
|
|
785
|
+
session: str | None = None,
|
|
786
|
+
title: Text | None = None,
|
|
787
|
+
icon: str | None = None,
|
|
788
|
+
data: dict | None = None,
|
|
789
|
+
result_kind: str | None = None,
|
|
790
|
+
raise_on_cancel: bool | None = None,
|
|
791
|
+
connection: str | None = None,
|
|
792
|
+
store: Any = None,
|
|
793
|
+
clock: Callable[[], float] | None = None,
|
|
794
|
+
) -> None:
|
|
795
|
+
self._token = token
|
|
796
|
+
self._session = session
|
|
797
|
+
self._title = title
|
|
798
|
+
self._icon = icon
|
|
799
|
+
self._data = data
|
|
800
|
+
self._result_kind = result_kind
|
|
801
|
+
self._raise_on_cancel = raise_on_cancel
|
|
802
|
+
self._connection = connection
|
|
803
|
+
self._store = store
|
|
804
|
+
self._clock = clock
|
|
805
|
+
self._reporter: Reporter | None = None
|
|
806
|
+
self._keepalive: asyncio.Task | None = None
|
|
807
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
808
|
+
self._loop_thread: threading.Thread | None = None
|
|
809
|
+
self._ambient_token: Any = None
|
|
810
|
+
# How many questions this operation is waiting on, counted on the root so a subtask's `ask()`
|
|
811
|
+
# is visible to whoever handles the worker being killed (TW-DLG-006).
|
|
812
|
+
self._asking = 0
|
|
813
|
+
# True when the binding belongs to the CALLER's context rather than to `__aenter__`'s. A
|
|
814
|
+
# `contextvars.Token` may only be reset in the context that created it, and a scope entered
|
|
815
|
+
# from a synchronous frame is entered inside a task of its own whose context is discarded
|
|
816
|
+
# on the way out - so binding there would leave nothing bound where the body runs and a
|
|
817
|
+
# Token that cannot be reset. See `bind_ambient_in_caller`.
|
|
818
|
+
self._caller_binds_ambient = False
|
|
819
|
+
|
|
820
|
+
@property
|
|
821
|
+
def _inert(self) -> bool:
|
|
822
|
+
return self._token is None or self._session is None
|
|
823
|
+
|
|
824
|
+
def _resolve_store(self) -> Any:
|
|
825
|
+
return self._store if self._store is not None else configured.store
|
|
826
|
+
|
|
827
|
+
# ---------------------------------------------------------------- async form
|
|
828
|
+
|
|
829
|
+
async def __aenter__(self) -> Reporter:
|
|
830
|
+
if self._inert:
|
|
831
|
+
self._reporter = InertReporter()
|
|
832
|
+
return self._reporter
|
|
833
|
+
|
|
834
|
+
store = self._resolve_store()
|
|
835
|
+
if store is None:
|
|
836
|
+
# No store configured is the same situation as no namespace: the application has not
|
|
837
|
+
# opted in. Be inert rather than raising into unrelated code.
|
|
838
|
+
self._reporter = InertReporter()
|
|
839
|
+
return self._reporter
|
|
840
|
+
|
|
841
|
+
ns = str(self._session)
|
|
842
|
+
token = str(self._token)
|
|
843
|
+
k = make_key(ns, token)
|
|
844
|
+
|
|
845
|
+
existing = await store.snapshot(k)
|
|
846
|
+
if existing is not None:
|
|
847
|
+
# `mark_queued()` already wrote the opening document; the write-once fields belong to
|
|
848
|
+
# it, and this transition only flips `queued` to `running` (TW-INV-009).
|
|
849
|
+
progress = existing.progress
|
|
850
|
+
progress.state = ProgressState.RUNNING
|
|
851
|
+
else:
|
|
852
|
+
progress = _opening_progress(
|
|
853
|
+
state=ProgressState.RUNNING,
|
|
854
|
+
session=ns,
|
|
855
|
+
title=self._title,
|
|
856
|
+
icon=self._icon,
|
|
857
|
+
data=self._data,
|
|
858
|
+
result_kind=self._result_kind,
|
|
859
|
+
connection=self._connection,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
self._reporter = Reporter(
|
|
863
|
+
key=k,
|
|
864
|
+
ns=ns,
|
|
865
|
+
token=token,
|
|
866
|
+
store=store,
|
|
867
|
+
progress=progress,
|
|
868
|
+
clock=self._clock,
|
|
869
|
+
raise_on_cancel=self._raise_on_cancel,
|
|
870
|
+
)
|
|
871
|
+
self._reporter._loop = asyncio.get_running_loop()
|
|
872
|
+
# TW-API-001: bind the ambient reporter, so code six frames down can `await progress.set()`
|
|
873
|
+
# without every function between here and there growing a `reporter` parameter. The
|
|
874
|
+
# caller-bound form binds around the body instead - see `bind_ambient_in_caller`.
|
|
875
|
+
if not self._caller_binds_ambient:
|
|
876
|
+
self._ambient_token = ambient_bind(self._reporter)
|
|
877
|
+
await self._reporter._commit()
|
|
878
|
+
self._keepalive = asyncio.get_running_loop().create_task(self._keepalive_loop(store, k))
|
|
879
|
+
return self._reporter
|
|
880
|
+
|
|
881
|
+
async def _keepalive_loop(self, store: Any, k: str) -> None:
|
|
882
|
+
"""TW-RET-002: touch every `active_ttl / 4`, without the operation's cooperation.
|
|
883
|
+
|
|
884
|
+
"Without cooperation" is the point. The phase that most needs the document to survive is the
|
|
885
|
+
one that never yields voluntarily, which is why the period is a quarter of the TTL rather
|
|
886
|
+
than something the caller schedules.
|
|
887
|
+
"""
|
|
888
|
+
period = settings.keepalive_period
|
|
889
|
+
try:
|
|
890
|
+
while True:
|
|
891
|
+
await asyncio.sleep(period)
|
|
892
|
+
await store.touch(k, settings.active_ttl)
|
|
893
|
+
except asyncio.CancelledError: # pragma: no cover - normal shutdown path
|
|
894
|
+
raise
|
|
895
|
+
except Exception: # noqa: BLE001 - a failed keepalive must not surface in the operation
|
|
896
|
+
logger.debug("taskwire: keepalive stopped for %s", k, exc_info=True)
|
|
897
|
+
|
|
898
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
|
899
|
+
# First, and unconditionally. TW-AMB-005 wants the reset to go through the `Token` rather
|
|
900
|
+
# than an overwrite, and TW-INV-011 names the failure: a raising task that leaked its
|
|
901
|
+
# binding puts operation A's progress on operation B's key, on the next task to reuse the
|
|
902
|
+
# thread. Nothing below here may be able to skip it.
|
|
903
|
+
if not self._caller_binds_ambient:
|
|
904
|
+
ambient_unbind(self._ambient_token)
|
|
905
|
+
self._ambient_token = None
|
|
906
|
+
|
|
907
|
+
if self._keepalive is not None:
|
|
908
|
+
self._keepalive.cancel()
|
|
909
|
+
# The keepalive is being torn down deliberately; whatever it was doing is no longer
|
|
910
|
+
# wanted, and neither its cancellation nor a late failure inside it is news.
|
|
911
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
912
|
+
await self._keepalive
|
|
913
|
+
self._keepalive = None
|
|
914
|
+
|
|
915
|
+
reporter = self._reporter
|
|
916
|
+
if reporter is None or isinstance(reporter, InertReporter):
|
|
917
|
+
return False
|
|
918
|
+
|
|
919
|
+
state, error = _terminal_for(exc_type, exc)
|
|
920
|
+
# An error the operation named for itself outranks one derived from the exception's class
|
|
921
|
+
# (`name_error`). The class is the more specific fact only when nobody knew better: a worker
|
|
922
|
+
# killed while holding a question raises whatever its execution limit raises, and
|
|
923
|
+
# `dialog_timeout` with `retryable: true` is what the reader can act on (TW-DLG-006,
|
|
924
|
+
# TW-PROG-005).
|
|
925
|
+
if error is not None and reporter._progress.error is None:
|
|
926
|
+
reporter._progress.error = error
|
|
927
|
+
|
|
928
|
+
if state is ProgressState.DONE and reporter._result_parked:
|
|
929
|
+
# TW-RES-004: the work is over, the record is not. The operation parks in
|
|
930
|
+
# `waiting_input` and the block returns immediately - it does not wait for collection
|
|
931
|
+
# (TW-RES-005), and neither does the caller awaiting it (TW-RES-006).
|
|
932
|
+
await reporter._store.withdraw_dialogs(reporter._key)
|
|
933
|
+
await reporter._commit()
|
|
934
|
+
await reporter._store.touch(reporter._key, settings.result_ttl)
|
|
935
|
+
return False
|
|
936
|
+
|
|
937
|
+
# TW-CANCEL-011: withdraw first, so a dialog can never outlive the operation that asked it,
|
|
938
|
+
# and anyone blocked in `ask()` is woken rather than left on a question whose operation has
|
|
939
|
+
# already ended.
|
|
940
|
+
#
|
|
941
|
+
# The terminal write takes the entry out of the register in the same step (TW-REG-007, which
|
|
942
|
+
# the store enforces off the state) and leaves the document readable at its own address for
|
|
943
|
+
# `tombstone_ttl` (TW-RET-004). The envelope is still what a watching client sees; the
|
|
944
|
+
# tombstone is what a client that was not watching can read afterwards, and it is the only
|
|
945
|
+
# thing that can tell it `done` from `failed` from `cancelled`.
|
|
946
|
+
await reporter._store.withdraw_dialogs(reporter._key)
|
|
947
|
+
await reporter._commit(state=state, ttl=settings.tombstone_ttl)
|
|
948
|
+
|
|
949
|
+
# TW-API-001: re-raise in every failure case. Returning True here would swallow the
|
|
950
|
+
# application's exception behind a progress-reporting context manager, which is never what
|
|
951
|
+
# the caller meant.
|
|
952
|
+
return False
|
|
953
|
+
|
|
954
|
+
def bind_ambient_in_caller(self) -> None:
|
|
955
|
+
"""Leave the ambient binding to the caller, who binds around the body in its own context.
|
|
956
|
+
|
|
957
|
+
For a caller that enters this scope from a synchronous frame by driving `__aenter__` on a
|
|
958
|
+
loop it already owns. The task that drives it has a context of its own which is discarded on
|
|
959
|
+
the way out, so a binding made inside `__aenter__` would be invisible where the body runs and
|
|
960
|
+
would carry a `contextvars.Token` that can never be reset (TW-AMB-005, TW-INV-011). Call this
|
|
961
|
+
before `__aenter__`, then `taskwire.ambient.bind` around the body and `unbind` in a `finally`.
|
|
962
|
+
|
|
963
|
+
`__enter__` does exactly that for the synchronous form; `wrap_sync_runner` does it for a
|
|
964
|
+
worker that owns its own loop (TW-CELERY-007).
|
|
965
|
+
"""
|
|
966
|
+
self._caller_binds_ambient = True
|
|
967
|
+
|
|
968
|
+
# ---------------------------------------------------------------- sync form
|
|
969
|
+
|
|
970
|
+
def __enter__(self) -> Reporter:
|
|
971
|
+
"""TW-API-002: a private event loop this scope creates and owns for this thread.
|
|
972
|
+
|
|
973
|
+
The loop runs in its own thread rather than being driven by `run_until_complete` from this
|
|
974
|
+
one. That is what lets the keepalive be an ordinary task on it and still run while the
|
|
975
|
+
calling thread is inside a CPU-bound phase - the synchronous form is the Celery form, and
|
|
976
|
+
Celery workers are exactly where a 90-second silent phase happens.
|
|
977
|
+
"""
|
|
978
|
+
self._caller_binds_ambient = True
|
|
979
|
+
self._loop = asyncio.new_event_loop()
|
|
980
|
+
self._loop_thread = threading.Thread(target=self._loop.run_forever, name="taskwire-operation-loop", daemon=True)
|
|
981
|
+
self._loop_thread.start()
|
|
982
|
+
reporter = asyncio.run_coroutine_threadsafe(self.__aenter__(), self._loop).result()
|
|
983
|
+
reporter._loop = self._loop
|
|
984
|
+
# Bind here, in the caller's context, which is where the body will run.
|
|
985
|
+
self._ambient_token = ambient_bind(reporter)
|
|
986
|
+
return reporter
|
|
987
|
+
|
|
988
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
|
989
|
+
ambient_unbind(self._ambient_token)
|
|
990
|
+
self._ambient_token = None
|
|
991
|
+
try:
|
|
992
|
+
asyncio.run_coroutine_threadsafe(self.__aexit__(exc_type, exc, tb), self._loop).result()
|
|
993
|
+
finally:
|
|
994
|
+
loop, thread = self._loop, self._loop_thread
|
|
995
|
+
self._loop, self._loop_thread = None, None
|
|
996
|
+
if loop is not None:
|
|
997
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
998
|
+
if thread is not None:
|
|
999
|
+
thread.join(timeout=5)
|
|
1000
|
+
if loop is not None:
|
|
1001
|
+
loop.close()
|
|
1002
|
+
return False
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _terminal_for(exc_type: Any, exc: Any) -> tuple[ProgressState, Error | None]:
|
|
1006
|
+
"""TW-API-001: exactly one completion, and `cancelled` is not `failed` (TW-CANCEL-006).
|
|
1007
|
+
|
|
1008
|
+
Collapsing `OperationCancelled` into `failed` would make a user-requested stop indistinguishable
|
|
1009
|
+
from a crash in every dashboard that reads the terminal state.
|
|
1010
|
+
"""
|
|
1011
|
+
if exc_type is None:
|
|
1012
|
+
return ProgressState.DONE, None
|
|
1013
|
+
if issubclass(exc_type, OperationCancelled):
|
|
1014
|
+
return ProgressState.CANCELLED, None
|
|
1015
|
+
return ProgressState.FAILED, Error(
|
|
1016
|
+
code=exc_type.__name__,
|
|
1017
|
+
message=Text(text=str(exc) or exc_type.__name__),
|
|
1018
|
+
retryable=False,
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def operation(
|
|
1023
|
+
token: str | None,
|
|
1024
|
+
*,
|
|
1025
|
+
session: str | None = None,
|
|
1026
|
+
title: Text | None = None,
|
|
1027
|
+
icon: str | None = None,
|
|
1028
|
+
data: dict | None = None,
|
|
1029
|
+
result_kind: str | None = None,
|
|
1030
|
+
raise_on_cancel: bool | None = None,
|
|
1031
|
+
connection: str | None = None,
|
|
1032
|
+
store: Any = None,
|
|
1033
|
+
clock: Callable[[], float] | None = None,
|
|
1034
|
+
) -> OperationScope:
|
|
1035
|
+
"""Open an operation. Async **and** sync context manager (TW-API-001, TW-API-002).
|
|
1036
|
+
|
|
1037
|
+
`session` is an already-resolved namespace string - never a user, a request or an identity
|
|
1038
|
+
object (TW-AMB-007). `session_resolver` is the only thing that produces one (TW-AMB-008), and a
|
|
1039
|
+
namespace crossing a process boundary travels as inert data rather than being resolved again on
|
|
1040
|
+
the far side.
|
|
1041
|
+
|
|
1042
|
+
`result_kind=None` makes the operation **private**: unlisted in the register, its progress
|
|
1043
|
+
fan-out scoped to the connection that started it, from start to finish (TW-PRIV-001). Privacy is
|
|
1044
|
+
not authorization - any caller of the namespace holding the token may still read it
|
|
1045
|
+
(TW-SEC-003).
|
|
1046
|
+
"""
|
|
1047
|
+
return OperationScope(
|
|
1048
|
+
token,
|
|
1049
|
+
session=session,
|
|
1050
|
+
title=title,
|
|
1051
|
+
icon=icon,
|
|
1052
|
+
data=data,
|
|
1053
|
+
result_kind=result_kind,
|
|
1054
|
+
raise_on_cancel=raise_on_cancel,
|
|
1055
|
+
connection=connection,
|
|
1056
|
+
store=store,
|
|
1057
|
+
clock=clock,
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
async def mark_queued(
|
|
1062
|
+
token: str,
|
|
1063
|
+
*,
|
|
1064
|
+
session: str | None,
|
|
1065
|
+
title: Text | None = None,
|
|
1066
|
+
icon: str | None = None,
|
|
1067
|
+
data: dict | None = None,
|
|
1068
|
+
result_kind: str | None = None,
|
|
1069
|
+
connection: str | None = None,
|
|
1070
|
+
store: Any = None,
|
|
1071
|
+
) -> None:
|
|
1072
|
+
"""Write the opening `queued` document (TW-PROG-009).
|
|
1073
|
+
|
|
1074
|
+
This is the web side's write, and it is the one that fixes `result_kind` and `origin_connection`
|
|
1075
|
+
for the operation's whole life (TW-INV-009). The worker that later enters `operation()` inherits
|
|
1076
|
+
them and cannot change them.
|
|
1077
|
+
"""
|
|
1078
|
+
if session is None:
|
|
1079
|
+
return None
|
|
1080
|
+
store = store if store is not None else configured.store
|
|
1081
|
+
if store is None:
|
|
1082
|
+
return None
|
|
1083
|
+
|
|
1084
|
+
k = make_key(session, token)
|
|
1085
|
+
if await store.snapshot(k) is not None:
|
|
1086
|
+
# Idempotent: a second `mark_queued` on a queued operation changes nothing, and on a running
|
|
1087
|
+
# or terminal one it must not reach back and rewrite the state (§4.1).
|
|
1088
|
+
return None
|
|
1089
|
+
|
|
1090
|
+
progress = _opening_progress(
|
|
1091
|
+
state=ProgressState.QUEUED,
|
|
1092
|
+
session=session,
|
|
1093
|
+
title=title,
|
|
1094
|
+
icon=icon,
|
|
1095
|
+
data=data,
|
|
1096
|
+
result_kind=result_kind,
|
|
1097
|
+
connection=connection,
|
|
1098
|
+
)
|
|
1099
|
+
written = await store.commit(k, progress, settings.active_ttl)
|
|
1100
|
+
await store.publish(
|
|
1101
|
+
session,
|
|
1102
|
+
Envelope(token=token, rev=written.rev, kind=EnvelopeKind.PROGRESS, body=progress.to_dict()),
|
|
1103
|
+
)
|
|
1104
|
+
return None
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
async def mark_failed(
|
|
1108
|
+
token: str,
|
|
1109
|
+
*,
|
|
1110
|
+
session: str | None,
|
|
1111
|
+
error: BaseException | Error,
|
|
1112
|
+
store: Any = None,
|
|
1113
|
+
) -> None:
|
|
1114
|
+
"""Write `failed` from outside the operation - a dispatch that never reached a worker.
|
|
1115
|
+
|
|
1116
|
+
Takes the entry out of the register in the same step and leaves the document readable at its own
|
|
1117
|
+
address, exactly as `operation()`'s exit does (TW-REG-007, TW-RET-004): the caller is already
|
|
1118
|
+
watching a token, and a `404` would tell it nothing about why the work never started.
|
|
1119
|
+
"""
|
|
1120
|
+
if session is None:
|
|
1121
|
+
return None
|
|
1122
|
+
store = store if store is not None else configured.store
|
|
1123
|
+
if store is None:
|
|
1124
|
+
return None
|
|
1125
|
+
|
|
1126
|
+
k = make_key(session, token)
|
|
1127
|
+
snapshot = await store.snapshot(k)
|
|
1128
|
+
if snapshot is None:
|
|
1129
|
+
return None
|
|
1130
|
+
|
|
1131
|
+
progress = snapshot.progress
|
|
1132
|
+
progress.error = (
|
|
1133
|
+
error
|
|
1134
|
+
if isinstance(error, Error)
|
|
1135
|
+
else Error(code=type(error).__name__, message=Text(text=str(error)), retryable=False)
|
|
1136
|
+
)
|
|
1137
|
+
progress.updated_at = now_iso()
|
|
1138
|
+
progress.state = ProgressState.FAILED
|
|
1139
|
+
written = await store.commit(k, progress, settings.tombstone_ttl)
|
|
1140
|
+
await store.publish(
|
|
1141
|
+
session,
|
|
1142
|
+
Envelope(token=token, rev=written.rev, kind=EnvelopeKind.PROGRESS, body=progress.to_dict()),
|
|
1143
|
+
)
|
|
1144
|
+
return None
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
async def migrate_namespace(from_ns: str, to_ns: str) -> int:
|
|
1148
|
+
"""TW-AMB-011: the **only** way operations change namespace, and the application is the only caller.
|
|
1149
|
+
|
|
1150
|
+
On login an application moves the anonymous session's operations onto the account with one call,
|
|
1151
|
+
the same shape as merging a shopping cart. taskwire does not infer that a login happened, does
|
|
1152
|
+
not compare a request's resolved namespace against a stored one, and does not expose this over
|
|
1153
|
+
REST (TW-SEC-008) - a migration is a bulk re-keying, and the only thing between it and a
|
|
1154
|
+
takeover is that the application, not a request, chooses both namespaces.
|
|
1155
|
+
|
|
1156
|
+
Publishes nothing, and is idempotent because `store.migrate` skips a token already present under
|
|
1157
|
+
the destination.
|
|
1158
|
+
"""
|
|
1159
|
+
store = configured.store
|
|
1160
|
+
if store is None:
|
|
1161
|
+
return 0
|
|
1162
|
+
return await store.migrate(from_ns, to_ns)
|