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.
Files changed (68) hide show
  1. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/PKG-INFO +3 -1
  2. dynamicforms_fastapi_viewsets-0.10.0/fastapi_viewsets/__init__.py +1 -0
  3. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/conf.py +2 -2
  4. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/__init__.py +2 -0
  5. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/conftest.py +51 -17
  6. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/delivery.py +31 -79
  7. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/lifecycle.py +2 -2
  8. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/redis.py +46 -97
  9. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/sources.py +2 -2
  10. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/store.py +2 -14
  11. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/viewset.py +16 -4
  12. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/middleware/idempotency.py +58 -7
  13. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/pyproject.toml +2 -0
  14. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/__init__.py +0 -1
  15. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/.gitignore +0 -0
  16. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/LICENSE +0 -0
  17. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/README.md +0 -0
  18. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/action_configuration.py +0 -0
  19. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/backends/__init__.py +0 -0
  20. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/backends/django_orm.py +0 -0
  21. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/collection_viewset.py +0 -0
  22. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/context/__init__.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/context/auth/django.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/context/auth/static.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/cursor.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/__init__.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/build_schema.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/conftest.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/decorators/route_viewset.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/endpoint_docs.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/authorizers.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/changes.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/event.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/events/topics.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/exceptions.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/filters/__init__.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/filters/base.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/filters/declaration.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/filters/operators.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/filters/registry.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/list_query.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/list_shapes.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/middleware/__init__.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mixins.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/server.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/py.typed +0 -0
  64. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/response_classes.py +0 -0
  65. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/routing.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/save_state/__init__.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.9.1 → dynamicforms_fastapi_viewsets-0.10.0}/fastapi_viewsets/save_state/save_state.py +0 -0
  68. {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.9.1
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 that watches
74
- the topic. None is one `MemoryEventStore` for this process. A deployment of more than one
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
- return FakePubSub(self)
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. `subscribe` sends the command and returns, as redis-py's does; Redis
169
- applies it and queues its confirmation at once, or, while `held` is set, when `release()` is
170
- called.
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
- if self.held:
184
- self._unapplied.append(channel)
185
- else:
186
- self._apply_subscribe(channel)
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 SUBSCRIBEs sent while `held` was set."""
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
- async def unsubscribe(self, *channels: str) -> None:
202
- for channel in channels:
203
- if channel in self.subscribed:
204
- self.subscribed.discard(channel)
205
- self.redis.channels[channel].remove(self)
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
- """One socket following one topic."""
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`. False skips the push to that subscription's socket; an exception counts as
40
- True.
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
- # the topics this process has called store.watch() for and not store.unwatch() since
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 once the store watches the
124
- topic. A topic the socket already holds is subscribed again with `context`, and counts once.
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 topic in
128
- this process calls `store.watch(topic)`; when that raises, the subscription is removed and the
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, and the store is not called.
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. The last subscription of a
160
- topic in this process calls `store.unwatch(topic)`; an exception it raises is logged."""
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`. The topics left without subscriptions are
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 _admitted(subscription: Subscription, topic: str, rev: int, name: str, payload: Any, version: int) -> bool:
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 _admitted(subscription, topic, rev, name, payload, version):
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 and watch of this process without calling the store."""
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 the events of
12
- the topics this process watches to `deliver()`. Called from the application's lifespan."""
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 `watch` waits for Redis to confirm a SUBSCRIBE.
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}ch:{topic}`, the channel `publish` publishes the event on, as the JSON object
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()` opens the reader, a task on one pub/sub connection that
69
- subscribes to the channel of every topic `watch` names and calls `deliver()` for each message;
70
- `stop()` closes it. A topic watched before `start()` is subscribed when the reader opens.
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
- While the reader runs, `watch` returns once the reader has read Redis's confirmation of the
73
- SUBSCRIBE, so every event published on the channel after `watch` returns reaches this process.
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
- def channel(self, topic: str) -> str:
94
- return f"{self.key_prefix}ch:{topic}"
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
- self._pubsub = self.redis_client.pubsub()
105
- self._wake = asyncio.Event()
106
- for topic in sorted(self._watched):
107
- await self._pubsub.subscribe(self.channel(topic))
108
- self._listening.add(topic)
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
- try:
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(topic), message)
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=False, timeout=_READ_TIMEOUT)
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 event channels failed")
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: a message on %s could not be delivered", message.get("channel"))
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 that watches the topic with `store.publish`. An exception of `store.publish` is
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 watching processes failed", name, rev, topic, exc_info=True
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 that watches the topic.
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
- that watches the topic."""
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
- events = [
196
- PolledEvent(rev=e.rev, prev=e.prev, event=e.name, version=e.version, payload=e.payload) for e in logged
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 events)]), events=events)
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. When the call completes, its outcome replaces
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
- remaining = await self._lock_remaining(storage_key)
338
- if remaining <= 0:
339
- return None
340
- done, _ = await asyncio.wait({run.future}, timeout=remaining)
341
- if not done or run.future.result() is None:
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:
@@ -49,6 +49,8 @@ dev = [
49
49
  "asgiref",
50
50
  "PyJWT",
51
51
  "muxws[starlette,websockets]>=0.4.1",
52
+ "ruff",
53
+ "uvicorn",
52
54
  ]
53
55
 
54
56
  [project.urls]
@@ -1 +0,0 @@
1
- __version__ = "0.9.1"