funora 0.0.1.dev2__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.
- funora/__init__.py +265 -0
- funora/_account.py +661 -0
- funora/_aclient.py +1484 -0
- funora/_budget.py +579 -0
- funora/_calc.py +182 -0
- funora/_canonical.py +235 -0
- funora/_catalog.py +473 -0
- funora/_chat_history.py +367 -0
- funora/_chats.py +392 -0
- funora/_chips.py +403 -0
- funora/_classify.py +466 -0
- funora/_client.py +1490 -0
- funora/_currency_switch.py +130 -0
- funora/_cursor.py +120 -0
- funora/_delivered.py +233 -0
- funora/_diff.py +752 -0
- funora/_engine.py +4795 -0
- funora/_extract.py +124 -0
- funora/_field_schema.py +112 -0
- funora/_fileio.py +58 -0
- funora/_gate.py +69 -0
- funora/_hops.py +101 -0
- funora/_host.py +120 -0
- funora/_identity.py +284 -0
- funora/_json.py +31 -0
- funora/_listen.py +312 -0
- funora/_lot_form.py +331 -0
- funora/_market.py +406 -0
- funora/_matching.py +147 -0
- funora/_money.py +279 -0
- funora/_monitoring.py +396 -0
- funora/_observed.py +237 -0
- funora/_order.py +552 -0
- funora/_order_details.py +281 -0
- funora/_orders.py +807 -0
- funora/_outbound.py +453 -0
- funora/_own_lots.py +301 -0
- funora/_poll.py +410 -0
- funora/_price_audit.py +281 -0
- funora/_proxies.py +232 -0
- funora/_raise.py +150 -0
- funora/_refund.py +102 -0
- funora/_result.py +157 -0
- funora/_retry.py +213 -0
- funora/_review_write.py +138 -0
- funora/_reviews.py +584 -0
- funora/_runner.py +651 -0
- funora/_secret.py +385 -0
- funora/_showcase.py +362 -0
- funora/_signals.py +375 -0
- funora/_skeleton.py +752 -0
- funora/_snapshot.py +276 -0
- funora/_state.py +279 -0
- funora/_stock.py +32 -0
- funora/_thread.py +574 -0
- funora/_transport.py +1023 -0
- funora/_updates.py +292 -0
- funora/_verdicts.py +91 -0
- funora/_viewing.py +143 -0
- funora/_watch.py +825 -0
- funora/_watch_state.py +237 -0
- funora/_whoami.py +546 -0
- funora/bot/__init__.py +52 -0
- funora/bot/_delivery.py +341 -0
- funora/bot/_outbox.py +261 -0
- funora/bot/_runtime.py +447 -0
- funora/bot/_spool.py +534 -0
- funora/budget.py +311 -0
- funora/capabilities.py +297 -0
- funora/conformance.py +758 -0
- funora/contract.py +73 -0
- funora/errors.py +996 -0
- funora/events.py +189 -0
- funora/extraction.py +420 -0
- funora/observe.py +560 -0
- funora/operations.py +671 -0
- funora/py.typed +0 -0
- funora/reconciliation.py +51 -0
- funora/response_classes.py +155 -0
- funora/retry.py +238 -0
- funora/send_outcome.py +78 -0
- funora/skeleton_format.py +77 -0
- funora-0.0.1.dev2.dist-info/METADATA +294 -0
- funora-0.0.1.dev2.dist-info/RECORD +87 -0
- funora-0.0.1.dev2.dist-info/WHEEL +4 -0
- funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
- funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/_watch.py
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
"""Роутер обработчиков и один шаг цикла наблюдения.
|
|
2
|
+
|
|
3
|
+
Роутер чистый, шаг цикла - почти: он получает снимки от вызывающего и ничего не
|
|
4
|
+
знает ни о сети, ни о часах. Сам цикл со сном живёт в клиенте.
|
|
5
|
+
|
|
6
|
+
Главное правило одно, и всё остальное здесь ради него: базовый снимок сдвигается
|
|
7
|
+
только после того, как все обработчики отработали. Упавший обработчик оставляет
|
|
8
|
+
базу на месте, и то же событие приходит снова. Сдвинь базу раньше - и событие,
|
|
9
|
+
которое обработчик не смог обработать, исчезнет навсегда, а обработчик как раз и
|
|
10
|
+
падает на тех событиях, которые важнее прочих.
|
|
11
|
+
|
|
12
|
+
Отсюда же требование к обработчику: он обязан быть идемпотентным. Гарантия
|
|
13
|
+
доставки - не менее одного раза, повтор возможен всегда, и второй вызов на том же
|
|
14
|
+
событии не должен выдавать товар дважды.
|
|
15
|
+
|
|
16
|
+
Холодный старт молчит намеренно. Первый снимок сохраняется без событий, и
|
|
17
|
+
выдаётся одно watch.primed: иначе первый запуск даёт лавину «изменений» по всему
|
|
18
|
+
наблюдаемому множеству сразу.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import inspect
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Callable, Generator
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from typing import Final
|
|
30
|
+
|
|
31
|
+
from ._diff import Event, make_event
|
|
32
|
+
from .budget import HANDLER_TIMEOUT_MS, MAX_CONCURRENT_HANDLERS
|
|
33
|
+
from .errors import (
|
|
34
|
+
ConfigurationError,
|
|
35
|
+
FunoraError,
|
|
36
|
+
HandlerCancelledError,
|
|
37
|
+
HandlerError,
|
|
38
|
+
HandlerTimeoutError,
|
|
39
|
+
)
|
|
40
|
+
from .events import ORDERING_KEY, REVISION_SEPARATOR, EventType
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Router",
|
|
44
|
+
"loss",
|
|
45
|
+
"incomplete",
|
|
46
|
+
"Handler",
|
|
47
|
+
"StepResult",
|
|
48
|
+
"PRODUCIBLE",
|
|
49
|
+
"dispatch",
|
|
50
|
+
"adispatch",
|
|
51
|
+
"dispatch_core",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
_log = logging.getLogger("funora.watch")
|
|
55
|
+
|
|
56
|
+
#: Обработчик события. Асинхронный клиент принимает и сопрограммы: там
|
|
57
|
+
#: возвращённое ожидаемое значение дожидается, а здесь - отвергается вслух.
|
|
58
|
+
Handler = Callable[[Event], object]
|
|
59
|
+
|
|
60
|
+
#: Просьба вызвать один обработчик на одном событии. Общая часть раздачи
|
|
61
|
+
#: возвращает её вместо вызова: вызывать синхронно и асинхронно - разные вещи, а
|
|
62
|
+
#: решать, что считать отказом и можно ли двигать курсор, - одна и та же.
|
|
63
|
+
Invoke = tuple[Handler, Event]
|
|
64
|
+
|
|
65
|
+
#: Событие, которым отмечается сохранение первого снимка.
|
|
66
|
+
_PRIMED: Final[EventType] = EventType.WATCH_PRIMED
|
|
67
|
+
|
|
68
|
+
#: Событие, которым отмечается неполно собранный снимок.
|
|
69
|
+
_INCOMPLETE: Final[EventType] = EventType.SNAPSHOT_INCOMPLETE
|
|
70
|
+
|
|
71
|
+
#: Событие, которым объявляется потеря событий.
|
|
72
|
+
_LOSS: Final[EventType] = EventType.EVENT_LOSS
|
|
73
|
+
|
|
74
|
+
#: Вид события о смене состояния доступа.
|
|
75
|
+
_HEALTH: Final[EventType] = EventType.PROTOCOL_HEALTH_CHANGED
|
|
76
|
+
|
|
77
|
+
#: Причина, по которой наблюдение объявляется начавшимся.
|
|
78
|
+
_COLD_START: Final[str] = "cold_start"
|
|
79
|
+
|
|
80
|
+
#: Разделитель частей версии сущности.
|
|
81
|
+
#:
|
|
82
|
+
#: Управляющий знак, а не двоеточие: причина неполноты приходит со страницы
|
|
83
|
+
#: и содержать двоеточие вправе.
|
|
84
|
+
#:
|
|
85
|
+
#: Величина берётся из порождённого файла. Прежде здесь стоял литерал U+001F -
|
|
86
|
+
#: тот же знак, которым склеивается сам отпечаток, - и составная версия
|
|
87
|
+
#: клала разделитель отпечатка внутрь его же части.
|
|
88
|
+
_PART_SEP: Final[str] = REVISION_SEPARATOR
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
#: Виды событий, которые эта реализация вправду порождает.
|
|
92
|
+
#:
|
|
93
|
+
#: Первоначально из шестнадцати видов порождались пять. Прочие
|
|
94
|
+
#: одиннадцать оказались ловушкой: обработчик на них принимался
|
|
95
|
+
#: без возражений и не срабатывал ни разу, а молчание неотличимо от «ничего не
|
|
96
|
+
#: произошло».
|
|
97
|
+
#:
|
|
98
|
+
#: Однажды это уже случилось с message.created - событие было объявлено, а цикл
|
|
99
|
+
#: наблюдения не читал переписок вовсе. Починили тогда одно событие; правила,
|
|
100
|
+
#: по которому этого не случится снова, не написали.
|
|
101
|
+
#:
|
|
102
|
+
#: Перечень обязан быть заработанным: проверка сверяет его с тем, что вправду
|
|
103
|
+
#: порождается на снимках. Объявить вид и не порождать - то же молчание с другой
|
|
104
|
+
#: стороны.
|
|
105
|
+
PRODUCIBLE: Final[frozenset[EventType]] = frozenset(
|
|
106
|
+
{
|
|
107
|
+
EventType.MESSAGE_CREATED,
|
|
108
|
+
EventType.CHAT_UNREAD_CHANGED,
|
|
109
|
+
EventType.ORDER_CREATED,
|
|
110
|
+
EventType.ORDER_STATUS_CHANGED,
|
|
111
|
+
EventType.WATCH_PRIMED,
|
|
112
|
+
EventType.WATCH_DEGRADED,
|
|
113
|
+
EventType.SNAPSHOT_INCOMPLETE,
|
|
114
|
+
EventType.EVENT_LOSS,
|
|
115
|
+
EventType.PROTOCOL_HEALTH_CHANGED,
|
|
116
|
+
EventType.MARKET_OFFER_APPEARED,
|
|
117
|
+
EventType.MARKET_OFFER_DISAPPEARED,
|
|
118
|
+
EventType.MARKET_PRICE_CHANGED,
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Router:
|
|
125
|
+
"""Реестр обработчиков событий.
|
|
126
|
+
|
|
127
|
+
Обработчики хранятся по типу события. Обработчик, зарегистрированный без
|
|
128
|
+
типа, получает все события: это нужно журналированию и метрикам, которым
|
|
129
|
+
важны не отдельные типы, а поток целиком.
|
|
130
|
+
|
|
131
|
+
Attributes:
|
|
132
|
+
by_type (dict[EventType, list[Handler]]): Обработчики по типам.
|
|
133
|
+
catch_all (list[Handler]): Обработчики, получающие все события.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
by_type: dict[EventType, list[Handler]] = field(default_factory=dict)
|
|
137
|
+
catch_all: list[Handler] = field(default_factory=list)
|
|
138
|
+
|
|
139
|
+
def on(self, event_type: EventType | None = None) -> Callable[[Handler], Handler]:
|
|
140
|
+
"""Регистрирует обработчик события.
|
|
141
|
+
|
|
142
|
+
Подписка на вид, которого реализация не порождает, отвергается вслух.
|
|
143
|
+
Промолчать значило бы завести обработчик, который не выполнится никогда,
|
|
144
|
+
- а его молчание неотличимо от «ничего не произошло». Продавец,
|
|
145
|
+
подписавшийся на отзывы, увидел бы ровно то же, что при отсутствии новых
|
|
146
|
+
отзывов, и узнал бы об ошибке в тот день, когда отзыв придёт.
|
|
147
|
+
|
|
148
|
+
Отказ происходит при регистрации, то есть при запуске. Строка в журнале
|
|
149
|
+
была бы тем же молчанием с отсрочкой: журнал читают после происшествия,
|
|
150
|
+
а не до.
|
|
151
|
+
|
|
152
|
+
Обработчик без указания вида отказа не получает: он просит поток
|
|
153
|
+
целиком и видит ровно то, что пришло.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
event_type (EventType | None): Тип события. None означает все типы.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Callable[[Handler], Handler]: Декоратор, возвращающий обработчик
|
|
160
|
+
без изменений, чтобы его можно было вызвать и напрямую.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
ConfigurationError: Если вид события эта реализация не порождает.
|
|
164
|
+
"""
|
|
165
|
+
if event_type is not None and event_type not in PRODUCIBLE:
|
|
166
|
+
raise ConfigurationError(
|
|
167
|
+
f"эта реализация не порождает событий вида {event_type}; "
|
|
168
|
+
f"порождаются: {', '.join(sorted(str(kind) for kind in PRODUCIBLE))}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def register(handler: Handler) -> Handler:
|
|
172
|
+
"""Добавляет обработчик в реестр.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
handler (Handler): Обработчик события.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Handler: Тот же обработчик.
|
|
179
|
+
"""
|
|
180
|
+
if event_type is None:
|
|
181
|
+
self.catch_all.append(handler)
|
|
182
|
+
else:
|
|
183
|
+
self.by_type.setdefault(event_type, []).append(handler)
|
|
184
|
+
return handler
|
|
185
|
+
|
|
186
|
+
return register
|
|
187
|
+
|
|
188
|
+
def handlers_for(self, event: Event) -> tuple[Handler, ...]:
|
|
189
|
+
"""Возвращает обработчики, которым положено это событие.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
event (Event): Событие.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
tuple[Handler, ...]: Обработчики в порядке регистрации: сначала
|
|
196
|
+
привязанные к типу, потом получающие всё.
|
|
197
|
+
"""
|
|
198
|
+
return (*self.by_type.get(event.type, ()), *self.catch_all)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class StepResult:
|
|
203
|
+
"""Итог одного шага наблюдения.
|
|
204
|
+
|
|
205
|
+
Attributes:
|
|
206
|
+
delivered (tuple[Event, ...]): События, дошедшие до обработчиков.
|
|
207
|
+
failed (tuple[Event, ...]): Непринятые события, включая следующие за
|
|
208
|
+
отказом того же ключа, ещё не переданные обработчикам. Курсор
|
|
209
|
+
не сдвигается, пока список непуст: иначе они исчезнут навсегда.
|
|
210
|
+
advance (bool): Можно ли сдвигать курсор.
|
|
211
|
+
errors (tuple[HandlerError, ...]): Отказы обработчиков.
|
|
212
|
+
fatal (FunoraError | None): Первая ошибка Funora, поднятая обработчиком.
|
|
213
|
+
Это не его баг, а условие площадки - истёкшая сессия, исчерпанный
|
|
214
|
+
бюджет, - и вызывающий обязан её увидеть. Партия при этом
|
|
215
|
+
дорабатывается до конца: отказ на первом событии не должен терять
|
|
216
|
+
все остальные.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
delivered: tuple[Event, ...]
|
|
220
|
+
failed: tuple[Event, ...]
|
|
221
|
+
advance: bool
|
|
222
|
+
errors: tuple[HandlerError, ...]
|
|
223
|
+
fatal: FunoraError | None = None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def dispatch_core(
|
|
227
|
+
router: Router, events: tuple[Event, ...]
|
|
228
|
+
) -> Generator[Invoke, Exception | None, StepResult]:
|
|
229
|
+
"""Раздаёт события обработчикам и решает судьбу базового снимка.
|
|
230
|
+
|
|
231
|
+
Обработчики здесь не вызываются. Ядро просит вызвать очередной и ждёт
|
|
232
|
+
ответа: None, если обошлось, либо пойманное исключение. Так решение о том,
|
|
233
|
+
что считать отказом и можно ли двигать курсор, остаётся одно на синхронный
|
|
234
|
+
и асинхронный клиент - а оно и есть главное правило цикла.
|
|
235
|
+
|
|
236
|
+
Порядок соблюдается внутри одного ключа упорядочивания. События с разными
|
|
237
|
+
ключами независимы, и здесь они всё равно идут последовательно: правило про
|
|
238
|
+
порядок этим не нарушается.
|
|
239
|
+
|
|
240
|
+
Отказ блокирует следующие события своего ключа и сдвиг базы.
|
|
241
|
+
Независимые ключи обрабатываются, непринятое сохраняется для повтора.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
router (Router): Реестр обработчиков.
|
|
245
|
+
events (tuple[Event, ...]): События этого шага.
|
|
246
|
+
|
|
247
|
+
Yields:
|
|
248
|
+
Invoke: Пара «обработчик и событие», которую надо вызвать.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
StepResult: Что доставлено, что нет, и можно ли сдвигать базу.
|
|
252
|
+
"""
|
|
253
|
+
delivered: list[Event] = []
|
|
254
|
+
failed: list[Event] = []
|
|
255
|
+
errors: list[HandlerError] = []
|
|
256
|
+
fatal: FunoraError | None = None
|
|
257
|
+
|
|
258
|
+
blocked: set[str] = set()
|
|
259
|
+
for event in events:
|
|
260
|
+
if event.ordering_key in blocked:
|
|
261
|
+
failed.append(event)
|
|
262
|
+
continue
|
|
263
|
+
handlers = router.handlers_for(event)
|
|
264
|
+
if not handlers:
|
|
265
|
+
# Событие без обработчика не считается непринятым: подписка на всё
|
|
266
|
+
# подряд не обязанность вызывающего, и база из-за неё стоять не
|
|
267
|
+
# должна.
|
|
268
|
+
delivered.append(event)
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
broke = False
|
|
272
|
+
for handler in handlers:
|
|
273
|
+
exc = yield (handler, event)
|
|
274
|
+
if exc is None:
|
|
275
|
+
continue
|
|
276
|
+
if isinstance(exc, HandlerError):
|
|
277
|
+
# Отказ обработчика - его беда, а не условие площадки, и это
|
|
278
|
+
# верно даже для отказа по времени: HandlerTimeoutError входит в
|
|
279
|
+
# иерархию Funora, и без этой ветки один задумавшийся обработчик
|
|
280
|
+
# останавливал бы наблюдение целиком.
|
|
281
|
+
#
|
|
282
|
+
# Ветка стоит ПЕРЕД проверкой на FunoraError намеренно:
|
|
283
|
+
# HandlerError её потомок, и обратный порядок отправил бы отказ
|
|
284
|
+
# обработчика в fatal.
|
|
285
|
+
broke = True
|
|
286
|
+
errors.append(exc)
|
|
287
|
+
_log.warning(
|
|
288
|
+
"обработчик отказал на событии %s (ключ %s): %s",
|
|
289
|
+
event.type,
|
|
290
|
+
event.ordering_key,
|
|
291
|
+
type(exc).__name__,
|
|
292
|
+
)
|
|
293
|
+
break
|
|
294
|
+
if isinstance(exc, FunoraError):
|
|
295
|
+
# Раньше здесь стоял raise, и партия обрывалась посреди раздачи:
|
|
296
|
+
# накопленные delivered и failed пропадали, курсор не
|
|
297
|
+
# сохранялся, а цикл падал целиком. Условие площадки при этом
|
|
298
|
+
# никуда не девалось - оно просто уносило с собой все остальные
|
|
299
|
+
# события партии.
|
|
300
|
+
broke = True
|
|
301
|
+
if fatal is None:
|
|
302
|
+
fatal = exc
|
|
303
|
+
_log.warning(
|
|
304
|
+
"обработчик получил ошибку площадки на событии %s (ключ %s): %s",
|
|
305
|
+
event.type,
|
|
306
|
+
event.ordering_key,
|
|
307
|
+
type(exc).__name__,
|
|
308
|
+
exc_info=exc,
|
|
309
|
+
)
|
|
310
|
+
break
|
|
311
|
+
broke = True
|
|
312
|
+
error = HandlerError(
|
|
313
|
+
f"обработчик {getattr(handler, '__name__', handler)!r} упал на "
|
|
314
|
+
f"событии {event.type} с ключом {event.ordering_key}: "
|
|
315
|
+
f"{type(exc).__name__}"
|
|
316
|
+
)
|
|
317
|
+
error.__cause__ = exc
|
|
318
|
+
errors.append(error)
|
|
319
|
+
# exc_info обязателен. Трассировка сохранена в error.__cause__ и
|
|
320
|
+
# никуда дальше не идёт: движок читает у результата delivered,
|
|
321
|
+
# advance, fatal и длину failed, а errors не читает никто. Без неё
|
|
322
|
+
# в журнале остаётся имя класса без единой строки о том, ГДЕ упало,
|
|
323
|
+
# а событие приходит снова каждый шаг - и так по кругу.
|
|
324
|
+
_log.warning(
|
|
325
|
+
"обработчик упал на событии %s (ключ %s): %s",
|
|
326
|
+
event.type,
|
|
327
|
+
event.ordering_key,
|
|
328
|
+
type(exc).__name__,
|
|
329
|
+
exc_info=exc,
|
|
330
|
+
)
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
if broke:
|
|
334
|
+
blocked.add(event.ordering_key)
|
|
335
|
+
(failed if broke else delivered).append(event)
|
|
336
|
+
|
|
337
|
+
return StepResult(
|
|
338
|
+
delivered=tuple(delivered),
|
|
339
|
+
failed=tuple(failed),
|
|
340
|
+
# Курсор сдвигается только когда непринятых нет. Сдвинь его раньше - и
|
|
341
|
+
# событие, на котором обработчик упал, исчезнет навсегда.
|
|
342
|
+
advance=not failed,
|
|
343
|
+
errors=tuple(errors),
|
|
344
|
+
fatal=fatal,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def dispatch(router: Router, events: tuple[Event, ...]) -> StepResult:
|
|
349
|
+
"""Раздаёт события синхронно.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
router (Router): Реестр обработчиков.
|
|
353
|
+
events (tuple[Event, ...]): События этого шага.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
StepResult: Что доставлено, что нет, и можно ли сдвигать базу.
|
|
357
|
+
|
|
358
|
+
Raises:
|
|
359
|
+
ConfigurationError: Если обработчик оказался сопрограммой. Синхронный
|
|
360
|
+
клиент дожидаться её не умеет, а промолчать здесь значило бы
|
|
361
|
+
зарегистрировать обработчик, который никогда не выполнится: ни
|
|
362
|
+
исключения, ни события в журнале - просто ничего не происходит.
|
|
363
|
+
"""
|
|
364
|
+
core = dispatch_core(router, events)
|
|
365
|
+
reply: Exception | None = None
|
|
366
|
+
while True:
|
|
367
|
+
try:
|
|
368
|
+
handler, event = core.send(reply)
|
|
369
|
+
except StopIteration as stop:
|
|
370
|
+
result: StepResult = stop.value
|
|
371
|
+
return result
|
|
372
|
+
reply = None
|
|
373
|
+
try:
|
|
374
|
+
outcome = handler(event)
|
|
375
|
+
except Exception as exc:
|
|
376
|
+
reply = exc
|
|
377
|
+
continue
|
|
378
|
+
if inspect.isawaitable(outcome):
|
|
379
|
+
# Сопрограмму надо закрыть вручную, иначе интерпретатор допишет к
|
|
380
|
+
# нашему внятному отказу своё «coroutine was never awaited».
|
|
381
|
+
close = getattr(outcome, "close", None)
|
|
382
|
+
if close is not None:
|
|
383
|
+
close()
|
|
384
|
+
raise ConfigurationError(
|
|
385
|
+
f"обработчик {getattr(handler, '__name__', handler)!r} асинхронный, "
|
|
386
|
+
"а клиент синхронный: дождаться его здесь некому. Возьмите "
|
|
387
|
+
"AsyncClient либо сделайте обработчик обычной функцией"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
async def _adispatch_serially(router: Router, events: tuple[Event, ...]) -> StepResult:
|
|
392
|
+
"""Раздаёт события асинхронно и последовательно.
|
|
393
|
+
|
|
394
|
+
Принимаются и обычные функции, и сопрограммы: возвращённое ожидаемое
|
|
395
|
+
значение дожидается, обычный результат берётся как есть. Решение о том, что
|
|
396
|
+
считать отказом, - общее с синхронной раздачей и живёт в [dispatch_core].
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
router (Router): Реестр обработчиков.
|
|
400
|
+
events (tuple[Event, ...]): События этого шага.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
StepResult: Что доставлено, что нет, и можно ли сдвигать базу.
|
|
404
|
+
"""
|
|
405
|
+
core = dispatch_core(router, events)
|
|
406
|
+
reply: Exception | None = None
|
|
407
|
+
while True:
|
|
408
|
+
try:
|
|
409
|
+
handler, event = core.send(reply)
|
|
410
|
+
except StopIteration as stop:
|
|
411
|
+
result: StepResult = stop.value
|
|
412
|
+
return result
|
|
413
|
+
reply = None
|
|
414
|
+
try:
|
|
415
|
+
outcome = handler(event)
|
|
416
|
+
if inspect.isawaitable(outcome):
|
|
417
|
+
# Предел времени объявлен спецификацией и до сих пор не
|
|
418
|
+
# применялся нигде: обработчик, ушедший в вечное ожидание,
|
|
419
|
+
# останавливал цикл наблюдения целиком. Ни исключения, ни
|
|
420
|
+
# строки в журнале - клиент просто переставал ходить на
|
|
421
|
+
# площадку, и внешне это неотличимо от «ничего не происходит».
|
|
422
|
+
#
|
|
423
|
+
# Обещание должно быть либо выполнено, либо снято. Здесь оно
|
|
424
|
+
# выполнимо: сопрограмму можно отменить.
|
|
425
|
+
await asyncio.wait_for(outcome, HANDLER_TIMEOUT_MS / 1000)
|
|
426
|
+
except TimeoutError as exc:
|
|
427
|
+
_log.warning(
|
|
428
|
+
"обработчик не уложился в %d мс на событии %s (ключ %s)",
|
|
429
|
+
HANDLER_TIMEOUT_MS,
|
|
430
|
+
event.type,
|
|
431
|
+
event.ordering_key,
|
|
432
|
+
exc_info=exc,
|
|
433
|
+
)
|
|
434
|
+
timeout = HandlerTimeoutError(
|
|
435
|
+
f"обработчик {getattr(handler, '__name__', handler)!r} не уложился "
|
|
436
|
+
f"в {HANDLER_TIMEOUT_MS} мс на событии {event.type} с ключом "
|
|
437
|
+
f"{event.ordering_key}"
|
|
438
|
+
)
|
|
439
|
+
timeout.__cause__ = exc
|
|
440
|
+
reply = timeout
|
|
441
|
+
except asyncio.CancelledError as exc:
|
|
442
|
+
# CancelledError - потомок BaseException, а не Exception, поэтому
|
|
443
|
+
# ветка ниже её не ловила и она пробивала раздачу насквозь: партия
|
|
444
|
+
# теряла и доставленное, и недоставленное, курсор не сохранялся.
|
|
445
|
+
#
|
|
446
|
+
# Но проглотить её целиком нельзя. Отмена, пришедшая ИЗВНЕ, - это
|
|
447
|
+
# отмена всей задачи, и съев её, мы сделали бы задачу неотменяемой.
|
|
448
|
+
# Различить два случая позволяет счётчик отмен самой задачи: он
|
|
449
|
+
# больше нуля ровно тогда, когда отменяют нас, а не когда отменился
|
|
450
|
+
# обработчик.
|
|
451
|
+
task = asyncio.current_task()
|
|
452
|
+
if task is not None and task.cancelling() > 0:
|
|
453
|
+
raise
|
|
454
|
+
_log.warning(
|
|
455
|
+
"обработчик отменился на событии %s (ключ %s)",
|
|
456
|
+
event.type,
|
|
457
|
+
event.ordering_key,
|
|
458
|
+
exc_info=exc,
|
|
459
|
+
)
|
|
460
|
+
cancelled = HandlerCancelledError(
|
|
461
|
+
f"обработчик {getattr(handler, '__name__', handler)!r} отменён "
|
|
462
|
+
f"на событии {event.type} с ключом {event.ordering_key}"
|
|
463
|
+
)
|
|
464
|
+
cancelled.__cause__ = exc
|
|
465
|
+
reply = cancelled
|
|
466
|
+
except Exception as exc: # noqa: BLE001
|
|
467
|
+
reply = exc
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _by_ordering_key(events: tuple[Event, ...]) -> tuple[tuple[Event, ...], ...]:
|
|
471
|
+
"""Разбивает партию на группы по ключу упорядочивания.
|
|
472
|
+
|
|
473
|
+
Спецификация говорит прямо: порядок сохраняется внутри одного ключа, события
|
|
474
|
+
с разными ключами порядка между собой не имеют. Отсюда и разбиение - внутри
|
|
475
|
+
группы события останутся последовательными, между группами могут идти
|
|
476
|
+
одновременно.
|
|
477
|
+
|
|
478
|
+
Порядок групп - по первому вошедшему событию. Он нужен не исполнению, а
|
|
479
|
+
сборке итога: итог обязан получиться один и тот же независимо от того, кто
|
|
480
|
+
из групп успел раньше.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
events (tuple[Event, ...]): События партии в порядке порождения.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
tuple[tuple[Event, ...], ...]: Группы в порядке первого события.
|
|
487
|
+
"""
|
|
488
|
+
groups: dict[str, list[Event]] = {}
|
|
489
|
+
for event in events:
|
|
490
|
+
groups.setdefault(event.ordering_key, []).append(event)
|
|
491
|
+
return tuple(tuple(group) for group in groups.values())
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _merge(events: tuple[Event, ...], results: tuple[StepResult, ...]) -> StepResult:
|
|
495
|
+
"""Собирает итог партии из итогов групп.
|
|
496
|
+
|
|
497
|
+
Сборка нарочно не зависит от того, кто из групп завершился раньше. Иначе одна
|
|
498
|
+
и та же партия давала бы разные итоги от запуска к запуску, а разными в них
|
|
499
|
+
были бы список непринятых событий и первая ошибка площадки - то есть ровно
|
|
500
|
+
то, по чему принимается решение о сдвиге курсора.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
events (tuple[Event, ...]): События партии в исходном порядке.
|
|
504
|
+
results (tuple[StepResult, ...]): Итоги групп в порядке групп.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
StepResult: Итог партии.
|
|
508
|
+
"""
|
|
509
|
+
place = {id(event): index for index, event in enumerate(events)}
|
|
510
|
+
|
|
511
|
+
def ordered(chosen: list[Event]) -> tuple[Event, ...]:
|
|
512
|
+
"""Возвращает события в исходном порядке партии.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
chosen (list[Event]): События вперемешку.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
tuple[Event, ...]: Они же в порядке порождения.
|
|
519
|
+
"""
|
|
520
|
+
return tuple(sorted(chosen, key=lambda event: place[id(event)]))
|
|
521
|
+
|
|
522
|
+
delivered: list[Event] = []
|
|
523
|
+
failed: list[Event] = []
|
|
524
|
+
errors: list[HandlerError] = []
|
|
525
|
+
fatal: FunoraError | None = None
|
|
526
|
+
for result in results:
|
|
527
|
+
delivered.extend(result.delivered)
|
|
528
|
+
failed.extend(result.failed)
|
|
529
|
+
errors.extend(result.errors)
|
|
530
|
+
if fatal is None:
|
|
531
|
+
fatal = result.fatal
|
|
532
|
+
|
|
533
|
+
return StepResult(
|
|
534
|
+
delivered=ordered(delivered),
|
|
535
|
+
failed=ordered(failed),
|
|
536
|
+
advance=not failed,
|
|
537
|
+
errors=tuple(errors),
|
|
538
|
+
fatal=fatal,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
async def adispatch(
|
|
543
|
+
router: Router, events: tuple[Event, ...], *, concurrency: int = 1
|
|
544
|
+
) -> StepResult:
|
|
545
|
+
"""Раздаёт события асинхронно.
|
|
546
|
+
|
|
547
|
+
По умолчанию последовательно - и это не осторожность ради осторожности.
|
|
548
|
+
Обработчики принадлежат вызывающему, и одновременный их запуск меняет
|
|
549
|
+
условия, в которых они писались: счётчик, дописывание в файл, соединение с
|
|
550
|
+
базой перестают быть в единоличном пользовании. Просить об этом надо явно.
|
|
551
|
+
|
|
552
|
+
С ``concurrency`` больше единицы события раздаются группами по ключу
|
|
553
|
+
упорядочивания. Внутри группы порядок сохраняется, между группами - нет, и
|
|
554
|
+
это ровно то, что говорит спецификация: события с разными ключами порядка
|
|
555
|
+
между собой не имеют.
|
|
556
|
+
|
|
557
|
+
Итог собирается детерминированно, независимо от того, кто из групп успел
|
|
558
|
+
раньше. Иначе одна и та же партия давала бы разные списки непринятых
|
|
559
|
+
событий - то есть разные решения о сдвиге курсора.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
router (Router): Реестр обработчиков.
|
|
563
|
+
events (tuple[Event, ...]): События этого шага.
|
|
564
|
+
concurrency (int): Сколько ключей упорядочивания обрабатывать
|
|
565
|
+
одновременно. Единица означает последовательную раздачу. Значение
|
|
566
|
+
выше объявленного спецификацией предела отвергается: договор об
|
|
567
|
+
одновременности - часть контракта, а не настройка вызывающего.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
StepResult: Что доставлено, что нет, и можно ли сдвигать базу.
|
|
571
|
+
|
|
572
|
+
Raises:
|
|
573
|
+
ConfigurationError: Если запрошено больше одновременных обработчиков,
|
|
574
|
+
чем объявляет спецификация.
|
|
575
|
+
"""
|
|
576
|
+
if concurrency > MAX_CONCURRENT_HANDLERS:
|
|
577
|
+
# Предел объявлен спецификацией и до сих пор не применялся: вызывающий
|
|
578
|
+
# мог попросить хоть тысячу. Тысяча одновременных обработчиков - это
|
|
579
|
+
# тысяча одновременных соединений с чужой базой у него и, что важнее,
|
|
580
|
+
# тысяча параллельных реакций на площадке.
|
|
581
|
+
#
|
|
582
|
+
# Отказ, а не тихое понижение: понизить молча значило бы дать
|
|
583
|
+
# вызывающему неверное представление о том, как работает его код.
|
|
584
|
+
raise ConfigurationError(
|
|
585
|
+
f"запрошено {concurrency} одновременных обработчиков, "
|
|
586
|
+
f"спецификация объявляет предел {MAX_CONCURRENT_HANDLERS}"
|
|
587
|
+
)
|
|
588
|
+
if concurrency <= 1 or len(events) < 2:
|
|
589
|
+
return await _adispatch_serially(router, events)
|
|
590
|
+
|
|
591
|
+
groups = _by_ordering_key(events)
|
|
592
|
+
if len(groups) < 2:
|
|
593
|
+
return await _adispatch_serially(router, events)
|
|
594
|
+
|
|
595
|
+
limit = asyncio.Semaphore(concurrency)
|
|
596
|
+
|
|
597
|
+
async def run(group: tuple[Event, ...]) -> StepResult:
|
|
598
|
+
"""Раздаёт одну группу, не превышая заданной одновременности.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
group (tuple[Event, ...]): События одного ключа упорядочивания.
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
StepResult: Итог группы.
|
|
605
|
+
"""
|
|
606
|
+
async with limit:
|
|
607
|
+
return await _adispatch_serially(router, group)
|
|
608
|
+
|
|
609
|
+
results = await asyncio.gather(*(run(group) for group in groups))
|
|
610
|
+
return _merge(events, tuple(results))
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def primed(
|
|
614
|
+
account_id: str,
|
|
615
|
+
observed_at: datetime,
|
|
616
|
+
entities: tuple[str, ...],
|
|
617
|
+
*,
|
|
618
|
+
watch_id: str | None = None,
|
|
619
|
+
) -> Event:
|
|
620
|
+
"""Собирает событие о сохранении первого снимка.
|
|
621
|
+
|
|
622
|
+
Холодный старт молчит намеренно: события на каждую существующую сущность
|
|
623
|
+
дали бы лавину «изменений» по всему наблюдаемому множеству сразу. Но молчать
|
|
624
|
+
совсем нельзя - вызывающий должен знать, что наблюдение началось, а не что
|
|
625
|
+
оно молчит по неисправности.
|
|
626
|
+
|
|
627
|
+
Идентификатор и ключ упорядочивания строятся общим строителем, а не вручную.
|
|
628
|
+
Прежде здесь собиралась строка «primed:{account_id}:{ordering_key}» с
|
|
629
|
+
ключом «account:...», и это расходилось с нормативным «watch:{watch_id}»
|
|
630
|
+
из спецификации, а версии сущности в идентификаторе не было вовсе.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
account_id (str): Идентификатор аккаунта. Он же служит идентификатором
|
|
634
|
+
наблюдения: наблюдение ведётся за аккаунтом целиком.
|
|
635
|
+
observed_at (datetime): Момент наблюдения.
|
|
636
|
+
entities (tuple[str, ...]): За чем установлено наблюдение.
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
Event: Событие watch.primed.
|
|
640
|
+
"""
|
|
641
|
+
return make_event(
|
|
642
|
+
account_id=account_id,
|
|
643
|
+
event_type=_PRIMED,
|
|
644
|
+
entity_id=account_id if watch_id is None else watch_id,
|
|
645
|
+
# Версия - причина. Приветствие приходит один раз за срок гашения, и
|
|
646
|
+
# второе приветствие того же наблюдения было бы повтором.
|
|
647
|
+
revision=_COLD_START,
|
|
648
|
+
observed_at=observed_at,
|
|
649
|
+
key_field="watch_id",
|
|
650
|
+
payload={
|
|
651
|
+
"watch_id": account_id if watch_id is None else watch_id,
|
|
652
|
+
# Перечень, а не число. Прежняя редакция схемы объявляла здесь
|
|
653
|
+
# количество - оно отвечает на «сколько», тогда как получателю нужен
|
|
654
|
+
# ответ на «за чем».
|
|
655
|
+
"entities": list(entities),
|
|
656
|
+
"reason": _COLD_START,
|
|
657
|
+
},
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def health_changed(
|
|
662
|
+
account_id: str,
|
|
663
|
+
observed_at: datetime,
|
|
664
|
+
*,
|
|
665
|
+
before: str,
|
|
666
|
+
after: str,
|
|
667
|
+
reason: str,
|
|
668
|
+
writes_paused: bool,
|
|
669
|
+
) -> Event:
|
|
670
|
+
"""Собирает событие о смене состояния доступа к площадке.
|
|
671
|
+
|
|
672
|
+
Смена, о которой не сказали, неотличима от её отсутствия: вызывающий видит,
|
|
673
|
+
что автоматика записи молчит, и не знает почему. Отсюда правило - всякая
|
|
674
|
+
смена состояния порождает событие, а переход в то же состояние не порождает
|
|
675
|
+
ничего: поток сообщений о неизменном заглушил бы сообщение об изменении.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
account_id (str): Идентификатор аккаунта.
|
|
679
|
+
observed_at (datetime): Момент наблюдения.
|
|
680
|
+
before (str): Прежнее состояние.
|
|
681
|
+
after (str): Новое состояние.
|
|
682
|
+
reason (str): Машиночитаемая причина перехода.
|
|
683
|
+
writes_paused (bool): Приостановлена ли автоматика записи.
|
|
684
|
+
|
|
685
|
+
Returns:
|
|
686
|
+
Event: Событие protocol.health_changed.
|
|
687
|
+
"""
|
|
688
|
+
return make_event(
|
|
689
|
+
account_id=account_id,
|
|
690
|
+
event_type=_HEALTH,
|
|
691
|
+
entity_id=account_id,
|
|
692
|
+
# Версия - новое состояние, как объявлено нормативным перечнем
|
|
693
|
+
# источников версии. Возврат в прежнее состояние даёт прежний
|
|
694
|
+
# отпечаток, и гашение повторов срабатывает само.
|
|
695
|
+
revision=after,
|
|
696
|
+
observed_at=observed_at,
|
|
697
|
+
key_field="account_id",
|
|
698
|
+
payload={
|
|
699
|
+
"before": before,
|
|
700
|
+
"after": after,
|
|
701
|
+
"reason_code": reason,
|
|
702
|
+
"writes_paused": writes_paused,
|
|
703
|
+
},
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def incomplete(
|
|
708
|
+
account_id: str,
|
|
709
|
+
observed_at: datetime,
|
|
710
|
+
*,
|
|
711
|
+
entity: str,
|
|
712
|
+
reason: str,
|
|
713
|
+
rows_total: int,
|
|
714
|
+
rows_accepted: int,
|
|
715
|
+
entity_ref: str | None = None,
|
|
716
|
+
watch_id: str | None = None,
|
|
717
|
+
) -> Event:
|
|
718
|
+
"""Собирает событие о неполно собранном снимке.
|
|
719
|
+
|
|
720
|
+
Цикл наблюдения умел обращаться с неполным чтением и молчал о нём. Курсор он
|
|
721
|
+
не двигал - это верно и защищает будущее: строки, выпавшие из неполного
|
|
722
|
+
чтения, не будут сочтены исчезнувшими. Настоящее несдвинутый курсор не
|
|
723
|
+
защищает никак: события по прочитанному порождаются, и обработчик принимает
|
|
724
|
+
их за полную картину - обрабатывает часть заказов как все.
|
|
725
|
+
|
|
726
|
+
Событие приходит в той же партии, что и события по этому списку. Отдельный
|
|
727
|
+
поздний сигнал бесполезен: решение по неполным данным к тому времени уже
|
|
728
|
+
принято.
|
|
729
|
+
|
|
730
|
+
Версия сущности - сущность, причина И ОБА ЧИСЛА. Первая редакция брала одно
|
|
731
|
+
число принятых строк, и этого мало: чтение 50 из 50 с тремя отброшенными и
|
|
732
|
+
чтение 100 из 100 с пятьюдесятью тремя отброшенными дают одинаковое число
|
|
733
|
+
принятых в одном случае из многих, а разница между «не хватает трёх» и «не
|
|
734
|
+
хватает пятидесяти трёх» - это новость, которую гасить нельзя.
|
|
735
|
+
|
|
736
|
+
Args:
|
|
737
|
+
account_id (str): Идентификатор аккаунта, он же наблюдения.
|
|
738
|
+
observed_at (datetime): Момент наблюдения.
|
|
739
|
+
entity (str): Что прочитано неполно: orders, chats, thread.
|
|
740
|
+
reason (str): Машиночитаемая причина неполноты со страницы.
|
|
741
|
+
rows_total (int): Сколько кандидатов в строки нашлось.
|
|
742
|
+
rows_accepted (int): Сколько строк принято.
|
|
743
|
+
entity_ref (str | None): К какой сущности относится неполнота, если она
|
|
744
|
+
не про список целиком. У переписки это диалог: без ссылки получатель
|
|
745
|
+
не узнает, какой из полусотни прочитан наполовину.
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
Event: Событие snapshot.incomplete.
|
|
749
|
+
"""
|
|
750
|
+
return make_event(
|
|
751
|
+
account_id=account_id,
|
|
752
|
+
event_type=_INCOMPLETE,
|
|
753
|
+
entity_id=account_id if watch_id is None else watch_id,
|
|
754
|
+
revision=_PART_SEP.join(
|
|
755
|
+
(entity, entity_ref or "", reason, f"{rows_accepted}/{rows_total}")
|
|
756
|
+
),
|
|
757
|
+
observed_at=observed_at,
|
|
758
|
+
key_field="watch_id",
|
|
759
|
+
payload={
|
|
760
|
+
# Идентификатор наблюдения лежит и в нагрузке: так велит схема
|
|
761
|
+
# события, и это не дубль конверта без причины. Нагрузку принято
|
|
762
|
+
# передавать дальше отдельно от конверта - в очередь, в журнал, - и
|
|
763
|
+
# там она обязана оставаться самодостаточной.
|
|
764
|
+
"watch_id": account_id if watch_id is None else watch_id,
|
|
765
|
+
"entity": entity,
|
|
766
|
+
# Ключ на месте всегда, а None означает «неполон список целиком»:
|
|
767
|
+
# у списка нет отдельной сущности, к которой неполноту можно
|
|
768
|
+
# отнести. Это неприменимость, а не незнание, и контракт различает
|
|
769
|
+
# два этих смысла пометкой у поля.
|
|
770
|
+
#
|
|
771
|
+
# Опускать ключ было бы хуже: в нагрузке, уехавшей в очередь,
|
|
772
|
+
# отсутствие ключа неотличимо от потери по дороге.
|
|
773
|
+
"entity_ref": entity_ref,
|
|
774
|
+
# Чтение однослойное: страница запрошена одна и получена одна. Поля
|
|
775
|
+
# сохранены, потому что неполнота бывает и постраничной, а получатель
|
|
776
|
+
# обязан уметь различать роды по одному и тому же событию. Здесь
|
|
777
|
+
# расходятся не страницы, а строки.
|
|
778
|
+
"pages_fetched": 1,
|
|
779
|
+
"pages_requested": 1,
|
|
780
|
+
"rows_total": rows_total,
|
|
781
|
+
"rows_accepted": rows_accepted,
|
|
782
|
+
"reason_code": reason,
|
|
783
|
+
},
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def loss(account_id: str, observed_at: datetime, *, lost: int, reason: str) -> Event:
|
|
788
|
+
"""Собирает событие о потерянных событиях.
|
|
789
|
+
|
|
790
|
+
Очередь дочитывания переписок ограничена: спецификация объявляет предел, и
|
|
791
|
+
предел этот нужен - очередь пополняется на каждом изменении диалога, а
|
|
792
|
+
вычерпывается по несколько штук за шаг. У продавца с полусотней активных
|
|
793
|
+
переписок она растёт быстрее, чем убывает.
|
|
794
|
+
|
|
795
|
+
Но ограничить очередь и молча выбросить лишнее - худший исход из возможных:
|
|
796
|
+
сообщение покупателя не будет прочитано никогда, и узнать об этом неоткуда.
|
|
797
|
+
Поэтому выброшенное объявляется вслух.
|
|
798
|
+
|
|
799
|
+
Само это событие не выбрасывается никогда: выбросить сообщение о потере
|
|
800
|
+
значит потерять и сам факт потери.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
account_id (str): Идентификатор аккаунта, он же наблюдения.
|
|
804
|
+
observed_at (datetime): Момент наблюдения.
|
|
805
|
+
lost (int): Сколько диалогов выпало из очереди.
|
|
806
|
+
reason (str): Машиночитаемая причина потери.
|
|
807
|
+
|
|
808
|
+
Returns:
|
|
809
|
+
Event: Событие event.loss.
|
|
810
|
+
"""
|
|
811
|
+
return make_event(
|
|
812
|
+
account_id=account_id,
|
|
813
|
+
event_type=_LOSS,
|
|
814
|
+
entity_id=account_id,
|
|
815
|
+
# Версия - причина и число: две потери подряд по одной причине с разным
|
|
816
|
+
# числом - разные новости, а с одинаковым - одна и та же.
|
|
817
|
+
revision=f"{reason}{_PART_SEP}{lost}",
|
|
818
|
+
observed_at=observed_at,
|
|
819
|
+
key_field="account_id",
|
|
820
|
+
payload={
|
|
821
|
+
"ordering_key": ORDERING_KEY[_LOSS].format(account_id=account_id),
|
|
822
|
+
"lost": lost,
|
|
823
|
+
"reason_code": reason,
|
|
824
|
+
},
|
|
825
|
+
)
|