dynamicforms-fastapi-viewsets 0.9.1__tar.gz → 0.10.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.
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/PKG-INFO +3 -1
- dynamicforms_fastapi_viewsets-0.10.0/fastapi_viewsets/__init__.py +1 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/conf.py +2 -2
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/__init__.py +2 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/conftest.py +51 -17
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/delivery.py +31 -79
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/lifecycle.py +2 -2
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/redis.py +46 -97
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/sources.py +2 -2
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/store.py +2 -14
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/viewset.py +16 -4
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/middleware/idempotency.py +58 -7
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/pyproject.toml +2 -0
- dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/__init__.py +0 -1
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/README.md +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/collection_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/cursor.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/build_schema.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/route_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/authorizers.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/changes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/event.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/topics.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/exceptions.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/filters/declaration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/middleware/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mixins.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/asgi.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/protocol.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/routing.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/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.10.0
|
|
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
|
|
@@ -40,6 +40,8 @@ Requires-Dist: pytest; extra == 'dev'
|
|
|
40
40
|
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
41
41
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
42
42
|
Requires-Dist: redis; extra == 'dev'
|
|
43
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
44
|
+
Requires-Dist: uvicorn; extra == 'dev'
|
|
43
45
|
Provides-Extra: django
|
|
44
46
|
Requires-Dist: asgiref>=3.6; extra == 'django'
|
|
45
47
|
Requires-Dist: django>=4.2; extra == 'django'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.10.0"
|
|
@@ -70,8 +70,8 @@ class Settings:
|
|
|
70
70
|
self.viewsets_event_store: EventStore | None = None
|
|
71
71
|
"""
|
|
72
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
|
|
74
|
-
|
|
73
|
+
topic's log for `poll` and carries the event to `deliver()` in every process whose store
|
|
74
|
+
is started. None is one `MemoryEventStore` for this process. A deployment of more than one
|
|
75
75
|
process, a Celery worker included, sets a `RedisEventStore` in every process.
|
|
76
76
|
"""
|
|
77
77
|
self.viewsets_event_authorizers: list[EventAuthorizer] = []
|
|
@@ -31,6 +31,7 @@ from .delivery import (
|
|
|
31
31
|
deliver,
|
|
32
32
|
DeliveryFilter,
|
|
33
33
|
drop_peer,
|
|
34
|
+
passes_filters,
|
|
34
35
|
reset_subscriptions,
|
|
35
36
|
subscribe,
|
|
36
37
|
Subscription,
|
|
@@ -74,6 +75,7 @@ __all__ = [
|
|
|
74
75
|
"get_store",
|
|
75
76
|
"is_valid_topic",
|
|
76
77
|
"is_viewset_topic",
|
|
78
|
+
"passes_filters",
|
|
77
79
|
"publish",
|
|
78
80
|
"register_events",
|
|
79
81
|
"reset_sources",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import time
|
|
5
5
|
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
import pytest
|
|
@@ -89,6 +90,8 @@ class FakeRedis:
|
|
|
89
90
|
self.expires: dict[str, float] = {}
|
|
90
91
|
self.channels: dict[str, list[FakePubSub]] = {}
|
|
91
92
|
self.commands: list[str] = []
|
|
93
|
+
self.pubsub_held = False
|
|
94
|
+
self.opened: list[FakePubSub] = []
|
|
92
95
|
|
|
93
96
|
def _expire(self, key: str) -> None:
|
|
94
97
|
if key in self.expires and self.expires[key] <= time.monotonic():
|
|
@@ -160,36 +163,68 @@ class FakeRedis:
|
|
|
160
163
|
return len(subscribers)
|
|
161
164
|
|
|
162
165
|
def pubsub(self) -> "FakePubSub":
|
|
163
|
-
|
|
166
|
+
"""A new pub/sub connection, `held` while `pubsub_held` is set; kept in `opened`."""
|
|
167
|
+
pubsub = FakePubSub(self)
|
|
168
|
+
pubsub.held = self.pubsub_held
|
|
169
|
+
self.opened.append(pubsub)
|
|
170
|
+
return pubsub
|
|
164
171
|
|
|
165
172
|
|
|
166
173
|
class FakePubSub:
|
|
167
174
|
"""
|
|
168
|
-
A pub/sub connection
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
A pub/sub connection, as `redis.asyncio.client.PubSub` without `decode_responses`.
|
|
176
|
+
|
|
177
|
+
`subscribe` and `unsubscribe` send the command and return, as redis-py's do; Redis applies it
|
|
178
|
+
and queues its confirmation at once, or, while `held` is set, when `release()` is called, in the
|
|
179
|
+
order the commands were sent. `channels` holds the channels the client has SUBSCRIBEd and not
|
|
180
|
+
UNSUBSCRIBEd. `lose_connection()` reconnects as redis-py does.
|
|
171
181
|
"""
|
|
172
182
|
|
|
173
183
|
def __init__(self, redis: FakeRedis):
|
|
174
184
|
self.redis = redis
|
|
175
185
|
self.queue: asyncio.Queue = asyncio.Queue()
|
|
176
186
|
self.subscribed: set[str] = set()
|
|
187
|
+
self.channels: set[str] = set()
|
|
177
188
|
self.closed = False
|
|
178
189
|
self.held = False
|
|
179
|
-
self._unapplied: list[str] = []
|
|
190
|
+
self._unapplied: list[tuple[Callable[[str], None], str]] = []
|
|
180
191
|
|
|
181
192
|
async def subscribe(self, *channels: str) -> None:
|
|
182
193
|
for channel in channels:
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
194
|
+
self.channels.add(channel)
|
|
195
|
+
self._send(self._apply_subscribe, channel)
|
|
196
|
+
|
|
197
|
+
async def unsubscribe(self, *channels: str) -> None:
|
|
198
|
+
for channel in channels:
|
|
199
|
+
self.channels.discard(channel)
|
|
200
|
+
self._send(self._apply_unsubscribe, channel)
|
|
201
|
+
|
|
202
|
+
def _send(self, apply: Callable[[str], None], channel: str) -> None:
|
|
203
|
+
if self.held or self._unapplied:
|
|
204
|
+
self._unapplied.append((apply, channel))
|
|
205
|
+
else:
|
|
206
|
+
apply(channel)
|
|
187
207
|
|
|
188
208
|
def release(self) -> None:
|
|
189
|
-
"""Applies and confirms the
|
|
209
|
+
"""Applies and confirms the commands sent while `held` was set."""
|
|
190
210
|
self.held = False
|
|
191
211
|
unapplied, self._unapplied = self._unapplied, []
|
|
192
|
-
for channel in unapplied:
|
|
212
|
+
for apply, channel in unapplied:
|
|
213
|
+
apply(channel)
|
|
214
|
+
|
|
215
|
+
def lose_connection(self) -> None:
|
|
216
|
+
"""
|
|
217
|
+
Drops the connection with every command Redis has not applied and every reply not read, and
|
|
218
|
+
reconnects as redis-py does: SUBSCRIBEs every channel in `channels`. Clears `held`.
|
|
219
|
+
"""
|
|
220
|
+
for channel in self.subscribed:
|
|
221
|
+
self.redis.channels[channel].remove(self)
|
|
222
|
+
self.subscribed.clear()
|
|
223
|
+
self._unapplied.clear()
|
|
224
|
+
while not self.queue.empty():
|
|
225
|
+
self.queue.get_nowait()
|
|
226
|
+
self.held = False
|
|
227
|
+
for channel in sorted(self.channels):
|
|
193
228
|
self._apply_subscribe(channel)
|
|
194
229
|
|
|
195
230
|
def _apply_subscribe(self, channel: str) -> None:
|
|
@@ -198,12 +233,11 @@ class FakePubSub:
|
|
|
198
233
|
self.redis.channels.setdefault(channel, []).append(self)
|
|
199
234
|
self.queue.put_nowait({"type": "subscribe", "channel": channel.encode(), "data": len(self.subscribed)})
|
|
200
235
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
self.queue.put_nowait({"type": "unsubscribe", "channel": channel.encode(), "data": len(self.subscribed)})
|
|
236
|
+
def _apply_unsubscribe(self, channel: str) -> None:
|
|
237
|
+
if channel in self.subscribed:
|
|
238
|
+
self.subscribed.discard(channel)
|
|
239
|
+
self.redis.channels[channel].remove(self)
|
|
240
|
+
self.queue.put_nowait({"type": "unsubscribe", "channel": channel.encode(), "data": len(self.subscribed)})
|
|
207
241
|
|
|
208
242
|
async def get_message(self, ignore_subscribe_messages: bool = False, timeout: float = 0.0):
|
|
209
243
|
try:
|
|
@@ -17,27 +17,32 @@ from typing import Any
|
|
|
17
17
|
from ..conf import settings
|
|
18
18
|
from ..context import Context
|
|
19
19
|
from ..exceptions import EventSubscriptionsExceededError
|
|
20
|
-
from .store import get_store
|
|
21
20
|
|
|
22
21
|
logger = logging.getLogger(__name__)
|
|
23
22
|
|
|
24
23
|
|
|
25
24
|
@dataclass(frozen=True, eq=False)
|
|
26
25
|
class Subscription:
|
|
27
|
-
"""
|
|
26
|
+
"""
|
|
27
|
+
One socket following one topic, or, for the delivery filters of a poll, the poll of one topic.
|
|
28
|
+
"""
|
|
28
29
|
|
|
29
|
-
peer: Any
|
|
30
|
-
"""The muxws peer of the socket.
|
|
30
|
+
peer: Any | None
|
|
31
|
+
"""The muxws peer of the socket. On a poll, the peer the poll arrived on; None for a poll over
|
|
32
|
+
REST."""
|
|
31
33
|
context: Context
|
|
32
|
-
"""The context of the `subscribe` call that made the subscription
|
|
34
|
+
"""The context of the `subscribe` call that made the subscription; on a poll, the context of the
|
|
35
|
+
poll call."""
|
|
33
36
|
topic: str
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
DeliveryFilter = Callable[[Subscription, str, int, str, Any, int], "bool | Awaitable[bool]"]
|
|
37
40
|
"""
|
|
38
41
|
`filter(subscription, topic, rev, event_name, payload, version)`, sync or async; `version` is the
|
|
39
|
-
event's `event_version`.
|
|
40
|
-
|
|
42
|
+
event's `event_version`. The filters run on every push and on every event a poll answers. False
|
|
43
|
+
skips the push to that subscription's socket, or leaves the event out of the poll answer; an
|
|
44
|
+
exception counts as True. On a poll, `subscription` is built from the poll call and its `peer` is
|
|
45
|
+
None for a poll over REST.
|
|
41
46
|
"""
|
|
42
47
|
|
|
43
48
|
# topic -> id(peer) -> Subscription
|
|
@@ -46,12 +51,7 @@ _by_topic: dict[str, dict[int, Subscription]] = {}
|
|
|
46
51
|
_by_peer: dict[int, dict[str, Subscription]] = {}
|
|
47
52
|
# id(peer) of every socket whose on_close handler is installed
|
|
48
53
|
_close_installed: set[int] = set()
|
|
49
|
-
#
|
|
50
|
-
_watched: set[str] = set()
|
|
51
|
-
# topic -> the future that completes when the running call bringing its watch in line with the
|
|
52
|
-
# table ends
|
|
53
|
-
_syncing: dict[str, asyncio.Future] = {}
|
|
54
|
-
# background tasks: pushes, and unwatches after a socket loss
|
|
54
|
+
# background tasks: pushes
|
|
55
55
|
_tasks: set[asyncio.Task] = set()
|
|
56
56
|
|
|
57
57
|
|
|
@@ -66,45 +66,6 @@ def _spawn(coroutine: Coroutine[Any, Any, Any]) -> asyncio.Task | None:
|
|
|
66
66
|
return task
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
async def _sync_watch(topic: str) -> None:
|
|
70
|
-
"""
|
|
71
|
-
Calls `store.watch(topic)` when the topic has subscriptions and is not watched, and
|
|
72
|
-
`store.unwatch(topic)` when it has none and is watched, until the two agree, and returns when
|
|
73
|
-
they agree. One call per topic runs the store at a time: a call made while another runs waits
|
|
74
|
-
for it to end, whatever its outcome, and then checks again. An exception of a store call this
|
|
75
|
-
call made propagates.
|
|
76
|
-
"""
|
|
77
|
-
while (running := _syncing.get(topic)) is not None:
|
|
78
|
-
await asyncio.shield(running)
|
|
79
|
-
done = asyncio.get_running_loop().create_future()
|
|
80
|
-
_syncing[topic] = done
|
|
81
|
-
try:
|
|
82
|
-
while True:
|
|
83
|
-
wanted = topic in _by_topic
|
|
84
|
-
if wanted == (topic in _watched):
|
|
85
|
-
return
|
|
86
|
-
store = get_store()
|
|
87
|
-
if wanted:
|
|
88
|
-
await store.watch(topic)
|
|
89
|
-
_watched.add(topic)
|
|
90
|
-
else:
|
|
91
|
-
_watched.discard(topic)
|
|
92
|
-
await store.unwatch(topic)
|
|
93
|
-
finally:
|
|
94
|
-
if _syncing.get(topic) is done:
|
|
95
|
-
del _syncing[topic]
|
|
96
|
-
done.set_result(None)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
async def _sync_watch_logged(topic: str) -> None:
|
|
100
|
-
try:
|
|
101
|
-
await _sync_watch(topic)
|
|
102
|
-
except Exception:
|
|
103
|
-
logger.warning(
|
|
104
|
-
"events: bringing the watch of topic %s in line with its subscriptions failed", topic, exc_info=True
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
69
|
def _remove(key: int, topic: str) -> Subscription | None:
|
|
109
70
|
held = _by_peer.get(key)
|
|
110
71
|
subscription = held.pop(topic, None) if held is not None else None
|
|
@@ -120,18 +81,16 @@ def _remove(key: int, topic: str) -> Subscription | None:
|
|
|
120
81
|
|
|
121
82
|
async def subscribe(peer: Any, context: Context, topic: str) -> Subscription:
|
|
122
83
|
"""
|
|
123
|
-
Subscribes the socket `peer` to `topic` and returns the subscription
|
|
124
|
-
|
|
84
|
+
Subscribes the socket `peer` to `topic` and returns the subscription. A topic the socket already
|
|
85
|
+
holds is subscribed again with `context`, and counts once. From then on `deliver()` pushes the
|
|
86
|
+
topic's events to the socket.
|
|
125
87
|
|
|
126
88
|
Raises `EventSubscriptionsExceededError` (409) when the socket already holds
|
|
127
|
-
`settings.viewsets_event_max_subscriptions` other topics. The first subscription of a
|
|
128
|
-
|
|
129
|
-
exception propagates. A subscribe that arrives while the topic's watch is being brought in line
|
|
130
|
-
waits for it. The first subscription of a socket installs one `peer.on_close` handler, which
|
|
131
|
-
drops every subscription of the socket.
|
|
89
|
+
`settings.viewsets_event_max_subscriptions` other topics. The first subscription of a socket
|
|
90
|
+
installs one `peer.on_close` handler, which drops every subscription of the socket.
|
|
132
91
|
|
|
133
92
|
A socket whose `is_open` is false is closed, and its `on_close` handlers have run: the
|
|
134
|
-
subscription is returned without entering the table
|
|
93
|
+
subscription is returned without entering the table.
|
|
135
94
|
"""
|
|
136
95
|
subscription = Subscription(peer, context, topic)
|
|
137
96
|
if not getattr(peer, "is_open", True):
|
|
@@ -146,31 +105,20 @@ async def subscribe(peer: Any, context: Context, topic: str) -> Subscription:
|
|
|
146
105
|
if key not in _close_installed:
|
|
147
106
|
_close_installed.add(key)
|
|
148
107
|
peer.on_close(lambda _reason: drop_peer(peer))
|
|
149
|
-
try:
|
|
150
|
-
await _sync_watch(topic)
|
|
151
|
-
except BaseException:
|
|
152
|
-
if _by_peer.get(key, {}).get(topic) is subscription:
|
|
153
|
-
_remove(key, topic)
|
|
154
|
-
raise
|
|
155
108
|
return subscription
|
|
156
109
|
|
|
157
110
|
|
|
158
111
|
async def unsubscribe(peer: Any, topic: str) -> None:
|
|
159
|
-
"""Removes the socket's subscription to `topic`, if it holds one.
|
|
160
|
-
|
|
161
|
-
if _remove(id(peer), topic) is not None and topic not in _by_topic:
|
|
162
|
-
await _sync_watch_logged(topic)
|
|
112
|
+
"""Removes the socket's subscription to `topic`, if it holds one."""
|
|
113
|
+
_remove(id(peer), topic)
|
|
163
114
|
|
|
164
115
|
|
|
165
116
|
def drop_peer(peer: Any) -> None:
|
|
166
|
-
"""Removes every subscription of the socket `peer`.
|
|
167
|
-
unwatched in a background task of the running event loop."""
|
|
117
|
+
"""Removes every subscription of the socket `peer`."""
|
|
168
118
|
key = id(peer)
|
|
169
119
|
_close_installed.discard(key)
|
|
170
120
|
for topic in list(_by_peer.get(key, {})):
|
|
171
121
|
_remove(key, topic)
|
|
172
|
-
if topic not in _by_topic:
|
|
173
|
-
_spawn(_sync_watch_logged(topic))
|
|
174
122
|
|
|
175
123
|
|
|
176
124
|
def subscriptions(topic: str) -> list[Subscription]:
|
|
@@ -187,7 +135,13 @@ def _is_connection_lost(error: BaseException) -> bool:
|
|
|
187
135
|
return any(cls.__name__ == "ConnectionLost" for cls in type(error).__mro__)
|
|
188
136
|
|
|
189
137
|
|
|
190
|
-
async def
|
|
138
|
+
async def passes_filters(
|
|
139
|
+
subscription: Subscription, topic: str, rev: int, name: str, payload: Any, version: int
|
|
140
|
+
) -> bool:
|
|
141
|
+
"""
|
|
142
|
+
Runs `settings.viewsets_event_delivery_filters` in order and answers False when one of them
|
|
143
|
+
answers False. A filter that raises counts as True; the exception is logged at debug level.
|
|
144
|
+
"""
|
|
191
145
|
for delivery_filter in settings.viewsets_event_delivery_filters:
|
|
192
146
|
try:
|
|
193
147
|
result = delivery_filter(subscription, topic, rev, name, payload, version)
|
|
@@ -207,7 +161,7 @@ async def _push(
|
|
|
207
161
|
subscription: Subscription, topic: str, rev: int, prev: int, name: str, payload: Any, version: int
|
|
208
162
|
) -> None:
|
|
209
163
|
async def push() -> None:
|
|
210
|
-
if await
|
|
164
|
+
if await passes_filters(subscription, topic, rev, name, payload, version):
|
|
211
165
|
headers = {":event": name, ":version": version, ":topic": topic, ":rev": rev, ":prev": prev}
|
|
212
166
|
await subscription.peer.notify(payload, headers=headers)
|
|
213
167
|
|
|
@@ -243,9 +197,7 @@ async def wait_for_pushes() -> None:
|
|
|
243
197
|
|
|
244
198
|
|
|
245
199
|
def reset_subscriptions() -> None:
|
|
246
|
-
"""Forgets every subscription
|
|
200
|
+
"""Forgets every subscription of this process."""
|
|
247
201
|
_by_topic.clear()
|
|
248
202
|
_by_peer.clear()
|
|
249
203
|
_close_installed.clear()
|
|
250
|
-
_watched.clear()
|
|
251
|
-
_syncing.clear()
|
|
@@ -8,8 +8,8 @@ logger = logging.getLogger(__name__)
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
async def start_events() -> None:
|
|
11
|
-
"""Starts the configured store: a `RedisEventStore` starts its reader, which carries
|
|
12
|
-
|
|
11
|
+
"""Starts the configured store: a `RedisEventStore` starts its reader, which carries every event
|
|
12
|
+
to `deliver()`. Called from the application's lifespan."""
|
|
13
13
|
await get_store().start()
|
|
14
14
|
|
|
15
15
|
|
|
@@ -9,7 +9,6 @@ import asyncio
|
|
|
9
9
|
import json
|
|
10
10
|
import logging
|
|
11
11
|
|
|
12
|
-
from collections import deque
|
|
13
12
|
from typing import Any, TYPE_CHECKING
|
|
14
13
|
|
|
15
14
|
from ..conf import settings
|
|
@@ -50,7 +49,7 @@ return {rev_text, prev_text}
|
|
|
50
49
|
# Seconds the reader waits for a message before it checks again, and waits after a failed read.
|
|
51
50
|
_READ_TIMEOUT = 1.0
|
|
52
51
|
|
|
53
|
-
# Seconds `
|
|
52
|
+
# Seconds `start` waits for Redis to confirm the SUBSCRIBE of the events channel.
|
|
54
53
|
_SUBSCRIBE_TIMEOUT = 5.0
|
|
55
54
|
|
|
56
55
|
|
|
@@ -62,27 +61,24 @@ class RedisEventStore:
|
|
|
62
61
|
- `{prefix}log:{topic}`, the topic's log, a list of JSON entries, newest first, of at most
|
|
63
62
|
`settings.viewsets_event_log_size` entries, expiring `settings.viewsets_event_log_ttl` seconds
|
|
64
63
|
after the topic's last event;
|
|
65
|
-
- `{prefix}
|
|
64
|
+
- `{prefix}events`, the one channel `publish` publishes every event on, as the JSON object
|
|
66
65
|
`{"topic", "rev", "prev", "name", "payload", "version"}`.
|
|
67
66
|
|
|
68
|
-
`append` is one Lua script. `start()`
|
|
69
|
-
|
|
70
|
-
`
|
|
67
|
+
`append` is one Lua script. `start()` SUBSCRIBEs the events channel on one pub/sub connection,
|
|
68
|
+
returns once Redis has confirmed it, and runs the reader, a task that calls `deliver()` for each
|
|
69
|
+
message; `deliver()` pushes the event to the subscriptions of its topic in this process and
|
|
70
|
+
leaves out a topic without one. `stop()` closes the connection.
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
When redis-py reconnects the pub/sub connection it SUBSCRIBEs the channel again. An event
|
|
73
|
+
published while the connection is down does not reach this process; the event client finds it
|
|
74
|
+
by poll, on the topic's next push or its check poll.
|
|
74
75
|
"""
|
|
75
76
|
|
|
76
77
|
def __init__(self, redis_client: "Redis", key_prefix: str = "events:"):
|
|
77
78
|
self.redis_client = redis_client
|
|
78
79
|
self.key_prefix = key_prefix
|
|
79
|
-
self._watched: set[str] = set()
|
|
80
|
-
self._listening: set[str] = set()
|
|
81
80
|
self._pubsub: Any = None
|
|
82
81
|
self._reader: asyncio.Task | None = None
|
|
83
|
-
self._wake: asyncio.Event | None = None
|
|
84
|
-
# channel -> one future per SUBSCRIBE sent and not yet confirmed, oldest first
|
|
85
|
-
self._confirming: dict[str, deque[asyncio.Future]] = {}
|
|
86
82
|
|
|
87
83
|
def rev_key(self, topic: str) -> str:
|
|
88
84
|
return f"{self.key_prefix}rev:{topic}"
|
|
@@ -90,8 +86,10 @@ class RedisEventStore:
|
|
|
90
86
|
def log_key(self, topic: str) -> str:
|
|
91
87
|
return f"{self.key_prefix}log:{topic}"
|
|
92
88
|
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
@property
|
|
90
|
+
def channel(self) -> str:
|
|
91
|
+
"""The channel every event is published on."""
|
|
92
|
+
return f"{self.key_prefix}events"
|
|
95
93
|
|
|
96
94
|
@property
|
|
97
95
|
def reader_running(self) -> bool:
|
|
@@ -99,15 +97,30 @@ class RedisEventStore:
|
|
|
99
97
|
return self._reader is not None and not self._reader.done()
|
|
100
98
|
|
|
101
99
|
async def start(self) -> None:
|
|
100
|
+
"""
|
|
101
|
+
SUBSCRIBEs the events channel and starts the reader once Redis has confirmed the SUBSCRIBE.
|
|
102
|
+
Raises `asyncio.TimeoutError` when the confirmation does not arrive within 5 seconds, and
|
|
103
|
+
the error of the connection when it fails; the store is then not started.
|
|
104
|
+
"""
|
|
102
105
|
if self.reader_running:
|
|
103
106
|
return
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
await
|
|
108
|
-
|
|
107
|
+
pubsub = self.redis_client.pubsub()
|
|
108
|
+
try:
|
|
109
|
+
await pubsub.subscribe(self.channel)
|
|
110
|
+
await asyncio.wait_for(self._confirmed(pubsub), _SUBSCRIBE_TIMEOUT)
|
|
111
|
+
except BaseException:
|
|
112
|
+
await _close(pubsub)
|
|
113
|
+
raise
|
|
114
|
+
self._pubsub = pubsub
|
|
109
115
|
self._reader = asyncio.create_task(self._read())
|
|
110
116
|
|
|
117
|
+
@staticmethod
|
|
118
|
+
async def _confirmed(pubsub: Any) -> None:
|
|
119
|
+
while True:
|
|
120
|
+
message = await pubsub.get_message(ignore_subscribe_messages=False, timeout=_READ_TIMEOUT)
|
|
121
|
+
if message is not None and message.get("type") == "subscribe":
|
|
122
|
+
return
|
|
123
|
+
|
|
111
124
|
async def stop(self) -> None:
|
|
112
125
|
reader, self._reader = self._reader, None
|
|
113
126
|
if reader is not None and not reader.done():
|
|
@@ -117,20 +130,8 @@ class RedisEventStore:
|
|
|
117
130
|
except asyncio.CancelledError:
|
|
118
131
|
pass
|
|
119
132
|
pubsub, self._pubsub = self._pubsub, None
|
|
120
|
-
self._listening.clear()
|
|
121
|
-
for pending in self._confirming.values():
|
|
122
|
-
for confirmed in pending:
|
|
123
|
-
if not confirmed.done():
|
|
124
|
-
confirmed.set_exception(
|
|
125
|
-
ConnectionError("the event reader stopped before Redis confirmed SUBSCRIBE")
|
|
126
|
-
)
|
|
127
|
-
confirmed.exception()
|
|
128
|
-
self._confirming.clear()
|
|
129
133
|
if pubsub is not None:
|
|
130
|
-
|
|
131
|
-
await pubsub.aclose()
|
|
132
|
-
except Exception:
|
|
133
|
-
logger.debug("events: closing the reader connection failed", exc_info=True)
|
|
134
|
+
await _close(pubsub)
|
|
134
135
|
|
|
135
136
|
async def append(self, topic: str, name: str, payload: Any, version: int) -> tuple[int, int]:
|
|
136
137
|
result = await self.redis_client.eval(
|
|
@@ -176,84 +177,32 @@ class RedisEventStore:
|
|
|
176
177
|
message = json.dumps(
|
|
177
178
|
{"topic": topic, "rev": rev, "prev": prev, "name": name, "payload": payload, "version": version}
|
|
178
179
|
)
|
|
179
|
-
await self.redis_client.publish(self.channel
|
|
180
|
+
await self.redis_client.publish(self.channel, message)
|
|
180
181
|
|
|
181
182
|
async def drop(self, topic: str) -> None:
|
|
182
183
|
await self.redis_client.delete(self.rev_key(topic), self.log_key(topic))
|
|
183
184
|
|
|
184
|
-
async def watch(self, topic: str) -> None:
|
|
185
|
-
"""
|
|
186
|
-
Records `topic` for `start()`. While the reader runs, SUBSCRIBEs its channel, unless it is
|
|
187
|
-
subscribed, and returns once the reader has read the confirmation of that SUBSCRIBE, or of
|
|
188
|
-
the one still unconfirmed. Raises `asyncio.TimeoutError` when no confirmation arrives within
|
|
189
|
-
5 seconds, and `ConnectionError` when the reader stops first.
|
|
190
|
-
"""
|
|
191
|
-
self._watched.add(topic)
|
|
192
|
-
if self._pubsub is None:
|
|
193
|
-
return
|
|
194
|
-
channel = self.channel(topic)
|
|
195
|
-
if topic in self._listening:
|
|
196
|
-
pending = self._confirming.get(channel)
|
|
197
|
-
if not pending:
|
|
198
|
-
return
|
|
199
|
-
confirmed = pending[-1]
|
|
200
|
-
else:
|
|
201
|
-
confirmed = asyncio.get_running_loop().create_future()
|
|
202
|
-
pending = self._confirming.setdefault(channel, deque())
|
|
203
|
-
pending.append(confirmed)
|
|
204
|
-
try:
|
|
205
|
-
await self._pubsub.subscribe(channel)
|
|
206
|
-
except BaseException:
|
|
207
|
-
pending.remove(confirmed)
|
|
208
|
-
if not pending and self._confirming.get(channel) is pending:
|
|
209
|
-
del self._confirming[channel]
|
|
210
|
-
raise
|
|
211
|
-
self._listening.add(topic)
|
|
212
|
-
self._wake.set()
|
|
213
|
-
await asyncio.wait_for(asyncio.shield(confirmed), _SUBSCRIBE_TIMEOUT)
|
|
214
|
-
|
|
215
|
-
def _confirm(self, channel: Any) -> None:
|
|
216
|
-
"""Completes the oldest unconfirmed SUBSCRIBE of `channel`."""
|
|
217
|
-
if isinstance(channel, bytes):
|
|
218
|
-
channel = channel.decode()
|
|
219
|
-
pending = self._confirming.get(channel)
|
|
220
|
-
if not pending:
|
|
221
|
-
return
|
|
222
|
-
confirmed = pending.popleft()
|
|
223
|
-
if not pending:
|
|
224
|
-
del self._confirming[channel]
|
|
225
|
-
if not confirmed.done():
|
|
226
|
-
confirmed.set_result(None)
|
|
227
|
-
|
|
228
|
-
async def unwatch(self, topic: str) -> None:
|
|
229
|
-
self._watched.discard(topic)
|
|
230
|
-
if self._pubsub is not None and topic in self._listening:
|
|
231
|
-
self._listening.discard(topic)
|
|
232
|
-
await self._pubsub.unsubscribe(self.channel(topic))
|
|
233
|
-
|
|
234
185
|
async def _read(self) -> None:
|
|
235
186
|
while True:
|
|
236
|
-
if not self._listening:
|
|
237
|
-
self._wake.clear()
|
|
238
|
-
await self._wake.wait()
|
|
239
|
-
continue
|
|
240
187
|
try:
|
|
241
|
-
message = await self._pubsub.get_message(ignore_subscribe_messages=
|
|
188
|
+
message = await self._pubsub.get_message(ignore_subscribe_messages=True, timeout=_READ_TIMEOUT)
|
|
242
189
|
except asyncio.CancelledError:
|
|
243
190
|
raise
|
|
244
191
|
except Exception:
|
|
245
|
-
logger.exception("events: reading the
|
|
192
|
+
logger.exception("events: reading the events channel failed")
|
|
246
193
|
await asyncio.sleep(_READ_TIMEOUT)
|
|
247
194
|
continue
|
|
248
|
-
if message is None:
|
|
249
|
-
continue
|
|
250
|
-
if message.get("type") == "subscribe":
|
|
251
|
-
self._confirm(message.get("channel"))
|
|
252
|
-
continue
|
|
253
|
-
if message.get("type") != "message":
|
|
195
|
+
if message is None or message.get("type") != "message":
|
|
254
196
|
continue
|
|
255
197
|
try:
|
|
256
198
|
data = json.loads(message["data"])
|
|
257
199
|
await deliver(data["topic"], data["rev"], data["prev"], data["name"], data["payload"], data["version"])
|
|
258
200
|
except Exception:
|
|
259
|
-
logger.exception("events:
|
|
201
|
+
logger.exception("events: an event on %s could not be delivered", message.get("channel"))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def _close(pubsub: Any) -> None:
|
|
205
|
+
try:
|
|
206
|
+
await pubsub.aclose()
|
|
207
|
+
except Exception:
|
|
208
|
+
logger.debug("events: closing the reader connection failed", exc_info=True)
|
|
@@ -20,7 +20,7 @@ async def publish(topic: str, event: Event) -> tuple[int, int]:
|
|
|
20
20
|
|
|
21
21
|
Raises `ValueError` for a topic of neither form; the form is all that is checked. Records the
|
|
22
22
|
event's `event_name`, payload and `event_version` with `store.append`, then carries them to
|
|
23
|
-
every process
|
|
23
|
+
every process whose store is started with `store.publish`. An exception of `store.publish` is
|
|
24
24
|
logged and not raised: the event is recorded, and a frontend learns of it on its next poll.
|
|
25
25
|
"""
|
|
26
26
|
validate_topic(topic)
|
|
@@ -35,7 +35,7 @@ async def publish(topic: str, event: Event) -> tuple[int, int]:
|
|
|
35
35
|
await store.publish(topic, rev, prev, name, payload, version)
|
|
36
36
|
except Exception:
|
|
37
37
|
logger.warning(
|
|
38
|
-
"events: carrying %s rev %s of topic %s to the
|
|
38
|
+
"events: carrying %s rev %s of topic %s to the processes failed", name, rev, topic, exc_info=True
|
|
39
39
|
)
|
|
40
40
|
return rev, prev
|
|
41
41
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""
|
|
2
2
|
The event store: it assigns each event of a topic its rev, keeps the topic's log for `poll`, and
|
|
3
|
-
carries a published event to `deliver()` in every process
|
|
3
|
+
carries a published event to `deliver()` in every process whose store is started.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import time
|
|
@@ -50,17 +50,11 @@ class EventStore(Protocol):
|
|
|
50
50
|
|
|
51
51
|
async def publish(self, topic: str, rev: int, prev: int, name: str, payload: Any, version: int) -> None:
|
|
52
52
|
"""Carries the event to `deliver(topic, rev, prev, name, payload, version)` in every process
|
|
53
|
-
|
|
53
|
+
whose store is started; `deliver()` pushes it to the subscriptions of the topic there."""
|
|
54
54
|
|
|
55
55
|
async def drop(self, topic: str) -> None:
|
|
56
56
|
"""Removes the topic's rev and log."""
|
|
57
57
|
|
|
58
|
-
async def watch(self, topic: str) -> None:
|
|
59
|
-
"""Starts carrying the topic's events to this process."""
|
|
60
|
-
|
|
61
|
-
async def unwatch(self, topic: str) -> None:
|
|
62
|
-
"""Stops carrying the topic's events to this process."""
|
|
63
|
-
|
|
64
58
|
|
|
65
59
|
def _now_ms() -> int:
|
|
66
60
|
return time.time_ns() // 1_000_000
|
|
@@ -147,12 +141,6 @@ class MemoryEventStore:
|
|
|
147
141
|
async def drop(self, topic: str) -> None:
|
|
148
142
|
self._topics.pop(topic, None)
|
|
149
143
|
|
|
150
|
-
async def watch(self, topic: str) -> None:
|
|
151
|
-
pass
|
|
152
|
-
|
|
153
|
-
async def unwatch(self, topic: str) -> None:
|
|
154
|
-
pass
|
|
155
|
-
|
|
156
144
|
|
|
157
145
|
_default_store: MemoryEventStore | None = None
|
|
158
146
|
|
|
@@ -55,6 +55,7 @@ class PolledEvent(BaseModel):
|
|
|
55
55
|
|
|
56
56
|
rev: int
|
|
57
57
|
prev: int
|
|
58
|
+
"""The rev of the event before it in the answer; for the first event, the cursor."""
|
|
58
59
|
event: str
|
|
59
60
|
"""The event's `event_name`."""
|
|
60
61
|
version: int = Field(ge=1, description="The event's `event_version`: the version of the payload's shape.")
|
|
@@ -161,6 +162,12 @@ class EventsViewSet:
|
|
|
161
162
|
to it `[]`, and any other cursor the events since it, or `events: null` when the log holds
|
|
162
163
|
no event whose `prev` is the cursor. 422 `event_cursors_exceeded` for more cursors than
|
|
163
164
|
`settings.viewsets_event_max_subscriptions`.
|
|
165
|
+
|
|
166
|
+
The delivery filters run on every event since the cursor, with a `Subscription` of the
|
|
167
|
+
topic whose `peer` is the socket the poll arrived on, None over REST, and whose `context` is
|
|
168
|
+
the poll call's. An event a filter refuses is left out of `events`, and each event's `prev`
|
|
169
|
+
is the rev of the event before it in the answer, the cursor for the first. The answer's
|
|
170
|
+
`rev` covers every event since the cursor, the ones left out included.
|
|
164
171
|
"""
|
|
165
172
|
cursors = body.cursors
|
|
166
173
|
limit = settings.viewsets_event_max_subscriptions
|
|
@@ -180,6 +187,7 @@ class EventsViewSet:
|
|
|
180
187
|
continue
|
|
181
188
|
admitted.append(topic)
|
|
182
189
|
|
|
190
|
+
peer = request.scope.get(PEER_SCOPE_KEY)
|
|
183
191
|
store = get_store()
|
|
184
192
|
revs = await store.revs(admitted) if admitted else {}
|
|
185
193
|
for topic in admitted:
|
|
@@ -192,11 +200,15 @@ class EventsViewSet:
|
|
|
192
200
|
if logged is None:
|
|
193
201
|
answers[topic] = TopicAnswer(rev=rev, events=None)
|
|
194
202
|
continue
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
203
|
+
subscription = delivery.Subscription(peer, context, topic)
|
|
204
|
+
events: list[PolledEvent] = []
|
|
205
|
+
prev = cursor
|
|
206
|
+
for e in logged:
|
|
207
|
+
if await delivery.passes_filters(subscription, topic, e.rev, e.name, e.payload, e.version):
|
|
208
|
+
events.append(PolledEvent(rev=e.rev, prev=prev, event=e.name, version=e.version, payload=e.payload))
|
|
209
|
+
prev = e.rev
|
|
198
210
|
# an event appended between `revs` and `since` is in the chain; the answer's rev covers it
|
|
199
|
-
answers[topic] = TopicAnswer(rev=max([rev, *(e.rev for e in
|
|
211
|
+
answers[topic] = TopicAnswer(rev=max([rev, *(e.rev for e in logged)]), events=events)
|
|
200
212
|
|
|
201
213
|
return PollAnswer(
|
|
202
214
|
topics={topic: answers[topic] for topic in cursors},
|
|
@@ -63,6 +63,19 @@ redis.call('SET', KEYS[2], '1', 'PX', ttl)
|
|
|
63
63
|
return 1
|
|
64
64
|
"""
|
|
65
65
|
|
|
66
|
+
# Extends the running record KEYS[1] and its attached mark KEYS[2] to ARGV[2] milliseconds, only while
|
|
67
|
+
# KEYS[1] is still the running record ARGV[1] this process wrote. Returns 1 when it was extended.
|
|
68
|
+
_RENEW_SCRIPT = """
|
|
69
|
+
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
|
|
70
|
+
return 0
|
|
71
|
+
end
|
|
72
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
73
|
+
if redis.call('EXISTS', KEYS[2]) == 1 then
|
|
74
|
+
redis.call('PEXPIRE', KEYS[2], ARGV[2])
|
|
75
|
+
end
|
|
76
|
+
return 1
|
|
77
|
+
"""
|
|
78
|
+
|
|
66
79
|
|
|
67
80
|
def parse_idempotency_key(value: str) -> str:
|
|
68
81
|
"""
|
|
@@ -232,7 +245,9 @@ class Idempotency(Middleware):
|
|
|
232
245
|
(`request_fingerprint`). A key recorded with a different fingerprint raises
|
|
233
246
|
`IdempotencyKeyReusedError` (422).
|
|
234
247
|
- The record is written when the chain reaches this middleware, as running, for
|
|
235
|
-
`settings.viewsets_idempotency_lock_ttl` seconds.
|
|
248
|
+
`settings.viewsets_idempotency_lock_ttl` seconds. While the call runs, this process extends the
|
|
249
|
+
running record to the lock TTL every third of the lock TTL, so it expires only once the process
|
|
250
|
+
stops running the call without completing it. When the call completes, its outcome replaces
|
|
236
251
|
the running record for `settings.viewsets_idempotency_ttl` seconds. An outcome of `GET` or
|
|
237
252
|
`HEAD` is stored only when a repeat attached while the call ran; an outcome with a cookie or a
|
|
238
253
|
`set-cookie` header is never stored. A record that is not stored is deleted.
|
|
@@ -294,7 +309,7 @@ class Idempotency(Middleware):
|
|
|
294
309
|
self, storage_key: str, run: _Run, method: str, call_next: Callable[[], Awaitable[ViewSetResult]]
|
|
295
310
|
) -> ViewSetResult:
|
|
296
311
|
try:
|
|
297
|
-
result = await call_next
|
|
312
|
+
result = await self._call_renewing(storage_key, run, call_next)
|
|
298
313
|
except ActionDispatchError:
|
|
299
314
|
await self._finish(storage_key, run, None, method)
|
|
300
315
|
raise
|
|
@@ -307,6 +322,34 @@ class Idempotency(Middleware):
|
|
|
307
322
|
await self._finish(storage_key, run, _Outcome(result=result), method)
|
|
308
323
|
return result
|
|
309
324
|
|
|
325
|
+
async def _call_renewing(
|
|
326
|
+
self, storage_key: str, run: _Run, call_next: Callable[[], Awaitable[ViewSetResult]]
|
|
327
|
+
) -> ViewSetResult:
|
|
328
|
+
renewal = asyncio.ensure_future(self._renew(storage_key, run))
|
|
329
|
+
try:
|
|
330
|
+
return await call_next()
|
|
331
|
+
finally:
|
|
332
|
+
renewal.cancel()
|
|
333
|
+
|
|
334
|
+
async def _renew(self, storage_key: str, run: _Run) -> None:
|
|
335
|
+
"""Extends the running record of `run` to the lock TTL every third of the lock TTL, until it is
|
|
336
|
+
cancelled or the record is no longer the one this process wrote."""
|
|
337
|
+
lock_ttl = settings.viewsets_idempotency_lock_ttl
|
|
338
|
+
while True:
|
|
339
|
+
await asyncio.sleep(lock_ttl / 3)
|
|
340
|
+
try:
|
|
341
|
+
if self.redis_client is None:
|
|
342
|
+
renewed = self._renew_in_memory(storage_key, run, lock_ttl)
|
|
343
|
+
else:
|
|
344
|
+
renewed = await self.redis_client.eval(
|
|
345
|
+
_RENEW_SCRIPT, 2, storage_key, storage_key + ":attached", run.running, _milliseconds(lock_ttl)
|
|
346
|
+
)
|
|
347
|
+
except Exception:
|
|
348
|
+
logger.exception("Idempotency: extending the running record of %s failed", storage_key)
|
|
349
|
+
continue
|
|
350
|
+
if not renewed:
|
|
351
|
+
return
|
|
352
|
+
|
|
310
353
|
async def _finish(self, storage_key: str, run: _Run, outcome: _Outcome | None, method: str) -> None:
|
|
311
354
|
"""Stores the outcome or deletes the record, publishes on the key's channel and resolves the
|
|
312
355
|
in-process repeats, in that order. `outcome` None (the call was cancelled) or an outcome that
|
|
@@ -334,11 +377,12 @@ class Idempotency(Middleware):
|
|
|
334
377
|
run = self._runs.get(storage_key)
|
|
335
378
|
if run is not None:
|
|
336
379
|
run.attached = True
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
380
|
+
while not run.future.done():
|
|
381
|
+
remaining = await self._lock_remaining(storage_key)
|
|
382
|
+
if remaining <= 0:
|
|
383
|
+
return None
|
|
384
|
+
await asyncio.wait({run.future}, timeout=remaining)
|
|
385
|
+
if run.future.result() is None:
|
|
342
386
|
return None
|
|
343
387
|
return _Record(record.fingerprint, outcome=run.future.result())
|
|
344
388
|
if self.redis_client is None:
|
|
@@ -369,6 +413,13 @@ class Idempotency(Middleware):
|
|
|
369
413
|
self._runs[storage_key] = run
|
|
370
414
|
return run
|
|
371
415
|
|
|
416
|
+
def _renew_in_memory(self, storage_key: str, run: _Run, lock_ttl: float) -> bool:
|
|
417
|
+
record = self._records.get(storage_key)
|
|
418
|
+
if record is None or record.owner != run.owner:
|
|
419
|
+
return False
|
|
420
|
+
record.expires_at = time.monotonic() + lock_ttl
|
|
421
|
+
return True
|
|
422
|
+
|
|
372
423
|
def _finish_in_memory(self, storage_key: str, run: _Run, outcome: _Outcome | None, mode: str) -> None:
|
|
373
424
|
record = self._records.get(storage_key)
|
|
374
425
|
if record is None or record.owner != run.owner:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.9.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|