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.
@@ -0,0 +1,352 @@
1
+ """The Celery worker entry (TW-CELERY-*).
2
+
3
+ Imports Celery; nothing in `taskwire/` outside `contrib` may (TW-CORE-006).
4
+
5
+ ## The one thing to get right
6
+
7
+ **In a Celery deployment exactly one process owns the terminal write, and it is the worker**
8
+ (TW-CELERY-004, TW-INV-009). The web side writes `queued` and - only if dispatch itself failed -
9
+ `failed` with `code: "dispatch_failed"` and `retryable: true`, because an unreachable broker is
10
+ transient and a frontend may legitimately offer a retry.
11
+
12
+ The web side must **not** open `operation()` for a task it dispatches (TW-CELERY-005). Middleware
13
+ never runs in the worker, so the contextvar would be unbound there and every `progress.set()` in the
14
+ task would be a silent no-op - a progress bar that never moves, with nothing logged. The middleware
15
+ that wraps a *non*-Celery action does open one, because the web process is the worker there
16
+ (TW-CELERY-006).
17
+
18
+ ## The blocked-worker problem is deliberately unsolved
19
+
20
+ A dialog has no deadline (TW-DLG-005), so **an unanswered question holds its worker until Celery's
21
+ own execution timeout kills the task** (TW-CELERY-010). At that point this module writes `failed`
22
+ with `code: "dialog_timeout"` and `retryable: true`, and withdraws the operation's open dialogs.
23
+
24
+ What you can do about it today, none of which is a solution:
25
+
26
+ - **Route interactive tasks to their own queue and worker pool**, so a wedged pool cannot starve
27
+ everything else::
28
+
29
+ app.conf.task_routes = {"myapp.tasks.interactive_*": {"queue": "interactive"}}
30
+
31
+ then run that queue with its own workers and its own concurrency.
32
+ - **Never `ask()` inside a database transaction** (TW-DLG-017). It waits for a human, and a
33
+ transaction held across it holds its locks for as long as the user takes.
34
+ - **A high-volume interactive flow wants a workflow engine**, which taskwire deliberately does not
35
+ implement. It reports on work and asks questions about it; it does not orchestrate, schedule or
36
+ resume.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import functools
42
+ import logging
43
+ import sys
44
+
45
+ from collections.abc import Callable
46
+ from typing import Any
47
+
48
+ from celery.exceptions import SoftTimeLimitExceeded
49
+
50
+ from ..ambient import bind as ambient_bind, unbind as ambient_unbind
51
+ from ..decorators import declared_result_kind
52
+ from ..headers import CONNECTION_HEADER, TOKEN_HEADER # noqa: F401 - re-exported for callers
53
+ from ..models import Error, Text
54
+ from ..reporter import mark_failed, mark_queued, operation
55
+ from ..settings import configured
56
+
57
+ logger = logging.getLogger("taskwire.celery")
58
+
59
+
60
+ DISPATCH_FAILED = "dispatch_failed"
61
+ DIALOG_TIMEOUT = "dialog_timeout"
62
+ """The two reserved error codes, both `retryable: true` (TW-PROG-005, TW-CELERY-004, TW-DLG-006)."""
63
+
64
+ TOKEN_KWARG = "_taskwire_token"
65
+ SESSION_KWARG = "_taskwire_session"
66
+ """The two Celery kwargs that carry an operation into a viewset worker (TW-CELERY-003).
67
+
68
+ Underscore-prefixed, and not `token` / `session`, because these travel beside an action's **own**
69
+ arguments rather than a task body's: a viewset action is free to declare a parameter called `token`,
70
+ and a caller that read that one as an operation address would write a namespace's progress to
71
+ whatever the application happened to mean by the word. `wrap_sync_runner` consumes them, so no action
72
+ ever sees them.
73
+ """
74
+
75
+
76
+ def taskwire_task(
77
+ fn: Callable[..., Any] | None = None,
78
+ *,
79
+ result_kind: str | None = None,
80
+ ) -> Callable[..., Any]:
81
+ """Wrap a plain Celery task body so it runs inside an `operation()`.
82
+
83
+ Uses the **synchronous** form (TW-CELERY-008), which creates and owns a private event loop for
84
+ that thread - a Celery worker has none of its own, and every blocking primitive here has to work
85
+ under one that drives it with `run_until_complete` (TW-CELERY-001).
86
+
87
+ ```python
88
+ @app.task(bind=True, soft_time_limit=600)
89
+ @taskwire_task(result_kind="acme.import_report")
90
+ def import_rows(self, reporter, *, rows):
91
+ for index, row in enumerate(rows):
92
+ reporter.sync.set(percent=100 * index / len(rows))
93
+ insert(row)
94
+ ```
95
+
96
+ The task body must declare a parameter named `reporter`; the wrapper passes it by that keyword,
97
+ and passes `None` when the caller was opted out. `token` and `session` cross
98
+ in as **plain strings** (TW-CELERY-003) and the namespace is *not* re-resolved here: there is no
99
+ request in a worker, `session_resolver` is the only producer of a namespace (TW-AMB-008), and a
100
+ namespace that crossed a process boundary travels as inert data.
101
+ """
102
+
103
+ def decorate(body: Callable[..., Any]) -> Callable[..., Any]:
104
+ declared = result_kind if result_kind is not None else declared_result_kind(body)
105
+
106
+ @functools.wraps(body)
107
+ def run(*args: Any, token: str | None = None, session: str | None = None, **kwargs: Any) -> Any:
108
+ if token is None or session is None:
109
+ # No namespace means the application opted this caller out (TW-AMB-009). Run the
110
+ # body anyway - reporting is an accelerator, and refusing to do the work because
111
+ # nobody is watching would be the wrong failure.
112
+ return body(*args, reporter=None, **kwargs)
113
+
114
+ # TW-CELERY-009: `result_kind` was written once by the web process's `queued` write.
115
+ # One is passed here only for the case where there was no such write, and the store
116
+ # ignores it otherwise - the field is write-once (TW-PROG-006). There is no exception.
117
+ with operation(token, session=session, result_kind=declared) as reporter:
118
+ try:
119
+ return body(*args, reporter=reporter, **kwargs)
120
+ except SoftTimeLimitExceeded:
121
+ # TW-DLG-006: an unanswered question is what this death usually is, and the code
122
+ # says so only when it is - a worker killed while grinding rows would otherwise
123
+ # tell its reader to answer a question nobody asked. `reporter.asking` is local
124
+ # and free, which is what makes the distinction affordable here, of all places.
125
+ #
126
+ # Named rather than committed: the exception is on its way out of `operation()`,
127
+ # whose exit makes the one terminal write this operation gets (TW-API-001). When
128
+ # it is not a question, nothing is named and the exception's own class is what
129
+ # the exit derives the code from, as it does for every other failure.
130
+ if reporter.asking:
131
+ reporter.name_error(
132
+ DIALOG_TIMEOUT,
133
+ Text(key="taskwire.dialog_timeout"),
134
+ True, # noqa: FBT003 - positional to match `name_error(code, message, retryable)`
135
+ )
136
+ raise
137
+
138
+ return run
139
+
140
+ return decorate if fn is None else decorate(fn)
141
+
142
+
143
+ async def dispatch(
144
+ task: Any,
145
+ *,
146
+ token: str,
147
+ session: str | None,
148
+ connection: str | None = None,
149
+ result_kind: str | None = None,
150
+ title: Text | None = None,
151
+ data: dict | None = None,
152
+ **task_kwargs: Any,
153
+ ) -> None:
154
+ """The web side of a dispatch: write `queued`, hand off, and write `failed` only if handing off failed.
155
+
156
+ **This must not open `operation()`** (TW-CELERY-005). The block would close when the request
157
+ ends, long before the worker started, and the terminal write it produced would land on top of -
158
+ or under - the worker's real one. The worker owns the terminal write and this side owns exactly
159
+ two: `queued`, and `failed` when the broker could not be reached.
160
+
161
+ A dispatch failure is `retryable: true` (TW-CELERY-004): an unreachable broker is transient, and
162
+ a frontend may legitimately offer the user a retry rather than reporting a dead operation.
163
+ """
164
+ await mark_queued(
165
+ token,
166
+ session=session,
167
+ result_kind=result_kind if result_kind is not None else declared_result_kind(task),
168
+ title=title,
169
+ data=data,
170
+ connection=connection,
171
+ )
172
+ try:
173
+ task.delay(token=token, session=session, **task_kwargs)
174
+ except Exception as broker_failure: # noqa: BLE001 - reported to the user, then re-raised
175
+ logger.warning("taskwire: dispatch failed for %s", token, exc_info=True)
176
+ await mark_failed(
177
+ token,
178
+ session=session,
179
+ error=Error(code=DISPATCH_FAILED, message=Text(key="taskwire.dispatch_failed"), retryable=True),
180
+ )
181
+ raise broker_failure
182
+
183
+
184
+ def wrap_sync_runner(
185
+ runner: Callable[[Any], Any],
186
+ kwargs: dict[str, Any],
187
+ ) -> tuple[Callable[[Any], Any], dict[str, Any]]:
188
+ """Wrap a worker's `run_until_complete` so what it drives runs inside an `operation()`.
189
+
190
+ The worker-side entry for a **viewset action** dispatched to Celery (TW-CELERY-007), and the twin
191
+ of `taskwire_task` for a caller that owns a loop already:
192
+
193
+ ```python
194
+ run, kwargs = wrap_sync_runner(loop.run_until_complete, kwargs)
195
+ kwargs = _reconstruct_kwargs(original_endpoint, kwargs, cls)
196
+ result = run(lifecycle_runner(original_endpoint, instance, cls, lifecycle, *args, **kwargs))
197
+ ```
198
+
199
+ Both arguments come back: `runner` wrapped, and `kwargs` without `_taskwire_token` /
200
+ `_taskwire_session`, so the action is called with its own arguments and nothing else. The order
201
+ of those two lines is load-bearing - a reconstruction pass that has already run hands the action
202
+ a keyword it never declared. A call carrying neither key gets both arguments back unchanged,
203
+ which is what lets one worker entry serve viewsets that never heard of taskwire (TW-AMB-009).
204
+
205
+ `token` and `session` cross as **plain strings** (TW-CELERY-003) and the namespace is not
206
+ re-resolved: `session_resolver` is the only thing that produces one (TW-AMB-008), there is no
207
+ request in a worker, and a namespace that crossed a process boundary is inert data. No
208
+ `result_kind` is passed either - the web process's `queued` write fixed it and the field is
209
+ write-once (TW-CELERY-009, TW-PROG-006, TW-INV-009).
210
+
211
+ Reporting closes before `run` returns, and therefore before the caller publishes the result. That
212
+ ordering is why TW-CELERY-007 bans `task_prerun` / `task_postrun`: a completion written after the
213
+ result has been pushed lets a client see the answer before `done`.
214
+ """
215
+ token = _plain_string(kwargs.get(TOKEN_KWARG), TOKEN_KWARG)
216
+ session = _plain_string(kwargs.get(SESSION_KWARG), SESSION_KWARG)
217
+ if token is None or session is None:
218
+ return runner, kwargs
219
+
220
+ remaining = {name: value for name, value in kwargs.items() if name not in (TOKEN_KWARG, SESSION_KWARG)}
221
+ return functools.partial(_drive, runner, token=token, session=session), remaining
222
+
223
+
224
+ def _drive(runner: Callable[[Any], Any], awaitable: Any, *, token: str, session: str) -> Any:
225
+ """Enter, drive and leave one operation - all with the caller's runner, all from this frame.
226
+
227
+ Three turns of the caller's loop rather than an `async with` inside the awaitable, and both
228
+ halves of that decide something.
229
+
230
+ **One loop.** The opening write, the keepalive, every `await progress.set()` the action makes and
231
+ the terminal write all run on the loop the worker is already driving. `operation()`'s synchronous
232
+ form owns a second loop on a second thread (TW-API-002), which is right for a synchronous task
233
+ body and wrong here: an action is `async def`, so its own awaits would run on the caller's loop
234
+ while the operation ran on the other one, and a `redis.asyncio` client whose connections were
235
+ opened on one loop cannot serve the other.
236
+
237
+ **One frame.** A worker's execution limit is a signal raised at whatever the thread is executing,
238
+ and a thread waiting on an action is inside `runner`, not inside the action. An `async with
239
+ operation(...)` in the awaitable is left suspended by that, its `__aexit__` never runs, and the
240
+ operation expires on its TTL with no terminal write at all. This `finally` is in the frame the
241
+ signal unwinds, so the write happens (TW-CELERY-004, TW-DLG-006).
242
+
243
+ The ambient binding is made here for the same reason `__enter__` makes it in the calling thread:
244
+ a `contextvars.Token` may only be reset in the context that created it, and the task `runner`
245
+ creates for `__aenter__` discards its own (TW-AMB-005). Resetting in the `finally` is what leaves
246
+ the next task on a pooled worker thread unbound rather than writing to the previous operation's
247
+ key (TW-INV-011).
248
+
249
+ A store that cannot be reached at entry leaves the action to run **unreported** rather than
250
+ refusing to run it, and a failure at the terminal write is logged rather than raised: reporting is
251
+ an accelerator, and a broker or store outage must not replace the answer the action produced.
252
+ """
253
+ scope = operation(token, session=session)
254
+ scope.bind_ambient_in_caller()
255
+ try:
256
+ reporter = runner(scope.__aenter__())
257
+ except Exception: # noqa: BLE001 - an unreachable store is not a reason to refuse the work
258
+ logger.warning("taskwire: no operation for %s; the action runs unreported", token, exc_info=True)
259
+ return runner(awaitable)
260
+
261
+ ambient = ambient_bind(reporter)
262
+ try:
263
+ try:
264
+ return runner(awaitable)
265
+ except SoftTimeLimitExceeded:
266
+ # TW-DLG-006, as `taskwire_task` names it, and on the same condition: `dialog_timeout` is
267
+ # what a reader can act on when the operation was holding a question, and a lie when it
268
+ # was not.
269
+ if reporter.asking:
270
+ reporter.name_error(
271
+ DIALOG_TIMEOUT,
272
+ Text(key="taskwire.dialog_timeout"),
273
+ True, # noqa: FBT003 - positional to match `name_error(code, message, retryable)`
274
+ )
275
+ raise
276
+ finally:
277
+ ambient_unbind(ambient)
278
+ try:
279
+ runner(scope.__aexit__(*sys.exc_info()))
280
+ except Exception: # noqa: BLE001 - logged here, never in place of the action's own outcome
281
+ logger.warning("taskwire: the terminal write failed for %s", token, exc_info=True)
282
+
283
+
284
+ def _plain_string(value: Any, name: str) -> str | None:
285
+ """TW-CELERY-003: both cross as plain strings, and anything else addresses nothing.
286
+
287
+ A wrapper object stringified into a key writes an operation to an address no reader holds, and it
288
+ does it silently - so a value of the wrong shape is refused loudly and the action runs unreported.
289
+ """
290
+ if value is None:
291
+ return None
292
+ if not isinstance(value, str) or not value:
293
+ logger.warning("taskwire: %s arrived as %r, not a plain string (TW-CELERY-003)", name, value)
294
+ return None
295
+ return value
296
+
297
+
298
+ def configure_worker(store: Any) -> None:
299
+ """A worker configures its own store and `NullTransport`, and **never pushes** (TW-CELERY-002).
300
+
301
+ Store and transport configuration must not travel in a serialized context: they are objects with
302
+ connections, the serialized form would be a copy nobody owns, and a worker that pushed would
303
+ duplicate every envelope the reader is already delivering (TW-INV-002).
304
+
305
+ The worker writes; the web process's reader pushes. That is the whole division.
306
+ """
307
+ from ..transport import NullTransport
308
+
309
+ configured.store = store
310
+ configured.transport = NullTransport()
311
+
312
+
313
+ async def _celery_dispatch_hook(kwargs: dict[str, Any]) -> dict[str, Any]:
314
+ """The client-side hook fastapi-viewsets calls just before `send_task` (TW-CELERY-007).
315
+
316
+ Reads `context["taskwire_token"]` / `["taskwire_session"]` - present only if the application
317
+ registered `taskwire_context_processor` (or its own equivalent) for this `celery_viewset_client`
318
+ call, and declared a `context: Context` parameter for the action to carry it. Neither is required
319
+ for the call itself; an action with no `context` kwarg or no token in it is one this deployment
320
+ opted out of tracking (TW-AMB-009), and this returns `{}`.
321
+ """
322
+ from .viewsets import CONNECTION_KEY, SESSION_KEY, TOKEN_KEY
323
+
324
+ context = kwargs.get("context")
325
+ if context is None or TOKEN_KEY not in context or SESSION_KEY not in context:
326
+ return {}
327
+ token = await context[TOKEN_KEY]
328
+ session = await context[SESSION_KEY]
329
+ if token is None or session is None:
330
+ return {}
331
+ connection = await context[CONNECTION_KEY] if CONNECTION_KEY in context else None
332
+
333
+ # No `result_kind`: the hook has no route-specific information to derive one from, and the field
334
+ # is write-once (TW-PROG-006) - an operation dispatched this way keeps the generic rendering a
335
+ # missing `result_kind` gets, rather than one guessed from nothing.
336
+ await mark_queued(token, session=session, connection=connection)
337
+ return {TOKEN_KWARG: token, SESSION_KWARG: session}
338
+
339
+
340
+ def register_taskwire_celery_viewset() -> None:
341
+ """Wire taskwire into both of fastapi-viewsets' Celery viewset hooks (TW-CELERY-007).
342
+
343
+ Call once: `set_celery_kwargs_hook` on the worker, `set_celery_dispatch_hook` wherever the web
344
+ process dispatches. Both degrade to a no-op for a call taskwire was never told to track
345
+ (TW-AMB-009), so this is safe to call even for a deployment where some `celery_viewset` actions
346
+ carry no `context: Context` parameter and no token at all.
347
+ """
348
+ from fastapi_viewsets.decorators.celery_viewset.client import set_celery_dispatch_hook
349
+ from fastapi_viewsets.decorators.celery_viewset.server import set_celery_kwargs_hook
350
+
351
+ set_celery_kwargs_hook(wrap_sync_runner)
352
+ set_celery_dispatch_hook(_celery_dispatch_hook)
@@ -0,0 +1,82 @@
1
+ """The FastAPI adapter. Imports FastAPI; nothing in `taskwire/` outside `contrib` may (TW-CORE-006).
2
+
3
+ The adapter does exactly two things and deliberately no more: it builds a `Client` from the request,
4
+ and it translates `(status, payload)` into a response. Every decision the endpoints make lives in
5
+ `taskwire.rest`, which knows nothing about any framework - so the same behaviour is testable
6
+ without a server, and an application on a different framework reimplements only this file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from fastapi import APIRouter, Request, Response
14
+
15
+ from ..delivery import Client
16
+ from ..headers import CONNECTION_HEADER
17
+ from ..rest import collect_result, dismiss_result, get_operations, get_snapshot, reply_to_dialog, request_cancel
18
+ from ..settings import configured, settings
19
+
20
+
21
+ def build_client(request: Request) -> Client:
22
+ """TW-REST-002. `session_resolver` fills `session`; the header fills `connection`.
23
+
24
+ `session_resolver` is the only producer of a namespace (TW-AMB-008). A `None` return is legal
25
+ and means the application opted this caller out entirely - not "nobody is logged in", since an
26
+ anonymous visitor still has a session (TW-AMB-009).
27
+ """
28
+ resolver = configured.session_resolver
29
+ session = resolver(request) if resolver is not None else None
30
+ return Client(
31
+ session=session, # type: ignore[arg-type]
32
+ connection=request.headers.get(CONNECTION_HEADER),
33
+ request=request,
34
+ )
35
+
36
+
37
+ def router(prefix: str | None = None) -> APIRouter:
38
+ """Mount point for the six endpoints of TW-REST-004, under `prefix`."""
39
+ api = APIRouter(prefix=prefix if prefix is not None else settings.rest_prefix)
40
+
41
+ # The register is the collection, so it lives at the collection's own URL and carries no extra
42
+ # segment (TW-REST-004). Nothing follows the prefix that a token could be mistaken for.
43
+ @api.get("")
44
+ async def operations(request: Request, response: Response) -> Any:
45
+ status, payload = await get_operations(build_client(request))
46
+ response.status_code = status
47
+ return payload
48
+
49
+ # Declared before `/{token}`: FastAPI matches in declaration order, and the longer path must be
50
+ # offered first or the token route swallows it.
51
+ @api.post("/{token}/dialogs/{did}")
52
+ async def dialog_reply(token: str, did: str, request: Request, response: Response) -> Any:
53
+ body = await request.json() if await request.body() else {}
54
+ status, payload = await reply_to_dialog(build_client(request), token, did, body)
55
+ response.status_code = status
56
+ return payload
57
+
58
+ @api.post("/{token}/cancel")
59
+ async def cancel(token: str, request: Request, response: Response) -> Any:
60
+ status, payload = await request_cancel(build_client(request), token)
61
+ response.status_code = status
62
+ return payload
63
+
64
+ @api.post("/{token}/collect")
65
+ async def collect(token: str, request: Request, response: Response) -> Any:
66
+ status, payload = await collect_result(build_client(request), token)
67
+ response.status_code = status
68
+ return payload
69
+
70
+ @api.post("/{token}/dismiss")
71
+ async def dismiss(token: str, request: Request, response: Response) -> Any:
72
+ status, payload = await dismiss_result(build_client(request), token)
73
+ response.status_code = status
74
+ return payload
75
+
76
+ @api.get("/{token}")
77
+ async def snapshot(token: str, request: Request, response: Response) -> Any:
78
+ status, payload = await get_snapshot(build_client(request), token)
79
+ response.status_code = status
80
+ return payload
81
+
82
+ return api