django-aiogram 4.0.0.dev0__py3-none-any.whl
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.
- django_aiogram/__init__.py +64 -0
- django_aiogram/_singleton.py +22 -0
- django_aiogram/admin.py +380 -0
- django_aiogram/api.py +38 -0
- django_aiogram/apps.py +56 -0
- django_aiogram/config/__init__.py +15 -0
- django_aiogram/config/checks.py +759 -0
- django_aiogram/config/defaults.py +102 -0
- django_aiogram/config/enums.py +108 -0
- django_aiogram/config/settings.py +266 -0
- django_aiogram/consumer/__init__.py +13 -0
- django_aiogram/consumer/delivery.py +625 -0
- django_aiogram/consumer/routers.py +19 -0
- django_aiogram/consumer/webhook.py +154 -0
- django_aiogram/context.py +34 -0
- django_aiogram/eventlog/__init__.py +16 -0
- django_aiogram/eventlog/dbrouter.py +55 -0
- django_aiogram/eventlog/events.py +120 -0
- django_aiogram/eventlog/instrumentation.py +231 -0
- django_aiogram/eventlog/recorder.py +922 -0
- django_aiogram/eventlog/signals.py +84 -0
- django_aiogram/eventlog/writer.py +231 -0
- django_aiogram/exceptions.py +60 -0
- django_aiogram/healthcheck.py +412 -0
- django_aiogram/management/__init__.py +1 -0
- django_aiogram/management/commands/__init__.py +1 -0
- django_aiogram/management/commands/start_tgbot.py +308 -0
- django_aiogram/management/commands/tgbot_healthcheck.py +57 -0
- django_aiogram/management/commands/tgbot_prune_events.py +144 -0
- django_aiogram/management/commands/tgbot_reclaim.py +135 -0
- django_aiogram/management/commands/tgbot_webhook.py +87 -0
- django_aiogram/migrations/0001_initial.py +50 -0
- django_aiogram/migrations/0002_kind_id_index.py +32 -0
- django_aiogram/migrations/__init__.py +1 -0
- django_aiogram/models.py +79 -0
- django_aiogram/producer/__init__.py +13 -0
- django_aiogram/producer/client.py +1540 -0
- django_aiogram/producer/throttling.py +336 -0
- django_aiogram/py.typed +0 -0
- django_aiogram/redis.py +394 -0
- django_aiogram/wire/__init__.py +14 -0
- django_aiogram/wire/envelope.py +146 -0
- django_aiogram/wire/payloads.py +195 -0
- django_aiogram/wire/serializers.py +533 -0
- django_aiogram-4.0.0.dev0.dist-info/METADATA +145 -0
- django_aiogram-4.0.0.dev0.dist-info/RECORD +48 -0
- django_aiogram-4.0.0.dev0.dist-info/WHEEL +4 -0
- django_aiogram-4.0.0.dev0.dist-info/licenses/LICENSE +21 -0
django_aiogram/redis.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""The one Redis connection this package shares.
|
|
2
|
+
|
|
3
|
+
Senders, consumers, the FSM heartbeat and the management commands all go through
|
|
4
|
+
:func:`get_redis`, so a process opens a single connection pool however many of
|
|
5
|
+
them are running. The connection is built on first use rather than at import,
|
|
6
|
+
because Django settings are not readable while the app registry is loading.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
import weakref
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
17
|
+
from django.core.signals import setting_changed
|
|
18
|
+
from redis import Redis
|
|
19
|
+
from redis.asyncio import Redis as AsyncRedis
|
|
20
|
+
from redis.connection import parse_url as _parse_url
|
|
21
|
+
|
|
22
|
+
from django_aiogram.config.settings import SETTINGS_NAME, conf
|
|
23
|
+
from django_aiogram.eventlog.events import worker_identity
|
|
24
|
+
|
|
25
|
+
#: redis-py ships py.typed but leaves parse_url unannotated, and strict mode refuses
|
|
26
|
+
#: to call it. Naming the shape here keeps the call site honest without an ignore
|
|
27
|
+
parse_url: Callable[[str], dict[str, Any]] = _parse_url
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
#: the package logger, so a contained failure here is still visible
|
|
31
|
+
logger = logging.getLogger('django_aiogram')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_timeout() -> int:
|
|
35
|
+
"""How long any single Redis call may take before the server is dead to us."""
|
|
36
|
+
return max(1, int(conf['REDIS_TIMEOUT']))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def connection_kwargs() -> dict[str, Any]:
|
|
40
|
+
"""How every client this package builds is configured.
|
|
41
|
+
|
|
42
|
+
Its own, rather than a detail of :func:`build_client`, because the FSM storage
|
|
43
|
+
builds a second client that this package never touches again — and it went
|
|
44
|
+
without any deadline at all until it was handed these.
|
|
45
|
+
|
|
46
|
+
Note that redis-py resolves a URL *after* keyword arguments and documents that
|
|
47
|
+
"querystring arguments always win", so anything here is a default a project can
|
|
48
|
+
still override with a query string on ``REDIS_URL``.
|
|
49
|
+
"""
|
|
50
|
+
timeout = read_timeout()
|
|
51
|
+
return {'socket_connect_timeout': timeout, 'socket_timeout': timeout}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def queue_key() -> str:
|
|
55
|
+
"""Return the list queued messages are written to and read from."""
|
|
56
|
+
return str(conf['REDIS_MESSAGES_KEY'])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def processing_key(worker: str | None = None) -> str:
|
|
60
|
+
"""Where one worker keeps the message it is sending.
|
|
61
|
+
|
|
62
|
+
Per worker, so a restarting one reclaims only its own: a shared list would let
|
|
63
|
+
a starting worker pull a message out from under another that is still sending
|
|
64
|
+
it. Takes a name so `tgbot_reclaim` can address a worker that is gone.
|
|
65
|
+
"""
|
|
66
|
+
return f'{queue_key()}:processing:{worker or worker_identity()}'
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def processing_pattern() -> str:
|
|
70
|
+
"""Match every worker's in-flight list, this one included.
|
|
71
|
+
|
|
72
|
+
Derived from :func:`processing_key` rather than spelled out again: the healthcheck
|
|
73
|
+
scans for these, and a probe that re-wrote the scheme by hand would keep scanning the
|
|
74
|
+
old one after a rename — silently reporting no stranded messages for ever, with the
|
|
75
|
+
suite green because the tests that hold the literal would have been updated.
|
|
76
|
+
|
|
77
|
+
The key is escaped because ``SCAN MATCH`` takes a glob, and a queue key is an operator's
|
|
78
|
+
string: ``REDIS_MESSAGES_KEY = 'tg[staging]'`` turned into a character class that
|
|
79
|
+
matches nothing this package ever writes, so the sweep reported zero stranded lists —
|
|
80
|
+
and reported the scan as *complete*, which is the one answer a wrong pattern must not
|
|
81
|
+
give. Only the key is escaped; the ``*`` is the wildcard this function exists for.
|
|
82
|
+
"""
|
|
83
|
+
return f'{_escaped(queue_key())}:processing:*'
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _escaped(literal: str) -> str:
|
|
87
|
+
"""Quote the glob metacharacters Redis honours in a ``MATCH`` pattern.
|
|
88
|
+
|
|
89
|
+
``^`` is in the set and cannot change an outcome here, which is worth writing down
|
|
90
|
+
rather than rediscovering: it is special only as the first character inside an
|
|
91
|
+
*unescaped* ``[...]``, and ``[`` is always escaped one line above. Measured both ways
|
|
92
|
+
against ``TG[^x]`` and ``TG^x`` — same match, escaped or not. Kept because the set is
|
|
93
|
+
the one Redis documents, and a future caller that builds a class deliberately would
|
|
94
|
+
want it; not covered by a test, because no test could fail.
|
|
95
|
+
"""
|
|
96
|
+
return ''.join(f'\\{character}' if character in '*?[]^\\' else character for character in literal)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def heartbeat_key(worker: str | None = None) -> str:
|
|
100
|
+
"""Where one worker says it is still turning. Per worker, like the list above."""
|
|
101
|
+
return f'{queue_key()}:heartbeat:{worker or worker_identity()}'
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def heartbeat_interval() -> int:
|
|
105
|
+
"""How often the consumer refreshes that key, never below a second."""
|
|
106
|
+
return max(1, int(conf['HEARTBEAT_INTERVAL']))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def heartbeat_ttl(interval: int | None = None) -> int:
|
|
110
|
+
"""How long the key survives without a refresh: three intervals.
|
|
111
|
+
|
|
112
|
+
One place, because it is also a ceiling on what any reader can observe. A probe given
|
|
113
|
+
`--max-age` above this can never see a stale heartbeat — the key is simply gone — so
|
|
114
|
+
the two numbers have to be derived from each other rather than agreed on by hand in
|
|
115
|
+
the writer, the probe and three pages of the wiki. Takes an interval so a caller that
|
|
116
|
+
has already read and vetted the setting does not read it twice.
|
|
117
|
+
"""
|
|
118
|
+
return (heartbeat_interval() if interval is None else interval) * 3
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_client() -> Redis:
|
|
122
|
+
"""Build a client bounded in time, so no call can hang for ever.
|
|
123
|
+
|
|
124
|
+
redis-py only started defaulting to a read deadline in 8.0; on the 6.2 floor
|
|
125
|
+
a server that accepts the connection and then stops answering blocks the
|
|
126
|
+
caller until the process is killed. Blocking reads stay inside the deadline
|
|
127
|
+
by asking for less than it — see :class:`~django_aiogram.consumer.delivery.BlpopDelivery`.
|
|
128
|
+
|
|
129
|
+
Commands are deliberately **not** retried. ``Redis.from_url`` builds the pool
|
|
130
|
+
first, so redis-py's client-level retry default never reaches the connection
|
|
131
|
+
and every command runs with ``Retry(NoBackoff(), 0)``; that was an accident,
|
|
132
|
+
and this docstring is what makes it a decision. Neither command on the hot path
|
|
133
|
+
is idempotent — a connection dropped after the server applied an ``RPUSH`` but
|
|
134
|
+
before the reply arrived would queue the message twice, and the consumer would
|
|
135
|
+
send a real person two of them. The connection-drop case is already handled
|
|
136
|
+
where it can be handled safely: the consumer logs and goes round its loop
|
|
137
|
+
again, and a failed ``send_redis`` records the drop and raises so the caller
|
|
138
|
+
knows nothing was queued.
|
|
139
|
+
"""
|
|
140
|
+
url = conf['REDIS_URL']
|
|
141
|
+
if not url:
|
|
142
|
+
msg = f"{SETTINGS_NAME}['REDIS_URL'] is required to talk to Redis."
|
|
143
|
+
raise ImproperlyConfigured(msg)
|
|
144
|
+
return Redis.from_url(url, **connection_kwargs())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_async_client() -> AsyncRedis:
|
|
148
|
+
"""Build the same client for a caller that is already on an event loop.
|
|
149
|
+
|
|
150
|
+
Same URL, same deadlines from :func:`connection_kwargs`, same deliberate lack
|
|
151
|
+
of retries — everything :func:`build_client` says applies here too, and
|
|
152
|
+
``redis.asyncio`` ships inside redis-py, so this needs no extra dependency.
|
|
153
|
+
|
|
154
|
+
What differs is ownership: these connections are **loop-affine**, so one
|
|
155
|
+
client cannot be shared the way the synchronous one is. :func:`aget_redis`
|
|
156
|
+
keeps one per loop.
|
|
157
|
+
"""
|
|
158
|
+
url = conf['REDIS_URL']
|
|
159
|
+
if not url:
|
|
160
|
+
msg = f"{SETTINGS_NAME}['REDIS_URL'] is required to talk to Redis."
|
|
161
|
+
raise ImproperlyConfigured(msg)
|
|
162
|
+
return AsyncRedis.from_url(url, **connection_kwargs())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def url_decodes_responses(url: str) -> bool:
|
|
166
|
+
"""Whether ``url`` asks redis-py to hand back ``str`` instead of ``bytes``.
|
|
167
|
+
|
|
168
|
+
Tolerated everywhere else — :func:`as_bytes` exists for it, because one
|
|
169
|
+
``REDIS_URL`` is often shared with a cache backend that wants decoding — but
|
|
170
|
+
pickled payloads cannot survive it, so check E043 refuses that one pairing.
|
|
171
|
+
|
|
172
|
+
Asked of redis-py rather than parsed here, because the answer is surprising
|
|
173
|
+
and any reimplementation would drift from it. ``decode_responses`` has no
|
|
174
|
+
entry in ``URL_QUERY_ARGUMENT_PARSERS``, so it never goes through a boolean
|
|
175
|
+
parser: it arrives as a raw string and reaches the connection on plain
|
|
176
|
+
truthiness. ``?decode_responses=false`` and ``?decode_responses=0`` both
|
|
177
|
+
**enable** decoding; only an empty value leaves it off, and only because the
|
|
178
|
+
query parser drops blanks before redis-py sees them.
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
return bool(parse_url(url).get('decode_responses'))
|
|
182
|
+
except (AttributeError, TypeError, ValueError):
|
|
183
|
+
# a URL redis-py cannot read is not this check's finding: W002 covers an
|
|
184
|
+
# empty one, and anything else fails at the first real connection
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _SharedConnection:
|
|
189
|
+
"""Holds the shared client, together with the lock that keeps it single."""
|
|
190
|
+
|
|
191
|
+
def __init__(self) -> None:
|
|
192
|
+
"""Start with an empty slot; nothing connects until someone asks."""
|
|
193
|
+
self._lock = threading.Lock()
|
|
194
|
+
self._client: Redis | None = None
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def is_open(self) -> bool:
|
|
198
|
+
"""Whether a client has been built and not reset since."""
|
|
199
|
+
return self._client is not None
|
|
200
|
+
|
|
201
|
+
def get(self) -> Redis:
|
|
202
|
+
"""Return the client, building it at most once."""
|
|
203
|
+
# one read, kept local: a reset() between two reads of the attribute
|
|
204
|
+
# would otherwise let this return None
|
|
205
|
+
client = self._client
|
|
206
|
+
if client is None:
|
|
207
|
+
with self._lock:
|
|
208
|
+
client = self._client
|
|
209
|
+
if client is None:
|
|
210
|
+
client = self._client = build_client()
|
|
211
|
+
return client
|
|
212
|
+
|
|
213
|
+
def reset(self) -> None:
|
|
214
|
+
"""Empty the slot, then close whatever was in it."""
|
|
215
|
+
with self._lock:
|
|
216
|
+
client, self._client = self._client, None
|
|
217
|
+
if client is not None:
|
|
218
|
+
# closing talks to the socket: a caller waiting to build a
|
|
219
|
+
# replacement should not be held up by it
|
|
220
|
+
client.close()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class _LoopConnections:
|
|
224
|
+
"""One async client per event loop, and the generation that invalidates them.
|
|
225
|
+
|
|
226
|
+
``redis.asyncio`` connections belong to the loop that created them, so the
|
|
227
|
+
single shared client the rest of this module keeps would be wrong here: two
|
|
228
|
+
loops sharing one would interleave reads on the same socket.
|
|
229
|
+
|
|
230
|
+
**A weak key cannot bound this on its own, and measuring said so.** A connected
|
|
231
|
+
client holds its loop — through the connection, its writer, its transport — so
|
|
232
|
+
the value keeps the key alive and the entry never dies. Measured against a real
|
|
233
|
+
server: three loops used through :func:`aget_redis` and abandoned left three
|
|
234
|
+
live loops and three live clients, where the same three clients dropped on the
|
|
235
|
+
floor left none. That is the whole argument for the sweep in :meth:`get`, and
|
|
236
|
+
the reason no death-triggered cleanup can work here: holding a loop-affine
|
|
237
|
+
client at all is holding its loop.
|
|
238
|
+
|
|
239
|
+
The weak keys stay as the cheaper half of the same job — a client built and
|
|
240
|
+
never used holds nothing, so that entry does go by itself — but the sweep is
|
|
241
|
+
what keeps a process that runs a loop per unit of work from accumulating a
|
|
242
|
+
client, and its sockets, for every one of them.
|
|
243
|
+
|
|
244
|
+
Invalidation cannot close: ``setting_changed`` is synchronous, ``aclose()`` is
|
|
245
|
+
a coroutine, and closing a client belonging to another loop from another
|
|
246
|
+
thread is exactly what these connections forbid. So a reset only bumps a
|
|
247
|
+
counter, and the next caller *on that loop* awaits the close itself.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def __init__(self) -> None:
|
|
251
|
+
"""Start empty, at generation zero."""
|
|
252
|
+
self._guard = threading.Lock()
|
|
253
|
+
self._clients: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, tuple[int, AsyncRedis]]
|
|
254
|
+
self._clients = weakref.WeakKeyDictionary()
|
|
255
|
+
self._generation = 0
|
|
256
|
+
|
|
257
|
+
def _forget_closed(self) -> list[AsyncRedis]:
|
|
258
|
+
"""Drop the entries whose loop is closed, and hand back their clients.
|
|
259
|
+
|
|
260
|
+
Held under the guard. The clients are returned rather than dropped here so
|
|
261
|
+
that whatever their collection costs happens outside it.
|
|
262
|
+
|
|
263
|
+
They cannot be closed properly: ``aclose()`` would have to be awaited on
|
|
264
|
+
the loop that is gone. Letting go is the whole of what is available, and it
|
|
265
|
+
is what would have happened anyway had this registry never held them.
|
|
266
|
+
"""
|
|
267
|
+
closed = [loop for loop in list(self._clients.keys()) if loop.is_closed()]
|
|
268
|
+
return [self._clients.pop(loop)[1] for loop in closed]
|
|
269
|
+
|
|
270
|
+
async def get(self) -> AsyncRedis:
|
|
271
|
+
"""Return this loop's client, building or replacing it as needed."""
|
|
272
|
+
loop = _running_loop()
|
|
273
|
+
# no await inside the lock, so a coroutine cannot be suspended holding it
|
|
274
|
+
# and another thread's loop is only ever held off for a dict lookup
|
|
275
|
+
with self._guard:
|
|
276
|
+
abandoned = self._forget_closed()
|
|
277
|
+
entry = self._clients.get(loop)
|
|
278
|
+
if entry is not None and entry[0] == self._generation:
|
|
279
|
+
stale, client = None, entry[1]
|
|
280
|
+
else:
|
|
281
|
+
stale = None if entry is None else entry[1]
|
|
282
|
+
client = build_async_client()
|
|
283
|
+
self._clients[loop] = (self._generation, client)
|
|
284
|
+
# outside the guard: letting go of the abandoned clients runs whatever
|
|
285
|
+
# their collection runs, and no other loop should wait behind it
|
|
286
|
+
abandoned.clear()
|
|
287
|
+
if stale is not None:
|
|
288
|
+
# on its own loop, which is the only place it may be closed. Contained,
|
|
289
|
+
# because the replacement is already cached and healthy: a client whose socket
|
|
290
|
+
# is broken can raise on close, and this path runs right after a settings
|
|
291
|
+
# change — so letting it out would fail the first `asend` after every reset
|
|
292
|
+
# for something that has already been replaced
|
|
293
|
+
try:
|
|
294
|
+
await stale.aclose()
|
|
295
|
+
except Exception:
|
|
296
|
+
logger.exception('could not close the client a settings change replaced')
|
|
297
|
+
return client
|
|
298
|
+
|
|
299
|
+
async def close(self) -> None:
|
|
300
|
+
"""Close and forget this loop's client, if it has one."""
|
|
301
|
+
loop = _running_loop('aclose_redis()')
|
|
302
|
+
with self._guard:
|
|
303
|
+
entry = self._clients.pop(loop, None)
|
|
304
|
+
if entry is not None:
|
|
305
|
+
await entry[1].aclose()
|
|
306
|
+
|
|
307
|
+
def invalidate(self) -> None:
|
|
308
|
+
"""Mark every client stale without touching any of them."""
|
|
309
|
+
with self._guard:
|
|
310
|
+
self._generation += 1
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _running_loop(caller: str = 'aget_redis()') -> asyncio.AbstractEventLoop:
|
|
314
|
+
"""Return the loop the caller is on, or refuse with what to call instead."""
|
|
315
|
+
try:
|
|
316
|
+
return asyncio.get_running_loop()
|
|
317
|
+
except RuntimeError as error:
|
|
318
|
+
msg = (
|
|
319
|
+
f'{caller} needs a running event loop; call get_redis() from '
|
|
320
|
+
'synchronous code instead. The async client exists for callers that '
|
|
321
|
+
'are already on a loop — an ASGI view, say — and a client built off '
|
|
322
|
+
'one could not be used from it.'
|
|
323
|
+
)
|
|
324
|
+
raise RuntimeError(msg) from error
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
_loops = _LoopConnections()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
async def aget_redis() -> AsyncRedis:
|
|
331
|
+
"""Return the async client for the loop this coroutine is running on."""
|
|
332
|
+
return await _loops.get()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def aclose_redis() -> None:
|
|
336
|
+
"""Close this loop's async client. Django has no hook that does it for you."""
|
|
337
|
+
await _loops.close()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
_shared = _SharedConnection()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def get_redis() -> Redis:
|
|
344
|
+
"""Return the shared connection, creating it on first use."""
|
|
345
|
+
return _shared.get()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def reset_redis() -> None:
|
|
349
|
+
"""Drop the shared connection so the next call reconnects."""
|
|
350
|
+
_shared.reset()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def as_bytes(value: bytes | str) -> bytes:
|
|
354
|
+
"""Redis hands back str when the URL enables decode_responses."""
|
|
355
|
+
return value if isinstance(value, bytes) else value.encode('utf-8')
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class RedisProxy:
|
|
359
|
+
"""Forwards attribute access to the lazily created connection.
|
|
360
|
+
|
|
361
|
+
Exists so `from django_aiogram import redis_conn` stays a plain
|
|
362
|
+
module-level import without connecting at import time.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
def __getattr__(self, item: str) -> Any: # noqa: ANN401 - a forwarded Redis method may return anything
|
|
366
|
+
"""Hand the attribute over to the shared client, connecting if needed."""
|
|
367
|
+
return getattr(get_redis(), item)
|
|
368
|
+
|
|
369
|
+
def __repr__(self) -> str:
|
|
370
|
+
"""Say whether the connection behind the proxy exists yet."""
|
|
371
|
+
state = 'connected' if _shared.is_open else 'not connected'
|
|
372
|
+
return f'<RedisProxy {state}>'
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if TYPE_CHECKING:
|
|
376
|
+
#: the proxy forwards everything through __getattr__, which types as Any — so
|
|
377
|
+
#: `redis_conn.ping()` was unchecked while `get_redis().ping()` was not, and
|
|
378
|
+
#: this annotation is what tells a consumer's mypy the difference
|
|
379
|
+
redis_conn: Redis
|
|
380
|
+
else:
|
|
381
|
+
redis_conn = RedisProxy()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _reset_on_setting_change(setting: str, **_kwargs: object) -> None:
|
|
385
|
+
"""Reconnect after the settings change, since REDIS_URL may have moved."""
|
|
386
|
+
if setting == SETTINGS_NAME:
|
|
387
|
+
reset_redis()
|
|
388
|
+
# marked, not closed: this runs on whatever thread changed the setting,
|
|
389
|
+
# and an async client may belong to a loop on another one. The next
|
|
390
|
+
# caller on that loop closes the stale client itself
|
|
391
|
+
_loops.invalidate()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
setting_changed.connect(_reset_on_setting_change, dispatch_uid='django_aiogram.redis')
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Bytes on the queue, and back: everything about how a message is represented.
|
|
2
|
+
|
|
3
|
+
`serializers` turns a payload into bytes and back; `envelope` wraps one with the version
|
|
4
|
+
and correlation id a consumer needs to read it; `payloads` shapes and redacts what the
|
|
5
|
+
event log is allowed to keep.
|
|
6
|
+
|
|
7
|
+
None of it knows which transport carries the result, which is what lets the same envelope
|
|
8
|
+
travel a Redis list, a stream, a queue or a topic.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
#: deliberately empty: callers import from the modules in this package, not from the
|
|
12
|
+
#: package itself. A re-export here would make a second path to every name, and the one
|
|
13
|
+
#: nobody chose is the one that cannot be moved later
|
|
14
|
+
__all__: tuple[str, ...] = ()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""What a queued call looks like on the wire.
|
|
2
|
+
|
|
3
|
+
2.x wrote ``{'function': name, **kwargs}`` and the consumer splatted it straight
|
|
4
|
+
back into aiogram, so there was nowhere to put an identifier, a timestamp or a
|
|
5
|
+
version without it arriving at Telegram as an unexpected argument. 3.0 nests the
|
|
6
|
+
arguments instead.
|
|
7
|
+
|
|
8
|
+
The tagged-JSON serializer needs no change for this. ``encode`` recurses through
|
|
9
|
+
mappings unconditionally, and ``decode`` only reacts to one when a codec tag is a
|
|
10
|
+
key in it — ``__envelope__`` is not a tag, so the envelope decodes as a plain
|
|
11
|
+
mapping and its ``kwargs`` still become real aiogram objects.
|
|
12
|
+
|
|
13
|
+
**Deployment order matters because of this.** The reader below accepts the old
|
|
14
|
+
flat shape, but a 2.x reader handed a new payload calls the Telegram method with
|
|
15
|
+
``__envelope__`` as a keyword, raises ``TypeError``, logs it and swallows it —
|
|
16
|
+
the message is lost silently. Deploy the bot container before the web tier.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from django_aiogram.exceptions import DjangoRedisAiogramError
|
|
26
|
+
|
|
27
|
+
#: marks a payload as nested, and says which shape to read it as
|
|
28
|
+
ENVELOPE_KEY = '__envelope__'
|
|
29
|
+
ENVELOPE_VERSION = 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MalformedEnvelopeError(DjangoRedisAiogramError, ValueError):
|
|
33
|
+
"""A payload decoded, but is not a shape any version of this reads.
|
|
34
|
+
|
|
35
|
+
Distinct from an unknown *version*, and the difference decides the message's
|
|
36
|
+
fate: a newer version is left in flight for an upgraded consumer to deliver,
|
|
37
|
+
while nothing will ever make sense of this one, so it is recorded and
|
|
38
|
+
acknowledged instead of coming back for ever.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, found: object) -> None:
|
|
42
|
+
"""Name what arrived, never its content: this came off an untrusted queue."""
|
|
43
|
+
super().__init__(f'Queued payload is not a readable envelope: {found}.')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UnknownEnvelopeVersionError(DjangoRedisAiogramError, ValueError):
|
|
47
|
+
"""A payload was written by a newer version than this consumer understands."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, version: object) -> None:
|
|
50
|
+
"""Name the version found and the newest one this consumer can read."""
|
|
51
|
+
super().__init__(
|
|
52
|
+
f'Queued payload declares envelope version {version!r}, but this consumer '
|
|
53
|
+
f'reads up to {ENVELOPE_VERSION}. Upgrade the bot container first.',
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Envelope:
|
|
59
|
+
"""One queued call, as the consumer needs it."""
|
|
60
|
+
|
|
61
|
+
function: str
|
|
62
|
+
kwargs: dict[str, Any]
|
|
63
|
+
correlation_id: uuid.UUID | None = None
|
|
64
|
+
#: a float, not a datetime: it survives both serializers without a codec
|
|
65
|
+
queued_at: float = 0.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def pack(
|
|
69
|
+
function: str,
|
|
70
|
+
kwargs: dict[str, Any],
|
|
71
|
+
correlation_id: uuid.UUID,
|
|
72
|
+
queued_at: float,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
"""Build the payload that goes on the list."""
|
|
75
|
+
return {
|
|
76
|
+
ENVELOPE_KEY: ENVELOPE_VERSION,
|
|
77
|
+
'correlation_id': correlation_id.hex,
|
|
78
|
+
'queued_at': queued_at,
|
|
79
|
+
'function': function,
|
|
80
|
+
'kwargs': kwargs,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _as_uuid(value: object) -> uuid.UUID | None:
|
|
85
|
+
"""Read the identifier back, tolerating a payload that carries nonsense."""
|
|
86
|
+
if isinstance(value, uuid.UUID):
|
|
87
|
+
return value
|
|
88
|
+
if isinstance(value, str) and value:
|
|
89
|
+
try:
|
|
90
|
+
return uuid.UUID(value)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _as_time(value: object) -> float:
|
|
97
|
+
"""Read a timestamp, or settle for none.
|
|
98
|
+
|
|
99
|
+
A figure this cannot read costs the queue latency, not the message, which
|
|
100
|
+
may otherwise be perfectly deliverable. `nan` and the infinities are in that
|
|
101
|
+
class too: `float()` accepts them, arithmetic on them produces more of them,
|
|
102
|
+
and `nan` is not even valid JSON to a strict reader.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
seconds = float(value or 0.0) # type: ignore[arg-type]
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
return 0.0
|
|
108
|
+
return seconds if math.isfinite(seconds) else 0.0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def unpack(payload: object) -> Envelope:
|
|
112
|
+
"""Read either shape, from whatever the queue actually held.
|
|
113
|
+
|
|
114
|
+
A rolling upgrade leaves 2.x payloads on the list for as long as the backlog
|
|
115
|
+
lasts, and refusing them would drop real messages. Everything else this
|
|
116
|
+
cannot read raises, because the consumer thread is on the far side of a
|
|
117
|
+
trust boundary and an exception escaping it ends the worker.
|
|
118
|
+
"""
|
|
119
|
+
if not isinstance(payload, Mapping):
|
|
120
|
+
found = f'a decoded {type(payload).__name__}, not a mapping'
|
|
121
|
+
raise MalformedEnvelopeError(found)
|
|
122
|
+
version = payload.get(ENVELOPE_KEY)
|
|
123
|
+
if version is None:
|
|
124
|
+
return Envelope(
|
|
125
|
+
function=str(payload.get('function', '')),
|
|
126
|
+
kwargs={key: value for key, value in payload.items() if key != 'function'},
|
|
127
|
+
)
|
|
128
|
+
# exactly an int: `int()` reads True, 1.0 and 1.5 as version 1, and this
|
|
129
|
+
# package writes an integer. The message names the type and never the
|
|
130
|
+
# value, which came off an untrusted queue and ends up in a log line
|
|
131
|
+
if type(version) is not int:
|
|
132
|
+
unreadable = f'an envelope version of type {type(version).__name__}'
|
|
133
|
+
raise MalformedEnvelopeError(unreadable)
|
|
134
|
+
if version > ENVELOPE_VERSION:
|
|
135
|
+
raise UnknownEnvelopeVersionError(version)
|
|
136
|
+
if version < ENVELOPE_VERSION:
|
|
137
|
+
# not a future shape somebody can deliver later, so it is not kept
|
|
138
|
+
older = f'envelope version {version}'
|
|
139
|
+
raise MalformedEnvelopeError(older)
|
|
140
|
+
arguments = payload.get('kwargs')
|
|
141
|
+
return Envelope(
|
|
142
|
+
function=str(payload.get('function', '')),
|
|
143
|
+
kwargs=dict(arguments) if isinstance(arguments, Mapping) else {},
|
|
144
|
+
correlation_id=_as_uuid(payload.get('correlation_id')),
|
|
145
|
+
queued_at=_as_time(payload.get('queued_at')),
|
|
146
|
+
)
|