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.
Files changed (62) hide show
  1. {taskwire-0.2.0 → taskwire-0.3.0}/PKG-INFO +11 -3
  2. {taskwire-0.2.0 → taskwire-0.3.0}/pyproject.toml +23 -8
  3. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/__init__.py +5 -3
  4. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/calls.py +63 -1
  5. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/celery.py +10 -10
  6. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/muxws.py +20 -29
  7. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/redis_store.py +80 -14
  8. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/viewsets.py +93 -26
  9. taskwire-0.3.0/taskwire/contrib/viewsets_events.py +291 -0
  10. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/delivery.py +26 -0
  11. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/reader.py +8 -1
  12. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/reporter.py +8 -4
  13. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/settings.py +29 -0
  14. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/transport.py +5 -0
  15. {taskwire-0.2.0 → taskwire-0.3.0}/.gitignore +0 -0
  16. {taskwire-0.2.0 → taskwire-0.3.0}/LICENSE +0 -0
  17. {taskwire-0.2.0 → taskwire-0.3.0}/README.md +0 -0
  18. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/README.md +0 -0
  19. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/sequences/.gitkeep +0 -0
  20. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/aggregate-counts.json +0 -0
  21. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/aggregate-waiting-input-dominates.json +0 -0
  22. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/dialog-request-open.json +0 -0
  23. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/envelope-cancel.json +0 -0
  24. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/envelope-progress.json +0 -0
  25. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/envelope-snapshot.json +0 -0
  26. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/error-dispatch-failed.json +0 -0
  27. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-percent-absent.json +0 -0
  28. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-percent-null.json +0 -0
  29. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-private.json +0 -0
  30. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-shared-origins.json +0 -0
  31. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-cancelled.json +0 -0
  32. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-done.json +0 -0
  33. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-failed.json +0 -0
  34. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-queued.json +0 -0
  35. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-running.json +0 -0
  36. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/progress-state-waiting-input.json +0 -0
  37. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/register-empty-null-aggregate.json +0 -0
  38. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/register-v1-with-aggregate.json +0 -0
  39. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/result-kind-and-params.json +0 -0
  40. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/result-label-and-value-no-ref.json +0 -0
  41. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/result-ref-with-expires-at.json +0 -0
  42. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/snapshot-v1-wall-clock-rev.json +0 -0
  43. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/snapshot-waiting-input-uncollected-result.json +0 -0
  44. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/text-key-and-text.json +0 -0
  45. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/text-key-only.json +0 -0
  46. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/text-key-params-and-text.json +0 -0
  47. {taskwire-0.2.0 → taskwire-0.3.0}/conformance/wire/text-text-only.json +0 -0
  48. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/ambient.py +0 -0
  49. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/conformance.py +0 -0
  50. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/__init__.py +0 -0
  51. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/asgi.py +0 -0
  52. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/fastapi.py +0 -0
  53. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/schema.py +0 -0
  54. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/threads.py +0 -0
  55. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/contrib/wsgi.py +0 -0
  56. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/decorators.py +0 -0
  57. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/headers.py +0 -0
  58. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/models.py +0 -0
  59. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/py.typed +0 -0
  60. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/register.py +0 -0
  61. {taskwire-0.2.0 → taskwire-0.3.0}/taskwire/rest.py +0 -0
  62. {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.2.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.9.0; extra == 'muxws'
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.9.0; extra == 'viewsets'
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.9.0: the execution hook, the Idempotency-Key helpers and a muxws command that runs to the end
34
- # of its action when its stream ends early (TW-CALL-*).
35
- viewsets = ["dynamicforms-fastapi-viewsets>=0.9.0"]
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.9.0"]
45
+ muxws = ["muxws>=0.4.1", "dynamicforms-fastapi-viewsets[muxws]>=0.11.1"]
41
46
  demo = ["uvicorn>=0.27"]
42
- dev = ["coverage", "pytest", "pytest-asyncio", "pytest-cov", "hypothesis", "httpx", "ruff"]
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. `ts/index.ts` is the third place the version
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
- # `ts/packaging.spec.ts` both compare against `package.json`, transitively pinning all three, but only
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 = ["ts/index.ts"]
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.2.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 configured
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 `NullTransport`, and **never pushes** (TW-CELERY-002).
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
- Store and transport configuration must not travel in a serialized context: they are objects with
177
- connections, the serialized form would be a copy nobody owns, and a worker that pushed would
178
- duplicate every envelope the reader is already delivering (TW-INV-002).
179
-
180
- The worker writes; the web process's reader pushes. That is the whole division.
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
- configured.store = store
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 Client, effective_delivery, may_be_suppressed, OperationRef, OVERRIDES_TAG
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
- await stream.reply(handle_watch(peer, payload))
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
- self._redis = client if client is not None else aioredis.Redis.from_url(url, decode_responses=True)
239
- self._commit = self._redis.register_script(_COMMIT)
240
- self._put_dialog = self._redis.register_script(_PUT_DIALOG)
241
- self._resolve_dialog = self._redis.register_script(_RESOLVE_DIALOG)
242
- self._withdraw_dialogs = self._redis.register_script(_WITHDRAW_DIALOGS)
243
- self._release_result = self._redis.register_script(_RELEASE_RESULT)
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._commit(
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._put_dialog(
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._resolve_dialog(
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._withdraw_dialogs(
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
- popped = await self._redis.blpop([reply_key], timeout=_BLPOP_SECONDS)
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._release_result(
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 connection this store was built with."""
503
- await self._redis.aclose()
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