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.
Files changed (48) hide show
  1. django_aiogram/__init__.py +64 -0
  2. django_aiogram/_singleton.py +22 -0
  3. django_aiogram/admin.py +380 -0
  4. django_aiogram/api.py +38 -0
  5. django_aiogram/apps.py +56 -0
  6. django_aiogram/config/__init__.py +15 -0
  7. django_aiogram/config/checks.py +759 -0
  8. django_aiogram/config/defaults.py +102 -0
  9. django_aiogram/config/enums.py +108 -0
  10. django_aiogram/config/settings.py +266 -0
  11. django_aiogram/consumer/__init__.py +13 -0
  12. django_aiogram/consumer/delivery.py +625 -0
  13. django_aiogram/consumer/routers.py +19 -0
  14. django_aiogram/consumer/webhook.py +154 -0
  15. django_aiogram/context.py +34 -0
  16. django_aiogram/eventlog/__init__.py +16 -0
  17. django_aiogram/eventlog/dbrouter.py +55 -0
  18. django_aiogram/eventlog/events.py +120 -0
  19. django_aiogram/eventlog/instrumentation.py +231 -0
  20. django_aiogram/eventlog/recorder.py +922 -0
  21. django_aiogram/eventlog/signals.py +84 -0
  22. django_aiogram/eventlog/writer.py +231 -0
  23. django_aiogram/exceptions.py +60 -0
  24. django_aiogram/healthcheck.py +412 -0
  25. django_aiogram/management/__init__.py +1 -0
  26. django_aiogram/management/commands/__init__.py +1 -0
  27. django_aiogram/management/commands/start_tgbot.py +308 -0
  28. django_aiogram/management/commands/tgbot_healthcheck.py +57 -0
  29. django_aiogram/management/commands/tgbot_prune_events.py +144 -0
  30. django_aiogram/management/commands/tgbot_reclaim.py +135 -0
  31. django_aiogram/management/commands/tgbot_webhook.py +87 -0
  32. django_aiogram/migrations/0001_initial.py +50 -0
  33. django_aiogram/migrations/0002_kind_id_index.py +32 -0
  34. django_aiogram/migrations/__init__.py +1 -0
  35. django_aiogram/models.py +79 -0
  36. django_aiogram/producer/__init__.py +13 -0
  37. django_aiogram/producer/client.py +1540 -0
  38. django_aiogram/producer/throttling.py +336 -0
  39. django_aiogram/py.typed +0 -0
  40. django_aiogram/redis.py +394 -0
  41. django_aiogram/wire/__init__.py +14 -0
  42. django_aiogram/wire/envelope.py +146 -0
  43. django_aiogram/wire/payloads.py +195 -0
  44. django_aiogram/wire/serializers.py +533 -0
  45. django_aiogram-4.0.0.dev0.dist-info/METADATA +145 -0
  46. django_aiogram-4.0.0.dev0.dist-info/RECORD +48 -0
  47. django_aiogram-4.0.0.dev0.dist-info/WHEEL +4 -0
  48. django_aiogram-4.0.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,84 @@
1
+ """The seam a project gets metrics out of.
2
+
3
+ Deliberately a ``django.dispatch.Signal`` rather than a setting naming a dotted
4
+ path. A setting would need an entry in ``Settings.md``, a check id, an
5
+ ``import_string``, a lazy cache to keep the import off the hot path, and a
6
+ decision about what to do when the path is wrong. A signal needs none of that,
7
+ ``send_robust`` contains most of what a receiver can do wrong, and connecting one is
8
+ the thing every Django developer already knows how to do. *Most*: see
9
+ :meth:`~django_aiogram.eventlog.recorder.EventRecorder._publish` for the receiver shape
10
+ Django's own containment misses, which is why this package does not rely on it
11
+ alone.
12
+
13
+ This module imports ``django.dispatch`` and nothing else — not aiogram, not the
14
+ ORM, not the rest of this package. A metrics module can import it at settings
15
+ time without dragging anything in.
16
+ """
17
+
18
+ from django.dispatch import Signal
19
+
20
+ #: Fired once per batch of recorded events, on whichever thread flushed that batch —
21
+ #: normally the event writer's own, and three other threads can be it.
22
+ #:
23
+ #: Receivers get ``events``: a tuple of :class:`~django_aiogram.eventlog.recorder.Event`,
24
+ #: whose field names are pinned by ``tests/test_public_surface.py`` and are
25
+ #: therefore public API. ``sender`` is the recorder instance.
26
+ #:
27
+ #: Four things about it are load-bearing, and three of them are surprising:
28
+ #:
29
+ #: * **It fires whether or not the event log is on.** The table and the metrics
30
+ #: are separate decisions: connect a receiver and the events flow, with
31
+ #: ``EVENT_LOG`` left off and no migration in sight.
32
+ #: * **Payload summaries are the only part of ``detail`` the log gates.** With the
33
+ #: log off, ``detail`` still carries what the recording seam measured itself —
34
+ #: a send's ``duration_ms``, a retry's ``retry_after``, a queueing failure's
35
+ #: ``stage``, a gap's ``dropped`` count. What is missing is the summarized
36
+ #: arguments, because redacting and bounding a payload is the expensive part of
37
+ #: recording and no part of counting. A receiver that needs message bodies needs
38
+ #: the log on as well, and then ``EVENT_LOG_PAYLOAD`` decides what is in there.
39
+ #: * **``EVENT_LOG_KINDS`` filters this too, with one exemption.** It is one answer
40
+ #: to "which events does this deployment care about", not two — so a receiver
41
+ #: sees exactly the kinds the table would have kept. ``log.dropped`` is the
42
+ #: exception, in both directions: it is the record that recording itself fell
43
+ #: behind, and a deployment that filtered it out would read the hole as quiet
44
+ #: traffic. The table has always been exempt from the filter for that row, and
45
+ #: receivers are exempt with it.
46
+ #: * **The write is attempted before receivers see the batch.** So nothing a
47
+ #: receiver does can change a row that was written, which is why they get real
48
+ #: ``Event`` objects rather than copies — the ``detail`` dict inside one is an
49
+ #: ordinary mutable dict shared with the other receivers, so treat it as
50
+ #: read-only. It is *attempted*, not guaranteed: a write that failed still
51
+ #: publishes, because a database being down is exactly when someone is watching a
52
+ #: dashboard. Receiving a batch is therefore not evidence that a row exists for
53
+ #: it, and with ``EVENT_LOG`` off there is no row by design.
54
+ #:
55
+ #: The rule is **whichever thread flushed the batch publishes it**, and normally that
56
+ #: is the event writer's, once the batch's own write has been attempted — so a slow
57
+ #: receiver delays neither a send nor that write, only later batches and the writer's
58
+ #: shutdown. Never delaying a send is the point of the design; the rest follows from
59
+ #: the rule rather than being promised separately.
60
+ #:
61
+ #: Three other threads can flush a batch, so three other threads can publish one:
62
+ #:
63
+ #: * whatever calls ``EventRecorder.drain_once()``, which exists so a test can drive
64
+ #: the real flush path on its own thread
65
+ #: * at shutdown, whichever thread called ``stop()``, for whatever the writer had not
66
+ #: drained. Those events are published rather than dropped because they are the last
67
+ #: ones before the process goes, and there is by then no writer left to hand them to
68
+ #: * under ``EVENT_LOG_SYNC``, the thread that recorded the event, which is the one
69
+ #: case with no batch and no writer involved at all
70
+ #:
71
+ #: ``EVENT_LOG_SYNC`` only takes effect with the log on — there is nothing to insert
72
+ #: synchronously otherwise — so the write is always attempted there, and may still
73
+ #: fail. It is a testing setting, and receivers running inside the send path is one
74
+ #: more reason to keep it one.
75
+ #:
76
+ #: A dispatch that fails can leave later receivers without the batch: ``send_robust``
77
+ #: stops its own loop when Django's failure logging raises, which it does for a
78
+ #: callable instance. This package catches that and logs
79
+ #: ``publishing recorded events failed``, but the receivers after the offending one
80
+ #: were never called.
81
+ #:
82
+ #: The write happens before either, and only when ``EVENT_LOG`` is on: with the log
83
+ #: off nothing is written at all, and a receiver is the only thing the batch reaches.
84
+ events_recorded: Signal = Signal()
@@ -0,0 +1,231 @@
1
+ """Turning recorded events into rows, on the one thread allowed to do it.
2
+
3
+ This is the only module in the package that touches the ORM, and it is imported
4
+ from inside the writer thread rather than at module scope — that is what keeps
5
+ ``recorder`` free of ``django.db``, and therefore keeps ``record()`` legal from
6
+ a coroutine and free in a process with the log switched off.
7
+ """
8
+
9
+ import datetime
10
+ import logging
11
+ from collections.abc import Sequence
12
+
13
+ from django.conf import settings as django_settings
14
+ from django.db import (
15
+ DEFAULT_DB_ALIAS,
16
+ DatabaseError,
17
+ InterfaceError,
18
+ OperationalError,
19
+ connections,
20
+ transaction,
21
+ )
22
+ from django.utils import timezone
23
+
24
+ from django_aiogram.eventlog.dbrouter import event_log_database
25
+ from django_aiogram.eventlog.recorder import Event
26
+ from django_aiogram.exceptions import DjangoRedisAiogramError
27
+ from django_aiogram.models import TelegramEvent
28
+ from django_aiogram.wire.payloads import redact_keys, redact_text, redact_values, secrets
29
+
30
+ logger = logging.getLogger('django_aiogram')
31
+
32
+ #: below this a failed batch is retried row by row rather than bisected
33
+ ROW_BY_ROW = 32
34
+
35
+
36
+ class EventLogRefusedError(DjangoRedisAiogramError):
37
+ """Every row of a batch was refused.
38
+
39
+ Not a `DatabaseError`: it is this module's verdict on one, and the difference
40
+ matters because the recorder treats it as a failed flush rather than as
41
+ something to bisect further. It never escapes that flush.
42
+ """
43
+
44
+ def __init__(self, count: int) -> None:
45
+ """Name how many rows were lost, which is what the log line reports."""
46
+ super().__init__(f'the database refused all {count} rows of this batch')
47
+
48
+
49
+ def log_alias() -> str:
50
+ """Return the alias rows are written to and read from."""
51
+ return event_log_database() or DEFAULT_DB_ALIAS
52
+
53
+
54
+ def _moment(stamp: float) -> datetime.datetime:
55
+ """Return the recorded instant, in whichever flavour of datetime this project stores."""
56
+ moment = datetime.datetime.fromtimestamp(stamp, tz=datetime.timezone.utc)
57
+ return moment if django_settings.USE_TZ else timezone.make_naive(moment)
58
+
59
+
60
+ def _text(value: object, length: int) -> str:
61
+ """Cut a value to its column's width, without the NULs PostgreSQL refuses."""
62
+ if value is None:
63
+ return ''
64
+ return str(value).replace('\x00', '')[:length]
65
+
66
+
67
+ def to_row(
68
+ event: Event,
69
+ keys: frozenset[str] | None = None,
70
+ configured: tuple[str, ...] | None = None,
71
+ ) -> TelegramEvent:
72
+ """Build the unsaved row for one event, sanitised so it cannot poison a batch.
73
+
74
+ Redaction happens here as well as at the producer. This is the boundary rows
75
+ cross, and the rule is that the token must not reach one: a caller that
76
+ builds an Event by hand, or a new seam that forgets, would otherwise put an
77
+ aiogram error message — which carries the API URL, which carries the token —
78
+ straight into a column.
79
+ """
80
+ if keys is None:
81
+ keys = redact_keys()
82
+ if configured is None:
83
+ configured = secrets()
84
+ return TelegramEvent(
85
+ created_at=_moment(event.created_at),
86
+ correlation_id=event.correlation_id,
87
+ kind=_text(event.kind, 48),
88
+ function=_text(event.function, 64),
89
+ chat_id=event.chat_id,
90
+ user_id=event.user_id,
91
+ message_id=event.message_id,
92
+ update_id=event.update_id,
93
+ worker=_text(event.worker, 128),
94
+ attempt=max(0, event.attempt),
95
+ duration_ms=event.duration_ms,
96
+ error_code=_text(event.error_code, 64),
97
+ error=_text(redact_text(str(event.error or ''), configured), 20000),
98
+ detail=redact_values(event.detail or {}, keys, configured),
99
+ )
100
+
101
+
102
+ def write_batch(events: Sequence[Event]) -> int:
103
+ """Insert one batch, recycling a connection the database has since dropped.
104
+
105
+ Returns how many rows did **not** land, so the caller can count them. A total
106
+ refusal raises; a partial one used to return here indistinguishable from a clean
107
+ write, so the rows the database refused one at a time disappeared with no counter,
108
+ no ``log.dropped`` row and nothing but a per-row line in the log — while the feed
109
+ read as complete coverage of the period that lost them.
110
+ """
111
+ alias = log_alias()
112
+ _recycle(alias)
113
+ # resolved once for the batch: both walk the settings, and a batch is 200 rows
114
+ keys, configured = redact_keys(), secrets()
115
+ rows = [to_row(event, keys, configured) for event in events]
116
+ manager = TelegramEvent.objects.using(alias)
117
+ try:
118
+ # the savepoint is what keeps a failed log write from taking the
119
+ # caller's data with it: under EVENT_LOG_SYNC this runs on the caller's
120
+ # thread, inside whatever atomic() block the caller opened
121
+ with transaction.atomic(using=alias):
122
+ manager.bulk_create(rows)
123
+ except (OperationalError, InterfaceError) as error:
124
+ # the connection died between the check above and the insert; one retry
125
+ # on a fresh one is the difference between losing a batch and not
126
+ _recycle(alias, force=True)
127
+ # the retry needs the same net as the first attempt: a fresh connection
128
+ # rejecting one poison row must not cost the whole batch
129
+ written = _write_half(rows, alias)
130
+ _refused(rows, written, error)
131
+ return len(rows) - written
132
+ except DatabaseError as error:
133
+ written = _write_one_by_one(rows, alias)
134
+ _refused(rows, written, error)
135
+ return len(rows) - written
136
+ return 0
137
+
138
+
139
+ def _recycle(alias: str, *, force: bool = False) -> None:
140
+ """Discard a connection the database has since dropped, unless we are inside a transaction.
141
+
142
+ Before the work, not after: this is what discards a connection whose
143
+ CONN_MAX_AGE expired, that a restart killed, or that a previous error marked
144
+ unusable. Closing afterward would leave a broken one in place.
145
+
146
+ Never while the caller holds a transaction open, though. Under EVENT_LOG_SYNC
147
+ this runs on the caller's thread inside their ``atomic()`` block — and on
148
+ PostgreSQL and MySQL, closing a connection there marks the whole transaction
149
+ for rollback, so recording an event would destroy the writes the caller made
150
+ alongside it. A connection Django is already using is not stale anyway.
151
+
152
+ ``in_atomic_block`` is only half of what *open transaction* means. With autocommit
153
+ off — ``transaction.set_autocommit(False)``, or ``AUTOCOMMIT: False`` on the alias —
154
+ the server holds one from the first statement with no block anywhere in sight, and
155
+ then it is worse than the block case: ``close_if_unusable_or_obsolete`` sees
156
+ ``get_autocommit()`` disagreeing with the configured value and closes on that basis,
157
+ while ``close()`` skips setting ``needs_rollback`` precisely *because*
158
+ ``in_atomic_block`` is False. So the caller's writes are rolled back by the server
159
+ and nothing raises. Measured on PostgreSQL 16: the caller's row was gone after a
160
+ successful ``commit()``, with the event row written.
161
+
162
+ One alias, not all of them. ``close_old_connections()`` walks every initialized
163
+ connection, so with ``EVENT_LOG_DATABASE`` pointing somewhere of its own it
164
+ reaches past the log's connection — which is not in a transaction — and closes
165
+ the caller's ``default`` one, which is. The guard above would then be reading
166
+ the wrong connection's state. The log has no business touching one it never
167
+ writes to.
168
+ """
169
+ connection = connections[alias]
170
+ if connection.in_atomic_block or not connection.get_autocommit():
171
+ return
172
+ if force:
173
+ connection.close()
174
+ return
175
+ connection.close_if_unusable_or_obsolete()
176
+
177
+
178
+ def _refused(rows: list[TelegramEvent], written: int, error: Exception) -> None:
179
+ """Raise when a batch reached the database and none of it landed.
180
+
181
+ The bisecting ladder below catches ``DatabaseError`` at every rung, so a
182
+ database that refuses everything — no table, no permission, no disk — used to
183
+ end in a normal return. The recorder read that as success: its failure counter
184
+ never moved, so the backoff never engaged and no ``log.dropped`` row was ever
185
+ written. It hammered the database once per flush interval, for ever.
186
+ """
187
+ if rows and not written:
188
+ raise EventLogRefusedError(len(rows)) from error
189
+
190
+
191
+ def _write_half(rows: list[TelegramEvent], alias: str) -> int:
192
+ """Insert one half of a bisected batch, splitting it again if it still fails."""
193
+ try:
194
+ with transaction.atomic(using=alias):
195
+ TelegramEvent.objects.using(alias).bulk_create(rows)
196
+ except (DatabaseError, InterfaceError):
197
+ # both, because Django defines them as siblings under `Error`: a connection the
198
+ # server dropped raises `InterfaceError`, which `DatabaseError` alone lets escape
199
+ # `write_batch` — with no refused count and no `EventLogRefusedError`
200
+ return _write_one_by_one(rows, alias)
201
+ return len(rows)
202
+
203
+
204
+ def _write_row(row: TelegramEvent, alias: str) -> bool:
205
+ """Insert one row, dropping it if the database refuses it."""
206
+ try:
207
+ # the savepoint is not optional: on PostgreSQL a failed statement aborts
208
+ # the transaction, so one bad row would take every later one with it
209
+ with transaction.atomic(using=alias):
210
+ row.save(force_insert=True, using=alias)
211
+ except (DatabaseError, InterfaceError):
212
+ logger.exception('dropping an event the database refused', extra={'tg_kind': row.kind})
213
+ return False
214
+ return True
215
+
216
+
217
+ def _write_one_by_one(rows: list[TelegramEvent], alias: str) -> int:
218
+ """Save rows individually, dropping only the ones the database refuses."""
219
+ if len(rows) > ROW_BY_ROW:
220
+ # bisect first, so a 200-row batch does not become 200 statements
221
+ middle = len(rows) // 2
222
+ return _write_half(rows[:middle], alias) + _write_half(rows[middle:], alias)
223
+ return sum(_write_row(row, alias) for row in rows)
224
+
225
+
226
+ def close_connections() -> None:
227
+ """Release the calling thread's connections; nothing else ever will."""
228
+ try:
229
+ connections.close_all()
230
+ except Exception:
231
+ logger.exception('could not close the event writer connection')
@@ -0,0 +1,60 @@
1
+ """The exceptions this package raises.
2
+
3
+ They live together, and build their own messages, so that call sites raise a
4
+ named domain error instead of a bare builtin with a formatted string.
5
+ """
6
+
7
+
8
+ class DjangoRedisAiogramError(Exception):
9
+ """Base class for every error this package raises."""
10
+
11
+
12
+ class LoopUnavailableError(DjangoRedisAiogramError, RuntimeError):
13
+ """No event loop in this process can take an update right now.
14
+
15
+ A refusal, not a handler failure: nothing has run, so the update is still
16
+ Telegram's to redeliver. The webhook view answers a non-2xx for this and 200
17
+ for everything else, which is the difference between a retry and a loop.
18
+
19
+ Raised as one of the two below, so a caller can tell a shutdown from a fault.
20
+ """
21
+
22
+
23
+ class ShuttingDownError(LoopUnavailableError):
24
+ """The update arrived while this process was tearing the bot down."""
25
+
26
+ def __init__(self) -> None:
27
+ """Say which window this is, since it closes on its own."""
28
+ super().__init__(
29
+ 'the bot is shutting down, so this update was not handled here. '
30
+ 'Telegram will redeliver it; another process, or this one after it '
31
+ 'restarts, will take it.',
32
+ )
33
+
34
+
35
+ class LoopThreadNotStartedError(LoopUnavailableError):
36
+ """The thread this process gives the loop did not start in time."""
37
+
38
+ def __init__(self, timeout: float) -> None:
39
+ """Name the deadline, which is the only number worth acting on."""
40
+ super().__init__(
41
+ f'the event loop thread did not start within {timeout}s, and driving '
42
+ 'the loop from this thread instead would put two threads on one loop. '
43
+ 'The update was not handled here.',
44
+ )
45
+
46
+
47
+ class SerializationError(DjangoRedisAiogramError):
48
+ """A payload could not be encoded or decoded."""
49
+
50
+
51
+ class UnknownApiMethodError(DjangoRedisAiogramError, ValueError):
52
+ """A queued payload named something that is not a Telegram API method."""
53
+
54
+ def __init__(self, function: str, method_count: int) -> None:
55
+ """Name the rejected method and how many the Bot API actually has."""
56
+ super().__init__(
57
+ f'{function!r} is not a Telegram API method. Queued payloads may only '
58
+ f'name one of the {method_count} methods aiogram exposes for the '
59
+ f'Bot API; see the Serialization page.',
60
+ )