taskwire 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → taskwire-0.3.0}/PKG-INFO +11 -3
- {taskwire-0.2.0 → taskwire-0.3.0}/pyproject.toml +23 -8
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/__init__.py +5 -3
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/calls.py +63 -1
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/celery.py +10 -10
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/muxws.py +20 -29
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/redis_store.py +80 -14
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/viewsets.py +93 -26
- taskwire-0.3.0/taskwire/contrib/viewsets_events.py +291 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/delivery.py +26 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/reader.py +8 -1
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/reporter.py +8 -4
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/settings.py +29 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/transport.py +5 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/.gitignore +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/LICENSE +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/README.md +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/README.md +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/sequences/.gitkeep +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/aggregate-counts.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/aggregate-waiting-input-dominates.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/dialog-request-open.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/envelope-cancel.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/envelope-progress.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/envelope-snapshot.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/error-dispatch-failed.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-percent-absent.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-percent-null.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-private.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-shared-origins.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-cancelled.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-done.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-failed.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-queued.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-running.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-waiting-input.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/register-empty-null-aggregate.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/register-v1-with-aggregate.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/result-kind-and-params.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/result-label-and-value-no-ref.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/result-ref-with-expires-at.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/snapshot-v1-wall-clock-rev.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/snapshot-waiting-input-uncollected-result.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/text-key-and-text.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/text-key-only.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/text-key-params-and-text.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/text-text-only.json +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/ambient.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/conformance.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/__init__.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/asgi.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/fastapi.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/schema.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/threads.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/wsgi.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/decorators.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/headers.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/models.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/py.typed +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/register.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/rest.py +0 -0
- {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: taskwire
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Progress reporting and awaitable dialogs for long-running server-side operations.
|
|
5
5
|
Project-URL: Homepage, https://github.com/velis74/taskwire
|
|
6
6
|
Project-URL: Repository, https://github.com/velis74/taskwire
|
|
@@ -28,22 +28,30 @@ Requires-Dist: nest-asyncio>=1.6; extra == 'celery'
|
|
|
28
28
|
Provides-Extra: demo
|
|
29
29
|
Requires-Dist: uvicorn>=0.27; extra == 'demo'
|
|
30
30
|
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: celery>=5.3; extra == 'dev'
|
|
31
32
|
Requires-Dist: coverage; extra == 'dev'
|
|
33
|
+
Requires-Dist: dynamicforms-fastapi-viewsets>=0.11.1; extra == 'dev'
|
|
34
|
+
Requires-Dist: dynamicforms-fastapi-viewsets[muxws]>=0.11.1; extra == 'dev'
|
|
35
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
32
36
|
Requires-Dist: httpx; extra == 'dev'
|
|
33
37
|
Requires-Dist: hypothesis; extra == 'dev'
|
|
38
|
+
Requires-Dist: muxws>=0.4.1; extra == 'dev'
|
|
39
|
+
Requires-Dist: nest-asyncio>=1.6; extra == 'dev'
|
|
34
40
|
Requires-Dist: pytest; extra == 'dev'
|
|
35
41
|
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
36
42
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
43
|
+
Requires-Dist: redis>=5.0; extra == 'dev'
|
|
37
44
|
Requires-Dist: ruff; extra == 'dev'
|
|
45
|
+
Requires-Dist: uvicorn>=0.27; extra == 'dev'
|
|
38
46
|
Provides-Extra: fastapi
|
|
39
47
|
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
40
48
|
Provides-Extra: muxws
|
|
41
|
-
Requires-Dist: dynamicforms-fastapi-viewsets[muxws]>=0.
|
|
49
|
+
Requires-Dist: dynamicforms-fastapi-viewsets[muxws]>=0.11.1; extra == 'muxws'
|
|
42
50
|
Requires-Dist: muxws>=0.4.1; extra == 'muxws'
|
|
43
51
|
Provides-Extra: redis
|
|
44
52
|
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
45
53
|
Provides-Extra: viewsets
|
|
46
|
-
Requires-Dist: dynamicforms-fastapi-viewsets>=0.
|
|
54
|
+
Requires-Dist: dynamicforms-fastapi-viewsets>=0.11.1; extra == 'viewsets'
|
|
47
55
|
Description-Content-Type: text/markdown
|
|
48
56
|
|
|
49
57
|
# <img src="taskwire-icon.svg" alt="" width="40" height="40" style="vertical-align: middle"> taskwire
|
|
@@ -30,16 +30,31 @@ dependencies = [] # DELIBERATE: core has no runtime dependency of any kind (TW
|
|
|
30
30
|
redis = ["redis>=5.0"]
|
|
31
31
|
celery = ["celery>=5.3", "nest_asyncio>=1.6"]
|
|
32
32
|
fastapi = ["fastapi>=0.100"]
|
|
33
|
-
# >=0.
|
|
34
|
-
# of its action when its stream ends early (TW-CALL-*)
|
|
35
|
-
|
|
33
|
+
# >=0.11.1: the execution hook and the Idempotency-Key helpers; a muxws command that runs to the end
|
|
34
|
+
# of its action when its stream ends early (TW-CALL-*); delivery filters that run on poll as on push,
|
|
35
|
+
# which `contrib/viewsets_events.py` relies on for the display decision (TW-EVT-004); and an executor
|
|
36
|
+
# that reports its outcomes in its own terms - `ActionInterruptedError`, `ActionOutcomeUnknownError`,
|
|
37
|
+
# `Middleware.executor_started()` and `Middleware.on_transition()` - so that `contrib/viewsets.py`
|
|
38
|
+
# imports no Celery (TW-CALL-101, TW-CALL-105), and that reports a process exit to `on_transition()`
|
|
39
|
+
# in every pool.
|
|
40
|
+
viewsets = ["dynamicforms-fastapi-viewsets>=0.11.1"]
|
|
36
41
|
# The socket carries the six request/response calls as viewset commands (TW-REST-004), so the muxws
|
|
37
42
|
# extra needs the viewset layer's muxws half as well as muxws itself. >=0.4.1: `Peer.close()` sends
|
|
38
43
|
# every queued frame before closing the socket, so the `goaway` of `goaway_if_stale` (TW-WS-007)
|
|
39
44
|
# reaches the client.
|
|
40
|
-
muxws = ["muxws>=0.4.1", "dynamicforms-fastapi-viewsets[muxws]>=0.
|
|
45
|
+
muxws = ["muxws>=0.4.1", "dynamicforms-fastapi-viewsets[muxws]>=0.11.1"]
|
|
41
46
|
demo = ["uvicorn>=0.27"]
|
|
42
|
-
|
|
47
|
+
# Everything the test suite, the linters and the demo need.
|
|
48
|
+
dev = [
|
|
49
|
+
"taskwire[redis,celery,fastapi,viewsets,muxws,demo]",
|
|
50
|
+
"coverage",
|
|
51
|
+
"pytest",
|
|
52
|
+
"pytest-asyncio",
|
|
53
|
+
"pytest-cov",
|
|
54
|
+
"hypothesis",
|
|
55
|
+
"httpx",
|
|
56
|
+
"ruff",
|
|
57
|
+
]
|
|
43
58
|
|
|
44
59
|
[project.urls]
|
|
45
60
|
Homepage = "https://github.com/velis74/taskwire"
|
|
@@ -50,13 +65,13 @@ Documentation = "https://docs.velis.si/taskwire/"
|
|
|
50
65
|
[tool.hatch.version]
|
|
51
66
|
path = "taskwire/__init__.py"
|
|
52
67
|
|
|
53
|
-
# The release tool stamps the two manifests by itself. `
|
|
68
|
+
# The release tool stamps the two manifests by itself. `vue/index.ts` is the third place the version
|
|
54
69
|
# is written down - the npm half exports it as `VERSION` - and nothing about a Python manifest points
|
|
55
70
|
# at it, so it is named here. `packaging_test.py::test_python_and_npm_versions_match` and
|
|
56
|
-
# `
|
|
71
|
+
# `vue/packaging.spec.ts` both compare against `package.json`, transitively pinning all three, but only
|
|
57
72
|
# once a release has actually bumped this file too.
|
|
58
73
|
[tool.publish]
|
|
59
|
-
additional_files = ["
|
|
74
|
+
additional_files = ["vue/index.ts"]
|
|
60
75
|
|
|
61
76
|
[tool.hatch.build.targets.wheel]
|
|
62
77
|
packages = ["taskwire"]
|
|
@@ -12,10 +12,10 @@ outside. Nothing in the core imports a framework, a driver or a UI library (TW-C
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
-
__version__ = "0.
|
|
15
|
+
__version__ = "0.3.0"
|
|
16
16
|
|
|
17
17
|
from .ambient import ask, current_reporter, progress
|
|
18
|
-
from .calls import Call, call_token, execute_call, hand_off, receive_call
|
|
18
|
+
from .calls import Call, call_token, end_call, execute_call, hand_off, receive_call
|
|
19
19
|
from .conformance import run_conformance
|
|
20
20
|
from .decorators import (
|
|
21
21
|
declared_result_kind,
|
|
@@ -60,7 +60,7 @@ from .reporter import (
|
|
|
60
60
|
Reporter,
|
|
61
61
|
SyncReporter,
|
|
62
62
|
)
|
|
63
|
-
from .settings import configure, configured, reset_configuration, Settings, settings
|
|
63
|
+
from .settings import configure, configure_executor, configured, reset_configuration, Settings, settings
|
|
64
64
|
from .store import (
|
|
65
65
|
Commit,
|
|
66
66
|
DialogResolution,
|
|
@@ -120,6 +120,7 @@ __all__ = [
|
|
|
120
120
|
"declared_result_kind",
|
|
121
121
|
"declared_result_params",
|
|
122
122
|
"configure",
|
|
123
|
+
"configure_executor",
|
|
123
124
|
"configured",
|
|
124
125
|
"CONNECTION_HEADER",
|
|
125
126
|
"key",
|
|
@@ -130,6 +131,7 @@ __all__ = [
|
|
|
130
131
|
"TOKEN_HEADER",
|
|
131
132
|
"operation",
|
|
132
133
|
"receive_call",
|
|
134
|
+
"end_call",
|
|
133
135
|
"execute_call",
|
|
134
136
|
"hand_off",
|
|
135
137
|
"reset_configuration",
|
|
@@ -11,6 +11,9 @@ each has one primitive:
|
|
|
11
11
|
- **where the work is handed to another process**, `hand_off()`: the values that cross the boundary,
|
|
12
12
|
and the terminal write left to the process that receives them.
|
|
13
13
|
|
|
14
|
+
A process that learns that an execution ended without writing its terminal state - an executor that
|
|
15
|
+
ended it from outside - ends the operation with `end_call()`.
|
|
16
|
+
|
|
14
17
|
The adapters do nothing but read a framework's request into a `Call` and place these primitives:
|
|
15
18
|
`taskwire.contrib.asgi.track_calls`, `taskwire.contrib.wsgi.track_calls` and
|
|
16
19
|
`taskwire.contrib.viewsets.TaskwireMiddleware`. Both context managers have an asynchronous and a
|
|
@@ -35,7 +38,7 @@ from dataclasses import dataclass
|
|
|
35
38
|
from typing import Any
|
|
36
39
|
|
|
37
40
|
from .headers import TOKEN_HEADER
|
|
38
|
-
from .models import Error, ProgressState, Text
|
|
41
|
+
from .models import DialogState, Error, ProgressState, Text
|
|
39
42
|
from .reporter import mark_failed, mark_queued, operation, OperationScope, Reporter
|
|
40
43
|
from .rest import _is_uuid
|
|
41
44
|
from .settings import configured
|
|
@@ -59,6 +62,11 @@ TW-CELERY-004)."""
|
|
|
59
62
|
DIALOG_TIMEOUT = "dialog_timeout"
|
|
60
63
|
"""An execution limit ended the work while it held an open question. `retryable: true` (TW-DLG-006)."""
|
|
61
64
|
|
|
65
|
+
INTERRUPTED = "interrupted"
|
|
66
|
+
"""The executor ended the work after it started, with no open question: a time limit, a termination, the
|
|
67
|
+
loss of the executing process. `message.params.reason` names which when the executor says. `retryable:
|
|
68
|
+
true` (TW-CALL-008)."""
|
|
69
|
+
|
|
62
70
|
HTTP_STATUS = "http_status"
|
|
63
71
|
"""The call was answered with an error status and no exception (TW-CALL-006)."""
|
|
64
72
|
|
|
@@ -77,6 +85,21 @@ def status_error(status: int) -> Error | None:
|
|
|
77
85
|
)
|
|
78
86
|
|
|
79
87
|
|
|
88
|
+
def dialog_timeout_error() -> Error:
|
|
89
|
+
"""The error of work an execution limit ended while it held an open question (TW-DLG-006)."""
|
|
90
|
+
return Error(code=DIALOG_TIMEOUT, message=Text(key="taskwire.dialog_timeout"), retryable=True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def interrupted_error(reason: str | None = None) -> Error:
|
|
94
|
+
"""The error of work the executor ended after it started (TW-CALL-008): `interrupted`, with
|
|
95
|
+
`message.params.reason` when `reason` is given."""
|
|
96
|
+
return Error(
|
|
97
|
+
code=INTERRUPTED,
|
|
98
|
+
message=Text(key="taskwire.interrupted", params={"reason": reason} if reason is not None else None),
|
|
99
|
+
retryable=True,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
80
103
|
class _Unsuccessful(Exception): # noqa: N818 - never raised; the exit's stand-in for a recorded failure
|
|
81
104
|
pass
|
|
82
105
|
|
|
@@ -399,6 +422,45 @@ def execute_call(call: Call | None, *, timeout_errors: tuple[type[BaseException]
|
|
|
399
422
|
return CallExecution(call, timeout_errors)
|
|
400
423
|
|
|
401
424
|
|
|
425
|
+
async def end_call(
|
|
426
|
+
call: Call | None,
|
|
427
|
+
error: Error,
|
|
428
|
+
*,
|
|
429
|
+
queued_only: bool = False,
|
|
430
|
+
asking_error: Error | None = None,
|
|
431
|
+
) -> bool:
|
|
432
|
+
"""End the operation of a call whose execution ended without writing its terminal state
|
|
433
|
+
(TW-CALL-008). For the process that observes such an end from outside the execution.
|
|
434
|
+
|
|
435
|
+
Writes `failed` with `error`, withdrawing the open dialogs in the same step, when the operation is
|
|
436
|
+
`queued`, `running` or `waiting_input` - only when `queued` with `queued_only`. With an open dialog
|
|
437
|
+
the error is `asking_error` when one is given. An operation holding a parked result is left as it
|
|
438
|
+
is: its work finished. Returns whether it wrote.
|
|
439
|
+
|
|
440
|
+
`call=None` and a store-less configuration make it a no-op. A store failure is logged and never
|
|
441
|
+
raised.
|
|
442
|
+
"""
|
|
443
|
+
store = configured.store
|
|
444
|
+
if call is None or store is None:
|
|
445
|
+
return False
|
|
446
|
+
try:
|
|
447
|
+
snapshot = await store.snapshot(make_key(call.session, call.token))
|
|
448
|
+
if snapshot is None or snapshot.progress.is_terminal or snapshot.progress.result is not None:
|
|
449
|
+
return False
|
|
450
|
+
if queued_only and ProgressState(snapshot.progress.state) is not ProgressState.QUEUED:
|
|
451
|
+
return False
|
|
452
|
+
asking = any(DialogState(dialog.state) is DialogState.OPEN for dialog in snapshot.dialogs)
|
|
453
|
+
await mark_failed(
|
|
454
|
+
call.token,
|
|
455
|
+
session=call.session,
|
|
456
|
+
error=asking_error if asking and asking_error is not None else error,
|
|
457
|
+
)
|
|
458
|
+
except Exception: # noqa: BLE001 - reporting is an accelerator
|
|
459
|
+
logger.warning("taskwire: could not end the operation %s", call.token, exc_info=True)
|
|
460
|
+
return False
|
|
461
|
+
return True
|
|
462
|
+
|
|
463
|
+
|
|
402
464
|
@contextlib.contextmanager
|
|
403
465
|
def hand_off() -> Iterator[dict[str, str]]:
|
|
404
466
|
"""Hand the executing call's operation to another process (TW-CALL-007).
|
|
@@ -57,7 +57,7 @@ from ..decorators import declared_result_kind
|
|
|
57
57
|
from ..headers import CONNECTION_HEADER, TOKEN_HEADER # noqa: F401 - re-exported for callers
|
|
58
58
|
from ..models import Error, Text
|
|
59
59
|
from ..reporter import mark_failed, mark_queued, operation
|
|
60
|
-
from ..settings import
|
|
60
|
+
from ..settings import configure_executor
|
|
61
61
|
|
|
62
62
|
logger = logging.getLogger("taskwire.celery")
|
|
63
63
|
|
|
@@ -170,16 +170,16 @@ async def dispatch(
|
|
|
170
170
|
raise broker_failure
|
|
171
171
|
|
|
172
172
|
|
|
173
|
-
def configure_worker(store: Any) -> None:
|
|
174
|
-
"""A worker configures its own store and
|
|
173
|
+
def configure_worker(store: Any, transport: Any = None) -> None:
|
|
174
|
+
"""A worker configures its own store and a transport that pushes to no socket (TW-CELERY-002).
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
`transport` None configures `NullTransport`: the worker writes, and the web process's reader
|
|
177
|
+
pushes. A transport whose `crosses_processes` is True, `ViewsetEventsTransport`, publishes each
|
|
178
|
+
envelope to the cross-process event store, from which every web process delivers it
|
|
179
|
+
(TW-EVT-002). Any other transport raises `ValueError`: a worker that pushed would duplicate every
|
|
180
|
+
envelope the reader is already delivering (TW-INV-002). `taskwire.configure_executor` applies the
|
|
181
|
+
rule (TW-CALL-009).
|
|
181
182
|
"""
|
|
182
183
|
from ..transport import NullTransport
|
|
183
184
|
|
|
184
|
-
|
|
185
|
-
configured.transport = NullTransport()
|
|
185
|
+
configure_executor(store=store, transport=transport if transport is not None else NullTransport())
|
|
@@ -37,10 +37,18 @@ from typing import Any
|
|
|
37
37
|
from muxws import PeerRegistry
|
|
38
38
|
from muxws.errors import ConnectionLost
|
|
39
39
|
|
|
40
|
-
from ..delivery import
|
|
40
|
+
from ..delivery import ( # noqa: F401 - CONNECTION_TAG is re-exported
|
|
41
|
+
Client,
|
|
42
|
+
connection_id,
|
|
43
|
+
CONNECTION_TAG,
|
|
44
|
+
effective_delivery,
|
|
45
|
+
may_be_suppressed,
|
|
46
|
+
OperationRef,
|
|
47
|
+
OVERRIDES_TAG,
|
|
48
|
+
)
|
|
41
49
|
from ..models import Envelope, EnvelopeKind, Progress
|
|
42
50
|
from ..reader import unwatch_namespace, watch_namespace
|
|
43
|
-
from ..settings import settings
|
|
51
|
+
from ..settings import configured, settings
|
|
44
52
|
from ..transport import TaskwireTransport
|
|
45
53
|
|
|
46
54
|
logger = logging.getLogger("taskwire.muxws")
|
|
@@ -154,32 +162,6 @@ def tag_peer(peer: Any, session: str) -> None:
|
|
|
154
162
|
# --------------------------------------------------------------------------- the watch handler
|
|
155
163
|
|
|
156
164
|
|
|
157
|
-
CONNECTION_TAG = "taskwire_connection"
|
|
158
|
-
"""The opaque, server-issued id naming one live socket (TW-DEL-016).
|
|
159
|
-
|
|
160
|
-
It is minted here and handed back only in the `watch` reply, over a socket the caller already
|
|
161
|
-
established. It is NOT stable across a reconnect and decides exactly two things: the
|
|
162
|
-
`progress_delivery` display flag, and which socket a private operation's progress is written to. It
|
|
163
|
-
reaches one store field, `origin_connection`, which is inert data recorded at start and never used
|
|
164
|
-
for authorization (TW-SEC-003).
|
|
165
|
-
"""
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def connection_id(peer: Any) -> str | None:
|
|
169
|
-
"""This socket's id, minting one on first use."""
|
|
170
|
-
tags = getattr(peer, "tags", None)
|
|
171
|
-
if not isinstance(tags, dict):
|
|
172
|
-
return None
|
|
173
|
-
existing = tags.get(CONNECTION_TAG)
|
|
174
|
-
if isinstance(existing, str):
|
|
175
|
-
return existing
|
|
176
|
-
import uuid
|
|
177
|
-
|
|
178
|
-
minted = str(uuid.uuid4())
|
|
179
|
-
tags[CONNECTION_TAG] = minted
|
|
180
|
-
return minted
|
|
181
|
-
|
|
182
|
-
|
|
183
165
|
def handle_watch(peer: Any, message: dict[str, Any]) -> dict[str, Any]:
|
|
184
166
|
"""The `watch` handler. Declares the **whole** override map, never a delta (TW-DEL-006).
|
|
185
167
|
|
|
@@ -255,6 +237,9 @@ async def serve_peer(peer: Any, session: str) -> StreamHandler:
|
|
|
255
237
|
(TW-PRIV-003) and `progress_delivery` falls back to the predicate alone. Muting would silently
|
|
256
238
|
do nothing.
|
|
257
239
|
|
|
240
|
+
With a transport that has `watch_topics(peer, session)`, `ViewsetEventsTransport`, the `watch`
|
|
241
|
+
reply also carries `topics`: the event topics this socket subscribes to (TW-EVT-005).
|
|
242
|
+
|
|
258
243
|
`watch` is the only kind the returned handler answers, and the only one that can never be a
|
|
259
244
|
viewset endpoint: it declares per-connection state held on the Peer, not an action on a resource
|
|
260
245
|
(TW-DEL-006, TW-DEL-016). Everything else - both reads, cancel, collect, dismiss and a dialog
|
|
@@ -273,7 +258,13 @@ async def serve_peer(peer: Any, session: str) -> StreamHandler:
|
|
|
273
258
|
# TW-DEL-007: this reads no store, in either direction. An override for a token that
|
|
274
259
|
# does not exist is accepted and inert - putting a per-tab display preference on the
|
|
275
260
|
# path of every socket's first message would be a poor trade.
|
|
276
|
-
|
|
261
|
+
reply = handle_watch(peer, payload)
|
|
262
|
+
# TW-EVT-005: a transport that carries the pushes over event topics names the topics
|
|
263
|
+
# this socket follows.
|
|
264
|
+
watch_topics = getattr(configured.transport, "watch_topics", None)
|
|
265
|
+
if watch_topics is not None:
|
|
266
|
+
reply["topics"] = watch_topics(peer, session)
|
|
267
|
+
await stream.reply(reply)
|
|
277
268
|
return
|
|
278
269
|
|
|
279
270
|
# TW-CORE-007: the wire vocabulary is closed. An unknown kind is answered rather than
|
|
@@ -28,8 +28,10 @@ it would make the fan-out unit smaller than the security unit.
|
|
|
28
28
|
from __future__ import annotations
|
|
29
29
|
|
|
30
30
|
import asyncio
|
|
31
|
+
import contextlib
|
|
31
32
|
import json
|
|
32
33
|
import logging
|
|
34
|
+
import weakref
|
|
33
35
|
|
|
34
36
|
from typing import Any
|
|
35
37
|
|
|
@@ -45,6 +47,7 @@ from ..models import (
|
|
|
45
47
|
Result,
|
|
46
48
|
Snapshot,
|
|
47
49
|
)
|
|
50
|
+
from ..settings import configured
|
|
48
51
|
from ..store import CANCELLED_BUTTON, Commit, DialogResolution, DialogVanished, split_key, TaskwireStore
|
|
49
52
|
|
|
50
53
|
logger = logging.getLogger("taskwire.redis")
|
|
@@ -57,6 +60,21 @@ overall deadline. A lost notification costs this much latency rather than hangin
|
|
|
57
60
|
why the loop re-reads the dialog document on every wakeup *and* on every expiry.
|
|
58
61
|
"""
|
|
59
62
|
|
|
63
|
+
|
|
64
|
+
def _blpop_seconds(client: Any) -> float:
|
|
65
|
+
"""The `BLPOP` timeout for `client`: `_BLPOP_SECONDS`, or half its `socket_timeout` if that is less.
|
|
66
|
+
|
|
67
|
+
A redis-py client aborts a read that outlasts its `socket_timeout` (5 s by default in redis-py 8)
|
|
68
|
+
with `TimeoutError`, and a blocking command is no exception. A `BLPOP` therefore has to return
|
|
69
|
+
before the socket timeout does.
|
|
70
|
+
"""
|
|
71
|
+
pool = getattr(client, "connection_pool", None)
|
|
72
|
+
socket_timeout = pool.connection_kwargs.get("socket_timeout") if pool is not None else None
|
|
73
|
+
if not socket_timeout:
|
|
74
|
+
return _BLPOP_SECONDS
|
|
75
|
+
return min(_BLPOP_SECONDS, socket_timeout / 2)
|
|
76
|
+
|
|
77
|
+
|
|
60
78
|
# --------------------------------------------------------------------------- Lua
|
|
61
79
|
|
|
62
80
|
_COMMIT = """
|
|
@@ -227,6 +245,18 @@ return released
|
|
|
227
245
|
"""
|
|
228
246
|
|
|
229
247
|
|
|
248
|
+
class _Scripts:
|
|
249
|
+
"""One Redis client and the five scripts registered on it."""
|
|
250
|
+
|
|
251
|
+
def __init__(self, client: Any) -> None:
|
|
252
|
+
self.redis = client
|
|
253
|
+
self.commit = client.register_script(_COMMIT)
|
|
254
|
+
self.put_dialog = client.register_script(_PUT_DIALOG)
|
|
255
|
+
self.resolve_dialog = client.register_script(_RESOLVE_DIALOG)
|
|
256
|
+
self.withdraw_dialogs = client.register_script(_WITHDRAW_DIALOGS)
|
|
257
|
+
self.release_result = client.register_script(_RELEASE_RESULT)
|
|
258
|
+
|
|
259
|
+
|
|
230
260
|
class RedisStore(TaskwireStore):
|
|
231
261
|
"""The cross-process store. Passes the exported conformance suite unchanged (TW-STORE-011).
|
|
232
262
|
|
|
@@ -235,12 +265,32 @@ class RedisStore(TaskwireStore):
|
|
|
235
265
|
"""
|
|
236
266
|
|
|
237
267
|
def __init__(self, url: str = "redis://127.0.0.1:6379/0", *, client: Any = None) -> None:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
268
|
+
"""A store on the Redis at `url`, or on `client`.
|
|
269
|
+
|
|
270
|
+
A `redis.asyncio` connection belongs to the event loop it was opened on. Built from `url`, the
|
|
271
|
+
store opens one client per event loop that uses it, so one store serves every loop of a process:
|
|
272
|
+
a worker's executions, its own transition loop, and the private loop of each synchronous
|
|
273
|
+
`operation()`. A `client` passed in is used on every loop as it is, and is the caller's to keep
|
|
274
|
+
on one.
|
|
275
|
+
"""
|
|
276
|
+
self._url = url
|
|
277
|
+
self._fixed = _Scripts(client) if client is not None else None
|
|
278
|
+
self._per_loop: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _Scripts] = weakref.WeakKeyDictionary()
|
|
279
|
+
|
|
280
|
+
def _bound(self) -> _Scripts:
|
|
281
|
+
if self._fixed is not None:
|
|
282
|
+
return self._fixed
|
|
283
|
+
loop = asyncio.get_running_loop()
|
|
284
|
+
bound = self._per_loop.get(loop)
|
|
285
|
+
if bound is None:
|
|
286
|
+
bound = _Scripts(aioredis.Redis.from_url(self._url, decode_responses=True))
|
|
287
|
+
self._per_loop[loop] = bound
|
|
288
|
+
return bound
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def _redis(self) -> Any:
|
|
292
|
+
"""The client of the running event loop."""
|
|
293
|
+
return self._bound().redis
|
|
244
294
|
|
|
245
295
|
# ---------------------------------------------------------------- keys
|
|
246
296
|
|
|
@@ -271,7 +321,7 @@ class RedisStore(TaskwireStore):
|
|
|
271
321
|
"""TW-STORE-014: one script call answers with the rev and the cancel flag it saw."""
|
|
272
322
|
k = self._keys(key)
|
|
273
323
|
created_ms = self._now_ms()
|
|
274
|
-
rev, cancelled = await self.
|
|
324
|
+
rev, cancelled = await self._bound().commit(
|
|
275
325
|
keys=[k["progress"], k["rev"], k["index"], k["cancel"]],
|
|
276
326
|
args=[
|
|
277
327
|
json.dumps(progress.to_dict()),
|
|
@@ -355,8 +405,18 @@ class RedisStore(TaskwireStore):
|
|
|
355
405
|
async def publish(self, ns: str, envelope: Envelope) -> None:
|
|
356
406
|
"""TW-BP-001: one channel per namespace, `twx:{ns}`, and never one per token.
|
|
357
407
|
|
|
408
|
+
With a configured transport whose `crosses_processes` is True the envelope goes to
|
|
409
|
+
`reader.dispatch` in this process instead, and nothing is published on `twx:{ns}`
|
|
410
|
+
(TW-EVT-002).
|
|
411
|
+
|
|
358
412
|
MUST NOT raise (TW-STORE-008): the state is already durable by the time this runs.
|
|
359
413
|
"""
|
|
414
|
+
if getattr(configured.transport, "crosses_processes", False):
|
|
415
|
+
from .. import reader
|
|
416
|
+
|
|
417
|
+
with contextlib.suppress(Exception):
|
|
418
|
+
await reader.dispatch(ns, envelope)
|
|
419
|
+
return
|
|
360
420
|
try:
|
|
361
421
|
await self._redis.publish(f"twx:{ns}", json.dumps(envelope.to_dict()))
|
|
362
422
|
except Exception: # noqa: BLE001 - a publish is a notification, never the copy of record
|
|
@@ -430,7 +490,7 @@ class RedisStore(TaskwireStore):
|
|
|
430
490
|
|
|
431
491
|
async def put_dialog(self, key: str, dialog: DialogRequest, ttl: float) -> int: # noqa: A002
|
|
432
492
|
k = self._keys(key)
|
|
433
|
-
rev = await self.
|
|
493
|
+
rev = await self._bound().put_dialog(
|
|
434
494
|
keys=[k["dialogs"], k["rev"], k["asking"], f"{k['reply_prefix']}{dialog.id}:reply"],
|
|
435
495
|
args=[dialog.id, json.dumps(dialog.to_dict()), int(ttl), self._now_ms(), k["token"]],
|
|
436
496
|
)
|
|
@@ -438,7 +498,7 @@ class RedisStore(TaskwireStore):
|
|
|
438
498
|
|
|
439
499
|
async def resolve_dialog(self, key: str, did: str, reply: DialogReply) -> DialogResolution: # noqa: A002
|
|
440
500
|
k = self._keys(key)
|
|
441
|
-
outcome, _rev = await self.
|
|
501
|
+
outcome, _rev = await self._bound().resolve_dialog(
|
|
442
502
|
keys=[k["dialogs"], k["rev"], k["asking"], f"{k['reply_prefix']}{did}:reply"],
|
|
443
503
|
args=[did, json.dumps(reply.to_dict()), self._now_ms(), k["token"]],
|
|
444
504
|
)
|
|
@@ -446,7 +506,7 @@ class RedisStore(TaskwireStore):
|
|
|
446
506
|
|
|
447
507
|
async def withdraw_dialogs(self, key: str) -> list[str]: # noqa: A002
|
|
448
508
|
k = self._keys(key)
|
|
449
|
-
withdrawn = await self.
|
|
509
|
+
withdrawn = await self._bound().withdraw_dialogs(
|
|
450
510
|
keys=[k["dialogs"], k["rev"], k["asking"]],
|
|
451
511
|
args=[
|
|
452
512
|
self._now_ms(),
|
|
@@ -477,14 +537,15 @@ class RedisStore(TaskwireStore):
|
|
|
477
537
|
if state is DialogState.CANCELLED:
|
|
478
538
|
return DialogReply(button=CANCELLED_BUTTON)
|
|
479
539
|
|
|
480
|
-
|
|
540
|
+
client = self._redis
|
|
541
|
+
popped = await client.blpop([reply_key], timeout=_blpop_seconds(client))
|
|
481
542
|
if popped is not None:
|
|
482
543
|
return DialogReply.from_dict(json.loads(popped[1])) or DialogReply(button=CANCELLED_BUTTON)
|
|
483
544
|
# Expired: re-read and wait again. No overall deadline is consumed by this.
|
|
484
545
|
|
|
485
546
|
async def release_result(self, key: str) -> Result | None: # noqa: A002
|
|
486
547
|
k = self._keys(key)
|
|
487
|
-
released = await self.
|
|
548
|
+
released = await self._bound().release_result(
|
|
488
549
|
keys=[k["progress"], k["rev"], k["dialogs"], k["index"]],
|
|
489
550
|
args=[self._now_ms(), k["token"]],
|
|
490
551
|
)
|
|
@@ -499,8 +560,13 @@ class RedisStore(TaskwireStore):
|
|
|
499
560
|
return RedisReader(self)
|
|
500
561
|
|
|
501
562
|
async def aclose(self) -> None:
|
|
502
|
-
"""Close the Redis
|
|
503
|
-
|
|
563
|
+
"""Close the Redis client of the running event loop, or the one the store was built with."""
|
|
564
|
+
if self._fixed is not None:
|
|
565
|
+
await self._fixed.redis.aclose()
|
|
566
|
+
return
|
|
567
|
+
bound = self._per_loop.pop(asyncio.get_running_loop(), None)
|
|
568
|
+
if bound is not None:
|
|
569
|
+
await bound.redis.aclose()
|
|
504
570
|
|
|
505
571
|
|
|
506
572
|
# --------------------------------------------------------------------------- the reader
|