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/_poll.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Расписание опроса и гашение повторов.
|
|
2
|
+
|
|
3
|
+
Оба механизма чистые и времени не знают: момент приходит числом. Расписание,
|
|
4
|
+
смотрящее на часы, проверяется настоящими минутами, а такая проверка живёт до
|
|
5
|
+
первого раза, когда она мешает.
|
|
6
|
+
|
|
7
|
+
Расписание отвечает на вопрос «через сколько спросить снова». Оно растёт в покое
|
|
8
|
+
и сбрасывается при событии данных. Управляющие события активностью не считаются:
|
|
9
|
+
иначе сообщение о деградации само себя продлевало бы, и клиент опрашивал бы
|
|
10
|
+
площадку часто именно потому, что у него что-то сломалось.
|
|
11
|
+
|
|
12
|
+
Нижний предел интервала обычной настройкой не понижается. Это единственное
|
|
13
|
+
число, которое защищает площадку от слишком уверенного пользователя, а аккаунт
|
|
14
|
+
пользователя - от него самого. Понизить его можно только именованным аргументом
|
|
15
|
+
с приставкой unsafe_, и факт понижения виден снаружи.
|
|
16
|
+
|
|
17
|
+
Гашение повторов работает в пределах ключа упорядочивания, а не глобально.
|
|
18
|
+
Глобальный кэш склеивал бы события разных диалогов при совпадении отпечатка, а
|
|
19
|
+
разъехавшиеся события двух аккаунтов гасили бы друг друга.
|
|
20
|
+
|
|
21
|
+
Проверка и запись разделены намеренно, и это не удобство вызова. Записав событие
|
|
22
|
+
в момент проверки, мы объявили бы его доставленным до того, как обработчик его
|
|
23
|
+
увидел. Обработчик падает - база не сдвигается, событие приходит снова и гасится
|
|
24
|
+
как повтор. Оно исчезает навсегда, причём именно то, которое обработчику не
|
|
25
|
+
далось, то есть самое важное. Запись выполняется после обработчиков, тем же
|
|
26
|
+
правилом, что и продвижение курсора.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from collections import OrderedDict
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from time import time
|
|
34
|
+
from typing import Final
|
|
35
|
+
|
|
36
|
+
from ._diff import Event
|
|
37
|
+
from .budget import SCHEDULING, Scheduling
|
|
38
|
+
from .errors import StateSchemaIncompatibleError
|
|
39
|
+
from .events import DEDUP_TTL_MS, EVENT_LANE, MIN_ENTRIES_PER_KEY
|
|
40
|
+
|
|
41
|
+
__all__ = ["Schedule", "Deduplicator", "UNSAFE_FLOOR_MARK"]
|
|
42
|
+
|
|
43
|
+
#: Пометка, которой отмечается понижение нижнего предела интервала.
|
|
44
|
+
#:
|
|
45
|
+
#: Появляется в состоянии клиента и в журнале, чтобы при разборе блокировки было
|
|
46
|
+
#: видно: опрос шёл чаще, чем позволяет спецификация, и это сделали намеренно.
|
|
47
|
+
UNSAFE_FLOOR_MARK: Final[str] = "unsafe_interval_floor_lowered"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
#: Полоса событий о самом наблюдении.
|
|
51
|
+
#:
|
|
52
|
+
#: Имя взято из спецификации, где полоса объявлена вместе с правилом: события о
|
|
53
|
+
#: состоянии системы не выбрасываются никогда, потому что выбросить сообщение о
|
|
54
|
+
#: потере событий значит потерять и сам факт потери.
|
|
55
|
+
_CONTROL_PLANE: Final[str] = "control_plane"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_data(event: Event) -> bool:
|
|
59
|
+
"""Сообщает, несёт ли событие данные площадки.
|
|
60
|
+
|
|
61
|
+
Полоса объявлена спецификацией, а не выведена здесь по именам: перечень
|
|
62
|
+
имён разошёлся бы с контрактом молча, а полоса решает ещё и то, можно ли
|
|
63
|
+
выбросить событие при переполнении.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
event (Event): Событие.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
bool: True, если событие о площадке, а не о самом наблюдении.
|
|
70
|
+
"""
|
|
71
|
+
return EVENT_LANE.get(event.type) != _CONTROL_PLANE
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Schedule:
|
|
76
|
+
"""Адаптивный интервал опроса.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
numbers (Scheduling): Числа расписания. По умолчанию из спецификации.
|
|
80
|
+
unsafe_floor_ms (int | None): Понижённый нижний предел. Обычной
|
|
81
|
+
настройкой не задаётся: приставка unsafe_ в имени намеренная, а сам
|
|
82
|
+
факт понижения отмечается в :attr:`marks`.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
numbers: Scheduling = SCHEDULING
|
|
86
|
+
unsafe_floor_ms: int | None = None
|
|
87
|
+
marks: set[str] = field(default_factory=set)
|
|
88
|
+
_interval_ms: int = 0
|
|
89
|
+
_last_data_event_at: float | None = None
|
|
90
|
+
|
|
91
|
+
def __post_init__(self) -> None:
|
|
92
|
+
"""Задаёт начальный интервал и отмечает небезопасное понижение.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
None
|
|
96
|
+
"""
|
|
97
|
+
self._interval_ms = self.numbers.active_interval_ms
|
|
98
|
+
if self.unsafe_floor_ms is not None:
|
|
99
|
+
self.marks.add(UNSAFE_FLOOR_MARK)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def floor_ms(self) -> int:
|
|
103
|
+
"""Возвращает действующий нижний предел интервала.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
int: Предел в миллисекундах.
|
|
107
|
+
"""
|
|
108
|
+
if self.unsafe_floor_ms is None:
|
|
109
|
+
return self.numbers.min_floor_ms
|
|
110
|
+
return max(1, self.unsafe_floor_ms)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def interval_ms(self) -> int:
|
|
114
|
+
"""Возвращает текущий интервал опроса.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
int: Интервал в миллисекундах, не ниже действующего предела.
|
|
118
|
+
"""
|
|
119
|
+
return max(self.floor_ms, min(self._interval_ms, self.numbers.max_interval_ms))
|
|
120
|
+
|
|
121
|
+
def is_active(self, now: float) -> bool:
|
|
122
|
+
"""Сообщает, считается ли аккаунт активным.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
now (float): Текущий момент, монотонные секунды.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
bool: True, если событие данных наблюдалось внутри окна активности.
|
|
129
|
+
"""
|
|
130
|
+
if self._last_data_event_at is None:
|
|
131
|
+
return False
|
|
132
|
+
elapsed_ms = (now - self._last_data_event_at) * 1000
|
|
133
|
+
return elapsed_ms <= self.numbers.activity_window_ms
|
|
134
|
+
|
|
135
|
+
def note(self, events: tuple[Event, ...], now: float) -> int:
|
|
136
|
+
"""Учитывает итог опроса и возвращает интервал до следующего.
|
|
137
|
+
|
|
138
|
+
Признаком активности считаются только события о данных. События о
|
|
139
|
+
самом наблюдении - приветствие, жалоба на неполноту, сообщение о потере
|
|
140
|
+
- данными не являются, и прежде считались наравне с ними.
|
|
141
|
+
|
|
142
|
+
Стоило это дорого и молча. Страница, разбирающаяся неполно, порождает
|
|
143
|
+
жалобу на каждом шаге; жалоба считалась активностью, интервал не рос, и
|
|
144
|
+
клиент стучался в площадку с минимальным интервалом бесконечно - из-за
|
|
145
|
+
собственного состояния, а не из-за чужого. Это ровно тот путь, которым
|
|
146
|
+
приходят к ограничению частоты.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
events (tuple[Event, ...]): События, порождённые этим опросом.
|
|
150
|
+
now (float): Текущий момент, монотонные секунды.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
int: Через сколько миллисекунд спрашивать снова.
|
|
154
|
+
"""
|
|
155
|
+
if any(_is_data(event) for event in events):
|
|
156
|
+
self._last_data_event_at = now
|
|
157
|
+
self._interval_ms = self.numbers.active_interval_ms
|
|
158
|
+
return self.interval_ms
|
|
159
|
+
|
|
160
|
+
if self.is_active(now):
|
|
161
|
+
# Внутри окна активности интервал не растёт: событие только что
|
|
162
|
+
# было, и следующее вероятно рядом.
|
|
163
|
+
self._interval_ms = self.numbers.active_interval_ms
|
|
164
|
+
return self.interval_ms
|
|
165
|
+
|
|
166
|
+
self._interval_ms = min(
|
|
167
|
+
int(self._interval_ms * self.numbers.idle_step_multiplier),
|
|
168
|
+
self.numbers.max_interval_ms,
|
|
169
|
+
)
|
|
170
|
+
return self.interval_ms
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _now_ms() -> int:
|
|
174
|
+
"""Возвращает текущий момент по стенным часам.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
Нет.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
int: Миллисекунды от эпохи, целое число. Целое, а не дробное, потому
|
|
181
|
+
что каноническая форма запрещает числа с плавающей точкой: величина
|
|
182
|
+
уходит в файл состояния, а он объявляет себя канонической формой.
|
|
183
|
+
"""
|
|
184
|
+
return int(time() * 1000)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Deduplicator:
|
|
188
|
+
"""Гасит повторную выдачу одного и того же события.
|
|
189
|
+
|
|
190
|
+
Гарантия доставки - не менее одного раза, поэтому повторы неизбежны и
|
|
191
|
+
гасить их обязан получатель. Здесь это делается в пределах ключа
|
|
192
|
+
упорядочивания: глобальный кэш склеивал бы события разных диалогов при
|
|
193
|
+
совпадении отпечатка.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
ttl_ms (int): Сколько хранится запись о доставленном событии.
|
|
197
|
+
entries_per_key (int): Сколько записей хранится на один ключ.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
__slots__ = ("_ttl_ms", "_entries_per_key", "_seen", "_suppressed")
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
ttl_ms: int = DEDUP_TTL_MS,
|
|
205
|
+
entries_per_key: int = MIN_ENTRIES_PER_KEY,
|
|
206
|
+
) -> None:
|
|
207
|
+
self._ttl_ms = ttl_ms
|
|
208
|
+
self._entries_per_key = entries_per_key
|
|
209
|
+
self._seen: dict[str, OrderedDict[str, float]] = {}
|
|
210
|
+
self._suppressed = 0
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def suppressed(self) -> int:
|
|
214
|
+
"""Возвращает число погашенных повторов.
|
|
215
|
+
|
|
216
|
+
Величина обязана быть наблюдаемой. Ложное гашение - когда два разных
|
|
217
|
+
события схлопнулись в одно - иначе невидимо: событие просто не приходит,
|
|
218
|
+
и найти причину не по чему.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
int: Сколько событий было погашено за время жизни объекта.
|
|
222
|
+
"""
|
|
223
|
+
return self._suppressed
|
|
224
|
+
|
|
225
|
+
def filter(self, events: tuple[Event, ...], now: float) -> tuple[Event, ...]:
|
|
226
|
+
"""Отбрасывает события, которые уже были доставлены.
|
|
227
|
+
|
|
228
|
+
Метод ничего не запоминает. Запись выполняет :meth:`commit` после того,
|
|
229
|
+
как обработчики отработали: событие, помеченное доставленным до
|
|
230
|
+
обработчика, при его отказе погасится как повтор и исчезнет навсегда.
|
|
231
|
+
|
|
232
|
+
Повторы внутри одного набора схлопываются здесь же: два одинаковых
|
|
233
|
+
события в одном опросе - это одно событие, и ждать обработчиков, чтобы
|
|
234
|
+
это понять, незачем.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
events (tuple[Event, ...]): События одного опроса.
|
|
238
|
+
now (float): Текущий момент, монотонные секунды.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
tuple[Event, ...]: События, которые вызывающий ещё не видел.
|
|
242
|
+
"""
|
|
243
|
+
fresh: list[Event] = []
|
|
244
|
+
in_batch: set[tuple[str, str]] = set()
|
|
245
|
+
|
|
246
|
+
for event in events:
|
|
247
|
+
bucket = self._seen.setdefault(event.ordering_key, OrderedDict())
|
|
248
|
+
self._evict(bucket, now)
|
|
249
|
+
|
|
250
|
+
key = (event.ordering_key, event.id)
|
|
251
|
+
if event.id in bucket or key in in_batch:
|
|
252
|
+
if event.id in bucket:
|
|
253
|
+
bucket.move_to_end(event.id)
|
|
254
|
+
self._suppressed += 1
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
in_batch.add(key)
|
|
258
|
+
fresh.append(event)
|
|
259
|
+
|
|
260
|
+
return tuple(fresh)
|
|
261
|
+
|
|
262
|
+
def commit(self, events: tuple[Event, ...], now: float) -> None:
|
|
263
|
+
"""Запоминает события как доставленные.
|
|
264
|
+
|
|
265
|
+
Вызывается после обработчиков. Событие, на котором обработчик упал, сюда
|
|
266
|
+
не попадает и потому придёт снова - в этом и смысл разделения.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
events (tuple[Event, ...]): События, дошедшие до обработчиков.
|
|
270
|
+
now (float): Текущий момент, монотонные секунды.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
None
|
|
274
|
+
"""
|
|
275
|
+
for event in events:
|
|
276
|
+
bucket = self._seen.setdefault(event.ordering_key, OrderedDict())
|
|
277
|
+
bucket[event.id] = now
|
|
278
|
+
bucket.move_to_end(event.id)
|
|
279
|
+
while len(bucket) > self._entries_per_key:
|
|
280
|
+
bucket.popitem(last=False)
|
|
281
|
+
|
|
282
|
+
def snapshot(self, now: float, *, wall_ms: int | None = None) -> dict[str, dict[str, int]]:
|
|
283
|
+
"""Отдаёт состояние гашения в виде обычных значений.
|
|
284
|
+
|
|
285
|
+
Метки переводятся в момент по стенным часам - целое число миллисекунд
|
|
286
|
+
от эпохи. Внутри объект по-прежнему считает монотонными секундами, и
|
|
287
|
+
это не прихоть: монотонные часы не прыгают от синхронизации времени и
|
|
288
|
+
не идут назад, а значит срок гашения нельзя ни продлить, ни обнулить,
|
|
289
|
+
подведя системные часы.
|
|
290
|
+
|
|
291
|
+
Наружу же монотонные секунды отдавать нельзя, и прежде отдавались.
|
|
292
|
+
Начало их отсчёта своё в каждом запуске, поэтому сохранённая метка
|
|
293
|
+
после перезапуска означала не то, что значила при записи. Исходов было
|
|
294
|
+
два, оба плохие. Машину перезагрузили - показание малое, разность с
|
|
295
|
+
меткой отрицательная, и запись не истекала НИКОГДА: срок в час не
|
|
296
|
+
работал вовсе. Машина работает давно - показание огромное, и весь кэш
|
|
297
|
+
выбрасывался разом на первом же чтении. Второе хуже: гашение
|
|
298
|
+
сохраняется затем, чтобы пережить перезапуск, а оно ровно перезапуска и
|
|
299
|
+
не переживало - всё доставленное приходило заново. Для обработчика
|
|
300
|
+
выдачи это выданный дважды товар.
|
|
301
|
+
|
|
302
|
+
Нужно для сохранения между запусками. Спецификация требует, чтобы кэш
|
|
303
|
+
переживал перезапуск: кэш только в памяти означает, что после любого
|
|
304
|
+
перезапуска повторно приходит всё, что успело прийти до него.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
now (float): Текущий момент, монотонные секунды.
|
|
308
|
+
wall_ms (int | None): Момент по стенным часам, миллисекунды от
|
|
309
|
+
эпохи. Задаётся проверками; по умолчанию берётся системный.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
dict[str, dict[str, int]]: Ключ упорядочивания, идентификатор
|
|
313
|
+
события и момент его доставки - миллисекунды от эпохи.
|
|
314
|
+
"""
|
|
315
|
+
moment = _now_ms() if wall_ms is None else wall_ms
|
|
316
|
+
return {
|
|
317
|
+
key: {
|
|
318
|
+
event_id: moment - round((now - stamp) * 1000) for event_id, stamp in bucket.items()
|
|
319
|
+
}
|
|
320
|
+
for key, bucket in self._seen.items()
|
|
321
|
+
if bucket
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
def snapshot_order(self) -> dict[str, list[str]]:
|
|
325
|
+
"""Сохраняет LRU отдельно от меток TTL: JSON сортирует ключи объектов."""
|
|
326
|
+
return {key: list(bucket) for key, bucket in self._seen.items() if bucket}
|
|
327
|
+
|
|
328
|
+
def restore(
|
|
329
|
+
self,
|
|
330
|
+
state: dict[str, dict[str, int]],
|
|
331
|
+
now: float,
|
|
332
|
+
*,
|
|
333
|
+
wall_ms: int | None = None,
|
|
334
|
+
ordering: dict[str, list[str]] | None = None,
|
|
335
|
+
) -> int:
|
|
336
|
+
"""Восстанавливает состояние гашения из сохранённого.
|
|
337
|
+
|
|
338
|
+
Метки приходят моментами по стенным часам и переводятся обратно в
|
|
339
|
+
монотонные секунды текущего запуска: сколько прошло по стенным часам,
|
|
340
|
+
столько же отнимается от нынешнего показания секундомера.
|
|
341
|
+
|
|
342
|
+
Записи с истёкшим сроком отбрасываются здесь же. Иначе после длинной
|
|
343
|
+
паузы восстановился бы кэш, который всё равно нельзя использовать, и
|
|
344
|
+
память тратилась бы на записи, гасящие уже ничего.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
state (dict[str, dict[str, int]]): Сохранённое состояние.
|
|
348
|
+
now (float): Текущий момент, монотонные секунды.
|
|
349
|
+
wall_ms (int | None): Момент по стенным часам, миллисекунды от
|
|
350
|
+
эпохи. Задаётся проверками; по умолчанию берётся системный.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
int: Сколько записей восстановлено.
|
|
354
|
+
"""
|
|
355
|
+
if not isinstance(state, dict) or any(
|
|
356
|
+
not isinstance(key, str)
|
|
357
|
+
or not isinstance(bucket, dict)
|
|
358
|
+
or any(
|
|
359
|
+
not isinstance(event_id, str) or type(stamp) is not int
|
|
360
|
+
for event_id, stamp in bucket.items()
|
|
361
|
+
)
|
|
362
|
+
for key, bucket in state.items()
|
|
363
|
+
):
|
|
364
|
+
raise StateSchemaIncompatibleError("непригодное состояние гашения повторов")
|
|
365
|
+
if ordering is not None and (
|
|
366
|
+
not isinstance(ordering, dict)
|
|
367
|
+
or any(
|
|
368
|
+
not isinstance(key, str)
|
|
369
|
+
or not isinstance(values, list)
|
|
370
|
+
or any(not isinstance(value, str) for value in values)
|
|
371
|
+
or len(set(values)) != len(values)
|
|
372
|
+
for key, values in ordering.items()
|
|
373
|
+
)
|
|
374
|
+
):
|
|
375
|
+
raise StateSchemaIncompatibleError("непригодный порядок LRU")
|
|
376
|
+
moment = _now_ms() if wall_ms is None else wall_ms
|
|
377
|
+
restored = 0
|
|
378
|
+
for key, bucket in state.items():
|
|
379
|
+
target: OrderedDict[str, float] = OrderedDict()
|
|
380
|
+
for event_id, stamp in sorted(bucket.items(), key=lambda item: item[1]):
|
|
381
|
+
# Метка из будущего означает, что системные часы подвели назад.
|
|
382
|
+
# Считать её свежей безопаснее, чем просроченной: лишнее
|
|
383
|
+
# гашение задержит событие, недостающее выдаст товар дважды.
|
|
384
|
+
elapsed = max(0.0, (moment - stamp) / 1000)
|
|
385
|
+
target[event_id] = now - elapsed
|
|
386
|
+
self._evict(target, now)
|
|
387
|
+
for event_id in (ordering or {}).get(key, []):
|
|
388
|
+
if event_id in target:
|
|
389
|
+
target.move_to_end(event_id)
|
|
390
|
+
while len(target) > self._entries_per_key:
|
|
391
|
+
target.popitem(last=False)
|
|
392
|
+
if target:
|
|
393
|
+
self._seen[key] = target
|
|
394
|
+
restored += len(target)
|
|
395
|
+
return restored
|
|
396
|
+
|
|
397
|
+
def _evict(self, bucket: OrderedDict[str, float], now: float) -> None:
|
|
398
|
+
"""Убирает записи, у которых вышел срок.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
bucket (OrderedDict[str, float]): Записи одного ключа.
|
|
402
|
+
now (float): Текущий момент, монотонные секунды.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
None
|
|
406
|
+
"""
|
|
407
|
+
deadline = now - self._ttl_ms / 1000
|
|
408
|
+
expired = [key for key, stamp in bucket.items() if stamp < deadline]
|
|
409
|
+
for key in expired:
|
|
410
|
+
del bucket[key]
|