dynamicforms-fastapi-viewsets 0.8.0__tar.gz → 0.9.1__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.
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/PKG-INFO +21 -12
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/README.md +17 -8
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/__init__.py +1 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/collection_viewset.py +5 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/conf.py +117 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/__init__.py +0 -4
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/build_schema.py +2 -3
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +1 -4
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/client.py +44 -41
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/decorators/celery_viewset/payload.py +56 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/server.py +44 -28
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/decorators/lifecycle_runner.py +173 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/route_viewset.py +76 -19
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/__init__.py +92 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/authorizers.py +252 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/changes.py +275 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/conftest.py +219 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/delivery.py +251 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/event.py +56 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/lifecycle.py +32 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/redis.py +259 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/sources.py +92 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/store.py +168 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/topics.py +47 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/viewset.py +224 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/exceptions.py +54 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/__init__.py +97 -6
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/auth/__init__.py +3 -3
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/middleware/idempotency.py +465 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/rate_limiter.py +2 -2
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/mux_ws/__init__.py +63 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/asgi.py +32 -10
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/registry.py +7 -67
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/server.py +66 -17
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/routing.py +78 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/pyproject.toml +6 -3
- dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/__init__.py +0 -1
- dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/conf.py +0 -59
- dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/decorators/lifecycle_runner.py +0 -117
- dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/mux_ws/__init__.py +0 -68
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/cursor.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/declaration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mixins.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/protocol.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/serialize_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: dynamicforms-fastapi-viewsets
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
|
|
5
5
|
Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
|
|
6
6
|
Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
|
|
@@ -33,8 +33,8 @@ Requires-Dist: asgiref; extra == 'dev'
|
|
|
33
33
|
Requires-Dist: celery; extra == 'dev'
|
|
34
34
|
Requires-Dist: coverage; extra == 'dev'
|
|
35
35
|
Requires-Dist: django; extra == 'dev'
|
|
36
|
-
Requires-Dist:
|
|
37
|
-
Requires-Dist: muxws[starlette,websockets]>=0.
|
|
36
|
+
Requires-Dist: httpx2; extra == 'dev'
|
|
37
|
+
Requires-Dist: muxws[starlette,websockets]>=0.4.1; extra == 'dev'
|
|
38
38
|
Requires-Dist: pyjwt; extra == 'dev'
|
|
39
39
|
Requires-Dist: pytest; extra == 'dev'
|
|
40
40
|
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
@@ -46,7 +46,7 @@ Requires-Dist: django>=4.2; extra == 'django'
|
|
|
46
46
|
Provides-Extra: jwt
|
|
47
47
|
Requires-Dist: pyjwt>=2.8; extra == 'jwt'
|
|
48
48
|
Provides-Extra: muxws
|
|
49
|
-
Requires-Dist: muxws[starlette]>=0.
|
|
49
|
+
Requires-Dist: muxws[starlette]>=0.4.1; extra == 'muxws'
|
|
50
50
|
Description-Content-Type: text/markdown
|
|
51
51
|
|
|
52
52
|
# DynamicForms FastAPI Viewsets
|
|
@@ -68,12 +68,14 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
68
68
|
extra).
|
|
69
69
|
- **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
|
|
70
70
|
- **muxws transport** — reach the same viewsets over a single WebSocket instead of one HTTP request
|
|
71
|
-
per call. A command
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
per call. A command goes through your application as an HTTP request does - its middleware,
|
|
72
|
+
exception handlers, router dependencies and state - so both transports behave identically. In the
|
|
73
|
+
demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
|
|
74
|
+
- **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
|
|
75
|
+
push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
|
|
76
|
+
its routed base path; custom events are declared with `source()`. Every event is recorded before it
|
|
77
|
+
is pushed, so a dropped push costs latency, not an event. On the frontend, `watch()` on a ViewSet
|
|
78
|
+
follows its topic and `applyChanges()` applies each change to a list of rows.
|
|
77
79
|
- **Three list shapes** — a bare array, offset paging, or cursor paging. A viewset declares which,
|
|
78
80
|
and may let a client pick per request with an `X-List-Shape` header.
|
|
79
81
|
- **Declarative filters** — declare which fields accept which operators and get query parameters,
|
|
@@ -148,8 +150,15 @@ An infinite-scrolling grid over a 5000-track library, cursor-paged. Switch the w
|
|
|
148
150
|
the REST and muxws transports and between the in-memory and SQLite backends, and compare their
|
|
149
151
|
latency side by side. Sorting and filtering are server-side.
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
The grid follows its viewset's change events (`ChangeEvents` on the backend, `watch()` in the
|
|
154
|
+
grid). Click a Favorite cell to toggle it: every tab showing the library applies the change, over
|
|
155
|
+
muxws as a push, over REST on its next poll. A sorted or filtered grid reloads its list on the
|
|
156
|
+
change: the row may have moved or left it.
|
|
157
|
+
|
|
158
|
+
`--celery` needs Redis on localhost:6379. The worker runs the viewset calls of both backends and
|
|
159
|
+
publishes their events through Redis. It runs as one process, because the in-memory library is a
|
|
160
|
+
dict held by each process; the SQLite library is one file shared by every process. The end-to-end
|
|
161
|
+
suite starts its own backend and dev server on their own ports.
|
|
153
162
|
|
|
154
163
|
## License
|
|
155
164
|
|
|
@@ -17,12 +17,14 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
17
17
|
extra).
|
|
18
18
|
- **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
|
|
19
19
|
- **muxws transport** — reach the same viewsets over a single WebSocket instead of one HTTP request
|
|
20
|
-
per call. A command
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
per call. A command goes through your application as an HTTP request does - its middleware,
|
|
21
|
+
exception handlers, router dependencies and state - so both transports behave identically. In the
|
|
22
|
+
demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
|
|
23
|
+
- **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
|
|
24
|
+
push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
|
|
25
|
+
its routed base path; custom events are declared with `source()`. Every event is recorded before it
|
|
26
|
+
is pushed, so a dropped push costs latency, not an event. On the frontend, `watch()` on a ViewSet
|
|
27
|
+
follows its topic and `applyChanges()` applies each change to a list of rows.
|
|
26
28
|
- **Three list shapes** — a bare array, offset paging, or cursor paging. A viewset declares which,
|
|
27
29
|
and may let a client pick per request with an `X-List-Shape` header.
|
|
28
30
|
- **Declarative filters** — declare which fields accept which operators and get query parameters,
|
|
@@ -97,8 +99,15 @@ An infinite-scrolling grid over a 5000-track library, cursor-paged. Switch the w
|
|
|
97
99
|
the REST and muxws transports and between the in-memory and SQLite backends, and compare their
|
|
98
100
|
latency side by side. Sorting and filtering are server-side.
|
|
99
101
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
The grid follows its viewset's change events (`ChangeEvents` on the backend, `watch()` in the
|
|
103
|
+
grid). Click a Favorite cell to toggle it: every tab showing the library applies the change, over
|
|
104
|
+
muxws as a push, over REST on its next poll. A sorted or filtered grid reloads its list on the
|
|
105
|
+
change: the row may have moved or left it.
|
|
106
|
+
|
|
107
|
+
`--celery` needs Redis on localhost:6379. The worker runs the viewset calls of both backends and
|
|
108
|
+
publishes their events through Redis. It runs as one process, because the in-memory library is a
|
|
109
|
+
dict held by each process; the SQLite library is one file shared by every process. The end-to-end
|
|
110
|
+
suite starts its own backend and dev server on their own ports.
|
|
102
111
|
|
|
103
112
|
## License
|
|
104
113
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.1"
|
|
@@ -102,6 +102,11 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
|
|
|
102
102
|
self._update_func = self._update_mapping
|
|
103
103
|
self._delete_func = container.__delitem__
|
|
104
104
|
|
|
105
|
+
@property
|
|
106
|
+
def pk_field_name(self) -> str:
|
|
107
|
+
"""`pk_field`, the name the list's cursor shape and `ChangeEvents` read the pk under."""
|
|
108
|
+
return self.pk_field
|
|
109
|
+
|
|
105
110
|
def _check_mutable(self):
|
|
106
111
|
if not self.is_mutable:
|
|
107
112
|
raise ReadOnlyCollectionError()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import Any, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
from fastapi.security.base import SecurityBase
|
|
9
|
+
|
|
10
|
+
from .context.auth import AuthBackend
|
|
11
|
+
from .events.authorizers import EventAuthorizer
|
|
12
|
+
from .events.delivery import DeliveryFilter
|
|
13
|
+
from .events.store import EventStore
|
|
14
|
+
from .middleware import CommandMiddleware
|
|
15
|
+
|
|
16
|
+
ContextProcessor = Callable[["Request", Any], Awaitable[dict[str, Any]]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Settings:
|
|
20
|
+
"""
|
|
21
|
+
Global, mutable settings for fastapi_viewsets - configured once by the application, e.g.:
|
|
22
|
+
|
|
23
|
+
from fastapi_viewsets.conf import settings
|
|
24
|
+
|
|
25
|
+
async def auth_processor(request, viewset) -> dict:
|
|
26
|
+
return {"user": await authenticate(request)}
|
|
27
|
+
|
|
28
|
+
settings.viewsets_context_processors = [auth_processor]
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.viewsets_context_processors: list[ContextProcessor] = []
|
|
33
|
+
self.viewsets_context_json_encoder: type[json.JSONEncoder] | None = None
|
|
34
|
+
self.viewsets_context_json_decoder: type[json.JSONDecoder] | None = None
|
|
35
|
+
self.viewsets_command_middleware: list[CommandMiddleware] = []
|
|
36
|
+
self.viewsets_idempotency_ttl: float = 86400
|
|
37
|
+
"""
|
|
38
|
+
Seconds an outcome recorded by `fastapi_viewsets.middleware.idempotency.Idempotency` is kept
|
|
39
|
+
after it was stored. A repeat after that runs the action again.
|
|
40
|
+
"""
|
|
41
|
+
self.viewsets_idempotency_lock_ttl: float = 600
|
|
42
|
+
"""
|
|
43
|
+
Seconds a running record of `Idempotency` is kept after it was written, unless the action
|
|
44
|
+
finishes first. Once it expires, a repeat runs the action again.
|
|
45
|
+
"""
|
|
46
|
+
self.viewsets_auth_processors: list[AuthBackend] = []
|
|
47
|
+
self.default_action_configuration: dict[Any, Any] = {}
|
|
48
|
+
self.default_list_shape: str = "plain"
|
|
49
|
+
"""
|
|
50
|
+
The shape a list endpoint answers in when the viewset does not say - "plain", "paginated"
|
|
51
|
+
or "cursor". An application that pages everything sets it once here instead of on every
|
|
52
|
+
viewset.
|
|
53
|
+
"""
|
|
54
|
+
self.viewsets_register_muxws: bool = True
|
|
55
|
+
"""
|
|
56
|
+
Whether a viewset is reachable over the muxws transport (see fastapi_viewsets/mux_ws/) when
|
|
57
|
+
it does not say either way itself. `route_viewset(register_muxws=...)` overrides this per
|
|
58
|
+
viewset, and `@transports(muxws=...)` overrides it again per endpoint - each
|
|
59
|
+
level only decides for the levels below it that left the question open (None). Read at
|
|
60
|
+
decoration time, so it must be set before any viewset class is decorated.
|
|
61
|
+
"""
|
|
62
|
+
self.viewsets_security_scheme: SecurityBase | None = None
|
|
63
|
+
"""
|
|
64
|
+
Purely for OpenAPI/Swagger discoverability (a lock icon + testable Authorize flow) - attached
|
|
65
|
+
as an extra sub-dependency alongside the per-route Middleware.depends() bridge (see
|
|
66
|
+
route_viewset.py), not otherwise consumed by this library. Must be set before any viewset
|
|
67
|
+
class is decorated with route_viewset, as `viewsets_command_middleware` must, which decides
|
|
68
|
+
`disable_response_model` when the routes are built.
|
|
69
|
+
"""
|
|
70
|
+
self.viewsets_event_store: EventStore | None = None
|
|
71
|
+
"""
|
|
72
|
+
The `EventStore` of `fastapi_viewsets.events`: it assigns each event its rev, keeps the
|
|
73
|
+
topic's log for `poll` and carries the event to `deliver()` in every process that watches
|
|
74
|
+
the topic. None is one `MemoryEventStore` for this process. A deployment of more than one
|
|
75
|
+
process, a Celery worker included, sets a `RedisEventStore` in every process.
|
|
76
|
+
"""
|
|
77
|
+
self.viewsets_event_authorizers: list[EventAuthorizer] = []
|
|
78
|
+
"""
|
|
79
|
+
The functions that decide whether a caller may follow a topic, consulted in list order on
|
|
80
|
+
every `subscribe` and on every `poll`, per topic. The first `True` admits the topic, the first
|
|
81
|
+
`False` refuses it, and no decision refuses it: an empty list refuses every topic.
|
|
82
|
+
"""
|
|
83
|
+
self.viewsets_event_delivery_filters: list[DeliveryFilter] = []
|
|
84
|
+
"""
|
|
85
|
+
The functions that decide, per subscription and per event, whether a push is sent. A filter
|
|
86
|
+
that answers False skips that socket; a filter that raises counts as True.
|
|
87
|
+
"""
|
|
88
|
+
self.viewsets_event_log_size: int = 32
|
|
89
|
+
"""
|
|
90
|
+
The number of a topic's most recent events the store keeps for `poll`. 0 keeps none, and a
|
|
91
|
+
poll from any cursor behind the topic's rev answers `events: null`.
|
|
92
|
+
"""
|
|
93
|
+
self.viewsets_event_log_ttl: float = 60.0
|
|
94
|
+
"""
|
|
95
|
+
Seconds a topic's log is kept after the topic's last event. The topic's rev counter is kept
|
|
96
|
+
without expiry. `RedisEventStore` sets it as the log key's expiry; `MemoryEventStore` empties
|
|
97
|
+
an expired log when it next reads or appends to it.
|
|
98
|
+
"""
|
|
99
|
+
self.viewsets_event_push_timeout: float = 2.0
|
|
100
|
+
"""
|
|
101
|
+
Seconds the delivery filters and `peer.notify()` of one push of one event to one socket may
|
|
102
|
+
take together. `notify()` queues the push on the socket and returns without waiting for the
|
|
103
|
+
socket to send it. A push that takes longer is cancelled and logged at debug level.
|
|
104
|
+
"""
|
|
105
|
+
self.viewsets_event_max_subscriptions: int = 256
|
|
106
|
+
"""
|
|
107
|
+
The number of topics one socket may hold; a subscription beyond it raises
|
|
108
|
+
`EventSubscriptionsExceededError` (409). Also the number of cursors one poll may carry.
|
|
109
|
+
"""
|
|
110
|
+
self.viewsets_event_poll_after_ms: int = 0
|
|
111
|
+
"""
|
|
112
|
+
`poll_after_ms` of every poll answer: the shortest interval, in milliseconds, at which a
|
|
113
|
+
client polls.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
settings = Settings()
|
|
@@ -2,8 +2,6 @@ from .celery_viewset import (
|
|
|
2
2
|
celery_viewset,
|
|
3
3
|
celery_viewset_client,
|
|
4
4
|
celery_viewset_server,
|
|
5
|
-
set_celery_dispatch_hook,
|
|
6
|
-
set_celery_kwargs_hook,
|
|
7
5
|
set_is_celery_worker,
|
|
8
6
|
)
|
|
9
7
|
from .route_viewset import route_viewset
|
|
@@ -13,7 +11,5 @@ __all__ = [
|
|
|
13
11
|
"celery_viewset_client",
|
|
14
12
|
"celery_viewset_server",
|
|
15
13
|
"route_viewset",
|
|
16
|
-
"set_celery_dispatch_hook",
|
|
17
|
-
"set_celery_kwargs_hook",
|
|
18
14
|
"set_is_celery_worker",
|
|
19
15
|
]
|
|
@@ -151,9 +151,8 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
|
|
|
151
151
|
def schema():
|
|
152
152
|
return app.openapi()
|
|
153
153
|
|
|
154
|
-
# Marks this route as the framework's own /schema endpoint.
|
|
155
|
-
#
|
|
156
|
-
# see fastapi_viewsets/mux_ws/registry.py.
|
|
154
|
+
# Marks this route as the framework's own /schema endpoint. route_viewset replaces it with
|
|
155
|
+
# one /schema route per transport, each reporting the endpoints published on that transport.
|
|
157
156
|
schema.__viewset_schema_endpoint__ = True
|
|
158
157
|
|
|
159
158
|
schema_route_kwargs = dict(
|
|
@@ -6,7 +6,6 @@ from .client import (
|
|
|
6
6
|
celery_viewset_client,
|
|
7
7
|
DEFAULT_RESULT_TIMEOUT,
|
|
8
8
|
get_registered_queue_keys,
|
|
9
|
-
set_celery_dispatch_hook,
|
|
10
9
|
)
|
|
11
10
|
from .result_reader import (
|
|
12
11
|
get_process_queue_key,
|
|
@@ -16,7 +15,7 @@ from .result_reader import (
|
|
|
16
15
|
start_result_reader,
|
|
17
16
|
stop_result_reader,
|
|
18
17
|
)
|
|
19
|
-
from .server import celery_viewset_server
|
|
18
|
+
from .server import celery_viewset_server
|
|
20
19
|
|
|
21
20
|
logger = logging.getLogger(__name__)
|
|
22
21
|
|
|
@@ -92,8 +91,6 @@ __all__ = [
|
|
|
92
91
|
"celery_viewset_server",
|
|
93
92
|
"DEFAULT_RESULT_TIMEOUT",
|
|
94
93
|
"set_is_celery_worker",
|
|
95
|
-
"set_celery_kwargs_hook",
|
|
96
|
-
"set_celery_dispatch_hook",
|
|
97
94
|
"start_result_reader",
|
|
98
95
|
"stop_result_reader",
|
|
99
96
|
"check_result_readers",
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import inspect
|
|
2
3
|
import logging
|
|
3
4
|
import uuid
|
|
4
5
|
|
|
5
|
-
from collections.abc import Awaitable, Callable
|
|
6
6
|
from functools import wraps
|
|
7
7
|
from typing import TYPE_CHECKING, TypeVar
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
11
|
from ...context import Context, SerializableObject, serialize_context, to_jsonable
|
|
12
|
-
from ...exceptions import ResultTimeoutError
|
|
12
|
+
from ...exceptions import ActionDispatchError, ResultTimeoutError
|
|
13
13
|
from ..build_schema import build_schema
|
|
14
|
+
from ..lifecycle_runner import current_call, DEFERRED_ACTIONS_ATTRIBUTE
|
|
14
15
|
from ..primary_key_model_helper import PARTIAL_UPDATE_ACTIONS
|
|
15
16
|
from . import result_reader
|
|
17
|
+
from .payload import CONTEXT_KWARG, PARENT_KWARG, REQUEST_KWARG, request_to_jsonable
|
|
16
18
|
from .result_reader import get_process_queue_key, get_result_queue_key
|
|
17
19
|
|
|
18
20
|
if TYPE_CHECKING:
|
|
@@ -25,20 +27,6 @@ logger = logging.getLogger(__name__)
|
|
|
25
27
|
|
|
26
28
|
DEFAULT_RESULT_TIMEOUT = 3600.0
|
|
27
29
|
|
|
28
|
-
_celery_dispatch_hook: Callable[[dict], Awaitable[dict]] | None = None
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def set_celery_dispatch_hook(hook: Callable[[dict], Awaitable[dict]] | None) -> None:
|
|
32
|
-
"""Register a callable awaited just before send_task; its return dict is merged into kwargs.
|
|
33
|
-
|
|
34
|
-
Receives the call's current kwargs (context included, if the action declares one) so a
|
|
35
|
-
registered hook can read what a context processor already put there instead of needing its own
|
|
36
|
-
request-level plumbing. Returns {} when there is nothing to add.
|
|
37
|
-
"""
|
|
38
|
-
global _celery_dispatch_hook
|
|
39
|
-
_celery_dispatch_hook = hook
|
|
40
|
-
|
|
41
|
-
|
|
42
30
|
# queue_keys registered by celery_viewset_client so far - populated at decoration (import) time,
|
|
43
31
|
# used by check_result_readers() to spot a queue_key with no running result reader.
|
|
44
32
|
_registered_queue_keys: set[str] = set()
|
|
@@ -111,8 +99,13 @@ async def _serialize_value(value, partial: bool = False):
|
|
|
111
99
|
def _patch_method(
|
|
112
100
|
cls: type, original_endpoint, task_name: str, celery_app, queue_key: str, result_timeout: float | None
|
|
113
101
|
):
|
|
114
|
-
"""Replace the method on cls with an async version that sends a Celery task and awaits the result.
|
|
102
|
+
"""Replace the method on cls with an async version that sends a Celery task and awaits the result.
|
|
103
|
+
|
|
104
|
+
The task carries the call's context, request and parent execution (see `payload`) from
|
|
105
|
+
`current_call()`; a `context` the caller passes replaces the call's. A send that fails raises
|
|
106
|
+
`ActionDispatchError`. The action's name joins the class's `DEFERRED_ACTIONS_ATTRIBUTE`."""
|
|
115
107
|
method_name = original_endpoint.__name__
|
|
108
|
+
declares_request = "request" in inspect.signature(original_endpoint).parameters
|
|
116
109
|
|
|
117
110
|
@wraps(original_endpoint)
|
|
118
111
|
async def async_client_wrapper(_self, *args, **kwargs):
|
|
@@ -122,32 +115,41 @@ def _patch_method(
|
|
|
122
115
|
result_reader.register_future(correlation_id, future, queue_key)
|
|
123
116
|
|
|
124
117
|
try:
|
|
118
|
+
call = current_call()
|
|
119
|
+
context = kwargs.pop("context", None)
|
|
120
|
+
if context is None:
|
|
121
|
+
context = call.context if call is not None else Context({})
|
|
122
|
+
if declares_request:
|
|
123
|
+
kwargs.pop("request", None)
|
|
124
|
+
parent = call.parent_of_dispatch() if call is not None else None
|
|
125
125
|
serializable_args = [await _serialize_value(v) for v in args]
|
|
126
|
-
serializable_kwargs = {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
serializable_kwargs[k] = await serialize_context(v.raw())
|
|
130
|
-
else:
|
|
131
|
-
serializable_kwargs[k] = await _serialize_value(v, method_name in PARTIAL_UPDATE_ACTIONS)
|
|
126
|
+
serializable_kwargs = {
|
|
127
|
+
k: await _serialize_value(v, method_name in PARTIAL_UPDATE_ACTIONS) for k, v in kwargs.items()
|
|
128
|
+
}
|
|
132
129
|
logger.info("Celery task scheduling: %s (correlation_id=%s)", task_name, correlation_id)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
130
|
+
try:
|
|
131
|
+
# send_task() blocks while it connects and publishes to the broker.
|
|
132
|
+
await asyncio.to_thread(
|
|
133
|
+
celery_app.send_task,
|
|
134
|
+
task_name,
|
|
135
|
+
args=serializable_args,
|
|
136
|
+
kwargs={
|
|
137
|
+
**serializable_kwargs,
|
|
138
|
+
CONTEXT_KWARG: await serialize_context(context.raw()),
|
|
139
|
+
REQUEST_KWARG: request_to_jsonable(call.request if call is not None else None),
|
|
140
|
+
PARENT_KWARG: parent.to_jsonable() if parent is not None else None,
|
|
141
|
+
"_correlation_id": correlation_id,
|
|
142
|
+
"_result_queue_key": get_process_queue_key(queue_key),
|
|
143
|
+
},
|
|
144
|
+
# send_task() defaults ignore_result to False regardless of what the task was
|
|
145
|
+
# registered with (celery_viewset_server sets ignore_result=True) - Celery's
|
|
146
|
+
# per-request value overrides the task's own, so it must be repeated here or the
|
|
147
|
+
# worker still tries to JSON-encode the raw return value into its result backend.
|
|
148
|
+
ignore_result=True,
|
|
149
|
+
)
|
|
150
|
+
except Exception as error:
|
|
151
|
+
logger.error("Celery task not sent: %s (correlation_id=%s)", task_name, correlation_id)
|
|
152
|
+
raise ActionDispatchError(task_name) from error
|
|
151
153
|
try:
|
|
152
154
|
return await asyncio.wait_for(future, result_timeout)
|
|
153
155
|
except asyncio.TimeoutError:
|
|
@@ -157,3 +159,4 @@ def _patch_method(
|
|
|
157
159
|
result_reader.unregister_future(correlation_id)
|
|
158
160
|
|
|
159
161
|
setattr(cls, method_name, async_client_wrapper)
|
|
162
|
+
setattr(cls, DEFERRED_ACTIONS_ATTRIBUTE, frozenset(getattr(cls, DEFERRED_ACTIONS_ATTRIBUTE, ())) | {method_name})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The call data a `celery_viewset` task carries beside the action's own arguments: the call's context,
|
|
3
|
+
its request and the execution it was dispatched in. `celery_viewset_client` writes them under the
|
|
4
|
+
reserved kwargs below, and `celery_viewset_server` consumes them before the action is called.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
from fastapi.encoders import jsonable_encoder
|
|
11
|
+
|
|
12
|
+
CONTEXT_KWARG = "_context"
|
|
13
|
+
"""The call's context, as `serialize_context` encodes it."""
|
|
14
|
+
REQUEST_KWARG = "_request"
|
|
15
|
+
"""The call's request, as `request_to_jsonable` encodes it; None without one."""
|
|
16
|
+
PARENT_KWARG = "_parent"
|
|
17
|
+
"""The `ExecutionRef` the call was dispatched in, as `ExecutionRef.to_jsonable` encodes it; None
|
|
18
|
+
for a call a request made."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _address(value: Any) -> list | None:
|
|
22
|
+
return list(value) if value is not None else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def request_to_jsonable(request: Request | None) -> dict[str, Any] | None:
|
|
26
|
+
"""The ASGI scope of `request` without its body, `app` and `state`, JSON-safe."""
|
|
27
|
+
if request is None:
|
|
28
|
+
return None
|
|
29
|
+
scope = request.scope
|
|
30
|
+
return {
|
|
31
|
+
"type": scope.get("type", "http"),
|
|
32
|
+
"http_version": scope.get("http_version", "1.1"),
|
|
33
|
+
"method": scope.get("method", "GET"),
|
|
34
|
+
"scheme": scope.get("scheme", "http"),
|
|
35
|
+
"path": scope.get("path", ""),
|
|
36
|
+
"root_path": scope.get("root_path", ""),
|
|
37
|
+
"query_string": scope.get("query_string", b"").decode("latin-1"),
|
|
38
|
+
"headers": [[name.decode("latin-1"), value.decode("latin-1")] for name, value in scope.get("headers", [])],
|
|
39
|
+
"path_params": jsonable_encoder(scope.get("path_params", {})),
|
|
40
|
+
"client": _address(scope.get("client")),
|
|
41
|
+
"server": _address(scope.get("server")),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def request_from_jsonable(data: dict[str, Any] | None) -> Request | None:
|
|
46
|
+
"""A `Request` over the scope `request_to_jsonable` encoded. Reading its body raises."""
|
|
47
|
+
if data is None:
|
|
48
|
+
return None
|
|
49
|
+
scope = {
|
|
50
|
+
**data,
|
|
51
|
+
"query_string": data["query_string"].encode("latin-1"),
|
|
52
|
+
"headers": [(name.encode("latin-1"), value.encode("latin-1")) for name, value in data["headers"]],
|
|
53
|
+
"client": tuple(data["client"]) if data["client"] is not None else None,
|
|
54
|
+
"server": tuple(data["server"]) if data["server"] is not None else None,
|
|
55
|
+
}
|
|
56
|
+
return Request(scope)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import contextlib
|
|
2
3
|
import functools
|
|
3
4
|
import inspect
|
|
4
5
|
import logging
|
|
5
6
|
import types
|
|
6
7
|
|
|
7
|
-
from collections.abc import Callable
|
|
8
8
|
from functools import wraps
|
|
9
|
-
from typing import get_args, get_origin, get_type_hints, TYPE_CHECKING, TypeVar, Union
|
|
9
|
+
from typing import Any, get_args, get_origin, get_type_hints, TYPE_CHECKING, TypeVar, Union
|
|
10
10
|
|
|
11
11
|
from fastapi import HTTPException
|
|
12
12
|
from fastapi.encoders import jsonable_encoder
|
|
@@ -14,6 +14,7 @@ from fastapi.exceptions import RequestValidationError
|
|
|
14
14
|
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
15
15
|
|
|
16
16
|
from ...context import Context, deserialize_context, to_jsonable
|
|
17
|
+
from ...middleware import ExecutionRef
|
|
17
18
|
from ..build_schema import build_schema
|
|
18
19
|
from ..lifecycle_runner import lifecycle_runner, LifecycleType
|
|
19
20
|
from ..primary_key_model_helper import (
|
|
@@ -24,6 +25,7 @@ from ..primary_key_model_helper import (
|
|
|
24
25
|
typecast_to_original_model,
|
|
25
26
|
)
|
|
26
27
|
from ..route_viewset import build_type_map, resolve_typevars
|
|
28
|
+
from .payload import CONTEXT_KWARG, PARENT_KWARG, request_from_jsonable, REQUEST_KWARG
|
|
27
29
|
from .result_reader import push_payload
|
|
28
30
|
|
|
29
31
|
if TYPE_CHECKING:
|
|
@@ -35,19 +37,6 @@ logger = logging.getLogger(__name__)
|
|
|
35
37
|
|
|
36
38
|
T = TypeVar("T")
|
|
37
39
|
|
|
38
|
-
_celery_kwargs_hook: Callable[[Callable, dict], tuple[Callable, dict]] | None = None
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def set_celery_kwargs_hook(hook: Callable[[Callable, dict], tuple[Callable, dict]] | None) -> None:
|
|
42
|
-
"""Register a callable that may wrap the runner and/or consume kwargs before reconstruction.
|
|
43
|
-
|
|
44
|
-
Receives (runner, kwargs), must return (runner, kwargs). Called with kwargs still raw - before
|
|
45
|
-
_reconstruct_kwargs, which passes through any key it has no type hint for, so a key the hook
|
|
46
|
-
does not consume would otherwise reach the action as an argument it never declared.
|
|
47
|
-
"""
|
|
48
|
-
global _celery_kwargs_hook
|
|
49
|
-
_celery_kwargs_hook = hook
|
|
50
|
-
|
|
51
40
|
|
|
52
41
|
def _unwrap_optional(hint):
|
|
53
42
|
"""Before Python 3.11, `get_type_hints()` implicitly wraps a None-defaulted parameter's
|
|
@@ -119,17 +108,11 @@ _model_without_pk = functools.lru_cache(create_model_without_pk)
|
|
|
119
108
|
def _reconstruct_kwargs(original_endpoint, kwargs: dict, cls: type = None) -> dict:
|
|
120
109
|
"""Reconstruct dict values back into Pydantic BaseModel instances based on endpoint type hints.
|
|
121
110
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
may need SerializableObject-aware reconstruction.
|
|
111
|
+
The call's context, request and parent arrive under the reserved kwargs of `payload`, which the
|
|
112
|
+
task consumes before this runs.
|
|
125
113
|
"""
|
|
126
114
|
result = {}
|
|
127
|
-
remaining =
|
|
128
|
-
for key, value in kwargs.items():
|
|
129
|
-
if key == "context" and isinstance(value, dict):
|
|
130
|
-
result[key] = Context(deserialize_context(value))
|
|
131
|
-
else:
|
|
132
|
-
remaining[key] = value
|
|
115
|
+
remaining = dict(kwargs)
|
|
133
116
|
|
|
134
117
|
try:
|
|
135
118
|
hints = get_type_hints(original_endpoint)
|
|
@@ -154,6 +137,22 @@ def _reconstruct_kwargs(original_endpoint, kwargs: dict, cls: type = None) -> di
|
|
|
154
137
|
return result
|
|
155
138
|
|
|
156
139
|
|
|
140
|
+
def _run_to_end(loop: asyncio.AbstractEventLoop, coroutine) -> Any:
|
|
141
|
+
"""Runs `coroutine` on `loop` as a task and returns its result. An exception that interrupts the
|
|
142
|
+
loop while the task is suspended (Celery's time limit, raised from a signal handler) cancels the
|
|
143
|
+
task and runs it until it has unwound, so its `finally` blocks and `Middleware.execute()` see the
|
|
144
|
+
interruption; the exception is then raised."""
|
|
145
|
+
task = loop.create_task(coroutine)
|
|
146
|
+
try:
|
|
147
|
+
return loop.run_until_complete(task)
|
|
148
|
+
except BaseException:
|
|
149
|
+
if not task.done():
|
|
150
|
+
task.cancel()
|
|
151
|
+
with contextlib.suppress(BaseException): # the interruption is what propagates
|
|
152
|
+
loop.run_until_complete(task)
|
|
153
|
+
raise
|
|
154
|
+
|
|
155
|
+
|
|
157
156
|
def celery_viewset_server(
|
|
158
157
|
celery_app: "Celery",
|
|
159
158
|
task_prefix: str,
|
|
@@ -165,6 +164,8 @@ def celery_viewset_server(
|
|
|
165
164
|
instance = cls() if lifecycle == "singleton" else None
|
|
166
165
|
|
|
167
166
|
def get_sync_wrapper(original_endpoint, task_name: str):
|
|
167
|
+
parameters = inspect.signature(original_endpoint).parameters
|
|
168
|
+
|
|
168
169
|
@wraps(original_endpoint)
|
|
169
170
|
def sync_wrapper(*args, **kwargs):
|
|
170
171
|
nonlocal instance
|
|
@@ -186,11 +187,26 @@ def celery_viewset_server(
|
|
|
186
187
|
|
|
187
188
|
logger.info("Celery task executing: %s (correlation_id=%s)", task_name, correlation_id)
|
|
188
189
|
try:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
context = Context(deserialize_context(kwargs.pop(CONTEXT_KWARG, None) or {}))
|
|
191
|
+
request = request_from_jsonable(kwargs.pop(REQUEST_KWARG, None))
|
|
192
|
+
parent = ExecutionRef.from_jsonable(kwargs.pop(PARENT_KWARG, None))
|
|
192
193
|
kwargs = _reconstruct_kwargs(original_endpoint, kwargs, cls)
|
|
193
|
-
result =
|
|
194
|
+
result = _run_to_end(
|
|
195
|
+
loop,
|
|
196
|
+
lifecycle_runner(
|
|
197
|
+
original_endpoint,
|
|
198
|
+
instance,
|
|
199
|
+
cls,
|
|
200
|
+
lifecycle,
|
|
201
|
+
*args,
|
|
202
|
+
needs_context="context" in parameters,
|
|
203
|
+
request=request,
|
|
204
|
+
forwards_request="request" in parameters,
|
|
205
|
+
context=context,
|
|
206
|
+
parent=parent,
|
|
207
|
+
**kwargs,
|
|
208
|
+
),
|
|
209
|
+
)
|
|
194
210
|
logger.info("Celery task completed: %s (correlation_id=%s)", task_name, correlation_id)
|
|
195
211
|
if correlation_id and result_queue_key and redis_client is not None:
|
|
196
212
|
push_payload(
|