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/bot/_spool.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""Очередь исходящих между ПРОЦЕССАМИ: каталог вместо памяти.
|
|
2
|
+
|
|
3
|
+
ЗАЧЕМ ВТОРАЯ ОЧЕРЕДЬ, КОГДА ЕСТЬ [_outbox.py]. Та решает задачу про потоки:
|
|
4
|
+
класть можно откуда угодно, трогает площадку один. Эта решает задачу про
|
|
5
|
+
процессы, и разница не в объёме, а в том, что ломается.
|
|
6
|
+
|
|
7
|
+
Очередь в памяти живёт ровно столько, сколько живёт процесс. Телеграм-бот,
|
|
8
|
+
поднятый отдельной командой - обычное устройство, и у него нет ни одного способа
|
|
9
|
+
попросить об отправке: у него другой интерпретатор, другая память и никакого
|
|
10
|
+
доступа к чужой очереди.
|
|
11
|
+
|
|
12
|
+
Соблазн решить это сокетом либо базой отвергнут: каталог с файлами не требует ни
|
|
13
|
+
порта, ни зависимости, ни запущенного посредника, а атомарность даёт сама
|
|
14
|
+
файловая система. Переименование внутри тома атомарно и на Windows, и на Linux.
|
|
15
|
+
|
|
16
|
+
ЧТО ЗДЕСЬ САМОЕ ВАЖНОЕ - НЕ ОЧЕРЕДЬ, А ЗАСТРЯВШЕЕ. Задание, взятое в работу
|
|
17
|
+
процессом, который умер, - это сообщение с НЕИЗВЕСТНОЙ судьбой: могло уйти,
|
|
18
|
+
могло не уйти. Отправить его снова значит рискнуть вторым сообщением
|
|
19
|
+
покупателю; выбросить молча - потерять первое.
|
|
20
|
+
|
|
21
|
+
Поэтому такие задания не делают ни того, ни другого. Они переносятся в
|
|
22
|
+
`stuck/`, откуда их не берёт никто, и о них сообщают вслух. Решает человек, и
|
|
23
|
+
решает он, посмотрев переписку, - другого способа узнать здесь нет.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from datetime import UTC, datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Final
|
|
36
|
+
|
|
37
|
+
from .._fileio import atomic_write, file_lock
|
|
38
|
+
from .._json import load_json
|
|
39
|
+
from ..errors import StateSchemaIncompatibleError, UsageError, ValidationError
|
|
40
|
+
from ._outbox import SendCommand
|
|
41
|
+
|
|
42
|
+
__all__ = ["Spool", "SpoolEntry", "SpoolOutcome", "MAX_SPOOLED"]
|
|
43
|
+
|
|
44
|
+
_log = logging.getLogger("funora.bot.spool")
|
|
45
|
+
|
|
46
|
+
#: Сколько заданий каталог принимает, прежде чем отказывать.
|
|
47
|
+
#:
|
|
48
|
+
#: Довод тот же, что у очереди в памяти, и цена ошибки та же: наблюдение
|
|
49
|
+
#: разбирает по нескольку за шаг, а класть можно сколько угодно быстро. Разница
|
|
50
|
+
#: одна - переполнение здесь съедает не память, а место на диске, и потому
|
|
51
|
+
#: предел взят с запасом.
|
|
52
|
+
MAX_SPOOLED: Final[int] = 4096
|
|
53
|
+
|
|
54
|
+
#: Сколько знаков занимает порядковый номер в имени файла.
|
|
55
|
+
#:
|
|
56
|
+
#: Имена сортируются как строки, и без выравнивания нулями десятое задание
|
|
57
|
+
#: встало бы раньше второго. Порядок здесь - это порядок отправки покупателям.
|
|
58
|
+
_ORDER_WIDTH: Final[int] = 12
|
|
59
|
+
|
|
60
|
+
#: Что позволено в ключе идемпотентности.
|
|
61
|
+
#:
|
|
62
|
+
#: Ключ становится ЧАСТЬЮ ИМЕНИ ФАЙЛА, и это накладывает ограничение, которого у
|
|
63
|
+
#: очереди в памяти нет. Косая черта увела бы задание в чужой каталог, точки -
|
|
64
|
+
#: на уровень выше, двоеточие не принимает Windows.
|
|
65
|
+
#:
|
|
66
|
+
#: Края отдельно: ключ обязан начинаться и кончаться буквой либо цифрой. Иначе
|
|
67
|
+
#: проходят ключи «.» и «..» - имена, которые у файловой системы означают не
|
|
68
|
+
#: файл, а каталог, - и ключ с ведущей точкой, дающий скрытый файл.
|
|
69
|
+
_KEY: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,118}[A-Za-z0-9])?$")
|
|
70
|
+
|
|
71
|
+
#: Подкаталоги. Имя каталога и есть состояние задания.
|
|
72
|
+
_READY: Final[str] = "ready"
|
|
73
|
+
_TAKEN: Final[str] = "taken"
|
|
74
|
+
_DONE: Final[str] = "done"
|
|
75
|
+
_STUCK: Final[str] = "stuck"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class SpoolEntry:
|
|
80
|
+
"""Задание, взятое из каталога.
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
command (SendCommand): Просьба отправить сообщение.
|
|
84
|
+
path (Path): Файл, которым задание сейчас представлено. Лежит в
|
|
85
|
+
подкаталоге взятого: задание уже никому больше не достанется.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
command: SendCommand
|
|
89
|
+
path: Path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class SpoolOutcome:
|
|
94
|
+
"""Чем кончилось задание.
|
|
95
|
+
|
|
96
|
+
Attributes:
|
|
97
|
+
idempotency_key (str): Ключ задания.
|
|
98
|
+
state (str): Одно из sent, refused, stuck.
|
|
99
|
+
detail (str): Подробность: исход отправки либо имя отказа.
|
|
100
|
+
at (str): Момент записи, как его пишет `datetime.isoformat`.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
idempotency_key: str
|
|
104
|
+
state: str
|
|
105
|
+
detail: str
|
|
106
|
+
at: str
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _order(value: int) -> str:
|
|
110
|
+
"""Собирает порядковую часть имени файла.
|
|
111
|
+
|
|
112
|
+
Аргументы:
|
|
113
|
+
value (int): Номер задания.
|
|
114
|
+
|
|
115
|
+
Возвращает:
|
|
116
|
+
str: Номер, выровненный нулями.
|
|
117
|
+
"""
|
|
118
|
+
return str(value).zfill(_ORDER_WIDTH)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Spool:
|
|
122
|
+
"""Очередь исходящих, разделяемая между процессами.
|
|
123
|
+
|
|
124
|
+
Каталог создаётся при первом обращении. Класть в него можно из любого
|
|
125
|
+
процесса; забирать обязан ровно один - тот, что ведёт наблюдение.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
path (Path | str): Каталог очереди.
|
|
129
|
+
max_spooled (int): Сколько заданий держать, прежде чем отказывать.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ValidationError: Если предел непригоден.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
__slots__ = ("_root", "_max")
|
|
136
|
+
|
|
137
|
+
def __init__(self, path: Path | str, max_spooled: int = MAX_SPOOLED) -> None:
|
|
138
|
+
if max_spooled < 1:
|
|
139
|
+
raise ValidationError(
|
|
140
|
+
f"предел очереди {max_spooled} не годится: ноль и отрицательное "
|
|
141
|
+
"здесь читались бы как «не принимать ничего», а очередь без "
|
|
142
|
+
"предела копит сообщения, которые уйдут с опозданием на часы"
|
|
143
|
+
)
|
|
144
|
+
self._root = Path(path)
|
|
145
|
+
self._max = max_spooled
|
|
146
|
+
for name in (_READY, _TAKEN, _DONE, _STUCK):
|
|
147
|
+
(self._root / name).mkdir(parents=True, exist_ok=True)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def root(self) -> Path:
|
|
151
|
+
"""Каталог очереди.
|
|
152
|
+
|
|
153
|
+
Возвращает:
|
|
154
|
+
Path: Корень, в котором лежат подкаталоги состояний.
|
|
155
|
+
"""
|
|
156
|
+
return self._root
|
|
157
|
+
|
|
158
|
+
def submit(self, command: SendCommand) -> bool:
|
|
159
|
+
"""Кладёт задание в очередь. Звать можно из любого процесса.
|
|
160
|
+
|
|
161
|
+
Аргументы:
|
|
162
|
+
command (SendCommand): Просьба отправить сообщение.
|
|
163
|
+
|
|
164
|
+
Возвращает:
|
|
165
|
+
bool: True, если задание принято. False означает, что задание с
|
|
166
|
+
таким ключом уже есть либо уже отработано, - то есть повтор.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ValidationError: Если ключ непригоден для имени файла.
|
|
170
|
+
UsageError: Если очередь переполнена.
|
|
171
|
+
"""
|
|
172
|
+
with file_lock(self._root / ".lock"):
|
|
173
|
+
key = command.idempotency_key
|
|
174
|
+
if not _KEY.fullmatch(key):
|
|
175
|
+
raise ValidationError(
|
|
176
|
+
f"ключ идемпотентности {key!r} не годится для очереди в каталоге: "
|
|
177
|
+
"он становится частью имени файла, а в имени позволены только "
|
|
178
|
+
"латиница, цифры, точка, дефис и подчёркивание, не длиннее 120 "
|
|
179
|
+
"знаков. Косая черта увела бы задание в чужой каталог"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if self._known(key):
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
ready = self._root / _READY
|
|
186
|
+
waiting = sorted(ready.iterdir())
|
|
187
|
+
if len(waiting) >= self._max:
|
|
188
|
+
raise UsageError(
|
|
189
|
+
f"очередь исходящих переполнена: {len(waiting)} заданий ждут "
|
|
190
|
+
f"отправки при пределе {self._max}. Наблюдение разбирает её по "
|
|
191
|
+
"нескольку за шаг, и класть быстрее, чем она вычерпывается, "
|
|
192
|
+
"значит копить сообщения, которые уйдут с опозданием на часы"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
payload = {
|
|
196
|
+
"chat_id": command.chat_id,
|
|
197
|
+
"text": command.text,
|
|
198
|
+
"idempotency_key": key,
|
|
199
|
+
"declared_cold": command.declared_cold,
|
|
200
|
+
"at": datetime.now(UTC).isoformat(),
|
|
201
|
+
}
|
|
202
|
+
target = ready / self._name_for(key, waiting)
|
|
203
|
+
# Проверка и публикация идут под общей блокировкой; временный файл
|
|
204
|
+
# становится видимым заданием только после полной записи.
|
|
205
|
+
if target.exists():
|
|
206
|
+
return False
|
|
207
|
+
atomic_write(target, json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _name_for(key: str, waiting: list[Path]) -> str:
|
|
212
|
+
"""Придумывает имя файла для нового задания.
|
|
213
|
+
|
|
214
|
+
Номер берётся от последнего лежащего, а не от счётчика в памяти:
|
|
215
|
+
процессов несколько, и у каждого свой счётчик начался бы с нуля.
|
|
216
|
+
|
|
217
|
+
Вынесено отдельно НАРОЧНО. Два процесса, посчитавшие номер до того, как
|
|
218
|
+
записал первый, получают одно имя, и защищает от этого исключительное
|
|
219
|
+
создание файла. Проверить защиту, не имея права подставить одинаковое
|
|
220
|
+
имя, нельзя - а непроверенная защита ничем не отличается от её
|
|
221
|
+
отсутствия.
|
|
222
|
+
|
|
223
|
+
Аргументы:
|
|
224
|
+
key (str): Ключ идемпотентности.
|
|
225
|
+
waiting (list[Path]): Задания, уже лежащие в очереди.
|
|
226
|
+
|
|
227
|
+
Возвращает:
|
|
228
|
+
str: Имя файла.
|
|
229
|
+
"""
|
|
230
|
+
last = 0
|
|
231
|
+
if waiting:
|
|
232
|
+
head = waiting[-1].name.split("-", 1)[0]
|
|
233
|
+
if head.isdigit():
|
|
234
|
+
last = int(head)
|
|
235
|
+
return f"{_order(last + 1)}-{key}.json"
|
|
236
|
+
|
|
237
|
+
def _known(self, key: str) -> bool:
|
|
238
|
+
"""Говорит, встречался ли ключ в любом из состояний.
|
|
239
|
+
|
|
240
|
+
Аргументы:
|
|
241
|
+
key (str): Ключ идемпотентности.
|
|
242
|
+
|
|
243
|
+
Возвращает:
|
|
244
|
+
bool: True, если задание с этим ключом уже есть либо было.
|
|
245
|
+
"""
|
|
246
|
+
if (self._root / _DONE / f"{key}.json").exists():
|
|
247
|
+
return True
|
|
248
|
+
for where in (_READY, _TAKEN, _STUCK):
|
|
249
|
+
for path in (self._root / where).iterdir():
|
|
250
|
+
if self._key_of(path) == key:
|
|
251
|
+
return True
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
def recover(self) -> tuple[str, ...]:
|
|
255
|
+
"""Разбирает задания, оставшиеся взятыми от прошлого запуска.
|
|
256
|
+
|
|
257
|
+
ВОЗВРАЩАТЬ ИХ В ОЧЕРЕДЬ НЕЛЬЗЯ. Задание попало во взятые перед самой
|
|
258
|
+
отправкой; умер процесс до неё или после - по файлу не видно. Вернуть в
|
|
259
|
+
очередь значит послать покупателю второе сообщение, а второго сообщения
|
|
260
|
+
не отменить.
|
|
261
|
+
|
|
262
|
+
Выбросить молча тоже нельзя: первое могло не уйти.
|
|
263
|
+
|
|
264
|
+
Поэтому они переносятся в `stuck/` и называются вслух. Решает человек,
|
|
265
|
+
посмотрев переписку.
|
|
266
|
+
|
|
267
|
+
Возвращает:
|
|
268
|
+
tuple[str, ...]: Ключи заданий с неизвестной судьбой.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
StateSchemaIncompatibleError: Непригодная квитанция. Все квитанции
|
|
272
|
+
проверяются до переноса или удаления первого задания.
|
|
273
|
+
"""
|
|
274
|
+
with file_lock(self._root / ".lock"):
|
|
275
|
+
stranded: list[str] = []
|
|
276
|
+
# До первого удаления проверяем все квитанции. Повреждение позднего
|
|
277
|
+
# результата не должно оставлять восстановление наполовину выполненным.
|
|
278
|
+
entries = [
|
|
279
|
+
(path, self.outcome(self._key_of(path)))
|
|
280
|
+
for path in sorted((self._root / _TAKEN).iterdir())
|
|
281
|
+
]
|
|
282
|
+
for path, previous in entries:
|
|
283
|
+
key = self._key_of(path)
|
|
284
|
+
if previous is not None and previous.state != "stuck":
|
|
285
|
+
path.unlink()
|
|
286
|
+
continue
|
|
287
|
+
target = self._root / _STUCK / path.name
|
|
288
|
+
os.replace(path, target)
|
|
289
|
+
if previous is None:
|
|
290
|
+
self._record(
|
|
291
|
+
SpoolOutcome(
|
|
292
|
+
idempotency_key=key,
|
|
293
|
+
state="stuck",
|
|
294
|
+
detail="процесс не дожил до записи исхода: сообщение могло уйти",
|
|
295
|
+
at=datetime.now(UTC).isoformat(),
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
stranded.append(key)
|
|
299
|
+
|
|
300
|
+
if stranded:
|
|
301
|
+
_log.warning(
|
|
302
|
+
"заданий с неизвестной судьбой: %d. Они не будут отправлены "
|
|
303
|
+
"повторно - посмотрите переписку и решите сами: %s",
|
|
304
|
+
len(stranded),
|
|
305
|
+
", ".join(stranded),
|
|
306
|
+
)
|
|
307
|
+
return tuple(stranded)
|
|
308
|
+
|
|
309
|
+
def take(self, limit: int) -> list[SpoolEntry]:
|
|
310
|
+
"""Забирает из очереди до указанного числа заданий.
|
|
311
|
+
|
|
312
|
+
Взятие - это ПЕРЕИМЕНОВАНИЕ, а не чтение. Файл, перенесённый во взятые,
|
|
313
|
+
второму разбирающему уже не достанется: переименование атомарно, и
|
|
314
|
+
проигравший получит отказ файловой системы, а не половину задания.
|
|
315
|
+
|
|
316
|
+
Аргументы:
|
|
317
|
+
limit (int): Сколько заданий забрать.
|
|
318
|
+
|
|
319
|
+
Возвращает:
|
|
320
|
+
list[SpoolEntry]: Взятые задания в порядке поступления.
|
|
321
|
+
"""
|
|
322
|
+
with file_lock(self._root / ".lock"):
|
|
323
|
+
taken: list[SpoolEntry] = []
|
|
324
|
+
for path in sorted((self._root / _READY).iterdir()):
|
|
325
|
+
if len(taken) >= max(0, limit):
|
|
326
|
+
break
|
|
327
|
+
|
|
328
|
+
target = self._root / _TAKEN / path.name
|
|
329
|
+
try:
|
|
330
|
+
os.replace(path, target)
|
|
331
|
+
except OSError:
|
|
332
|
+
# Задание перехватил кто-то другой либо файл исчез. Ни то, ни
|
|
333
|
+
# другое не повод останавливать разбор остальных.
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
command = self._read(target)
|
|
337
|
+
if command is None:
|
|
338
|
+
# Непригодное задание не отправляется и не возвращается в
|
|
339
|
+
# очередь: оно вернулось бы снова и снова. Уходит в застрявшие,
|
|
340
|
+
# где его увидит человек.
|
|
341
|
+
key = self._key_of(target)
|
|
342
|
+
os.replace(target, self._root / _STUCK / target.name)
|
|
343
|
+
self._record(
|
|
344
|
+
SpoolOutcome(
|
|
345
|
+
idempotency_key=key,
|
|
346
|
+
state="stuck",
|
|
347
|
+
detail="файл задания непригоден: отправлять нечего",
|
|
348
|
+
at=datetime.now(UTC).isoformat(),
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
_log.warning("задание %s непригодно и перенесено в застрявшие", key)
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
taken.append(SpoolEntry(command=command, path=target))
|
|
355
|
+
return taken
|
|
356
|
+
|
|
357
|
+
def settle(self, entry: SpoolEntry, *, state: str, detail: str) -> None:
|
|
358
|
+
"""Закрывает задание, записав исход.
|
|
359
|
+
|
|
360
|
+
Аргументы:
|
|
361
|
+
entry (SpoolEntry): Задание, взятое через `take`.
|
|
362
|
+
state (str): sent либо refused.
|
|
363
|
+
detail (str): Подробность исхода.
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
ValidationError: Чужое задание или непригодный исход. Запись
|
|
367
|
+
квитанции и удаление задания при этом не выполняются.
|
|
368
|
+
|
|
369
|
+
Возвращает:
|
|
370
|
+
None
|
|
371
|
+
"""
|
|
372
|
+
with file_lock(self._root / ".lock"):
|
|
373
|
+
key = entry.command.idempotency_key
|
|
374
|
+
if (
|
|
375
|
+
state not in ("sent", "refused")
|
|
376
|
+
or not isinstance(detail, str)
|
|
377
|
+
or not isinstance(key, str)
|
|
378
|
+
or not _KEY.fullmatch(key)
|
|
379
|
+
or entry.path.resolve().parent != (self._root / _TAKEN).resolve()
|
|
380
|
+
or self._key_of(entry.path) != key
|
|
381
|
+
):
|
|
382
|
+
raise ValidationError("непригодный исход или чужое задание очереди")
|
|
383
|
+
self._record(
|
|
384
|
+
SpoolOutcome(
|
|
385
|
+
idempotency_key=entry.command.idempotency_key,
|
|
386
|
+
state=state,
|
|
387
|
+
detail=detail,
|
|
388
|
+
at=datetime.now(UTC).isoformat(),
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
# Файл задания снимается ПОСЛЕ записи исхода. Обратный порядок оставил
|
|
392
|
+
# бы задание, которого нет ни во взятых, ни в отработанных, - и повтор
|
|
393
|
+
# с тем же ключом прошёл бы как новый.
|
|
394
|
+
entry.path.unlink(missing_ok=True)
|
|
395
|
+
|
|
396
|
+
def outcome(self, key: str) -> SpoolOutcome | None:
|
|
397
|
+
"""Читает исход задания. Звать можно из любого процесса.
|
|
398
|
+
|
|
399
|
+
Аргументы:
|
|
400
|
+
key (str): Ключ идемпотентности.
|
|
401
|
+
|
|
402
|
+
Возвращает:
|
|
403
|
+
SpoolOutcome | None: Исход либо None, если задание ещё не
|
|
404
|
+
отработано.
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
StateSchemaIncompatibleError: Существующий результат не читается,
|
|
408
|
+
принадлежит другому ключу или содержит непригодные поля.
|
|
409
|
+
"""
|
|
410
|
+
if not isinstance(key, str) or not _KEY.fullmatch(key):
|
|
411
|
+
raise ValidationError("непригодный ключ результата очереди")
|
|
412
|
+
path = self._root / _DONE / f"{key}.json"
|
|
413
|
+
try:
|
|
414
|
+
raw = load_json(path.read_text(encoding="utf-8"))
|
|
415
|
+
except FileNotFoundError as exc:
|
|
416
|
+
if not path.is_symlink():
|
|
417
|
+
return None
|
|
418
|
+
raise StateSchemaIncompatibleError("ссылка результата очереди не читается") from exc
|
|
419
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
420
|
+
raise StateSchemaIncompatibleError("результат очереди не читается") from exc
|
|
421
|
+
if (
|
|
422
|
+
not isinstance(raw, dict)
|
|
423
|
+
or raw.get("idempotency_key") != key
|
|
424
|
+
or raw.get("state") not in ("sent", "refused", "stuck")
|
|
425
|
+
or not isinstance(raw.get("detail"), str)
|
|
426
|
+
or not isinstance(raw.get("at"), str)
|
|
427
|
+
):
|
|
428
|
+
raise StateSchemaIncompatibleError("непригодные поля результата очереди")
|
|
429
|
+
try:
|
|
430
|
+
stamp = datetime.fromisoformat(raw["at"])
|
|
431
|
+
except ValueError as exc:
|
|
432
|
+
raise StateSchemaIncompatibleError("непригодная дата результата очереди") from exc
|
|
433
|
+
if stamp.utcoffset() is None:
|
|
434
|
+
raise StateSchemaIncompatibleError("дата результата очереди не содержит часовой пояс")
|
|
435
|
+
return SpoolOutcome(
|
|
436
|
+
idempotency_key=raw["idempotency_key"],
|
|
437
|
+
state=raw["state"],
|
|
438
|
+
detail=raw["detail"],
|
|
439
|
+
at=raw["at"],
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def pending(self) -> int:
|
|
444
|
+
"""Сколько заданий ждёт отправки.
|
|
445
|
+
|
|
446
|
+
Возвращает:
|
|
447
|
+
int: Число файлов в очереди.
|
|
448
|
+
"""
|
|
449
|
+
return sum(1 for _ in (self._root / _READY).iterdir())
|
|
450
|
+
|
|
451
|
+
@property
|
|
452
|
+
def stuck(self) -> tuple[str, ...]:
|
|
453
|
+
"""Задания с неизвестной судьбой.
|
|
454
|
+
|
|
455
|
+
Возвращает:
|
|
456
|
+
tuple[str, ...]: Ключи, о которых должен решить человек.
|
|
457
|
+
"""
|
|
458
|
+
return tuple(self._key_of(one) for one in sorted((self._root / _STUCK).iterdir()))
|
|
459
|
+
|
|
460
|
+
def _record(self, outcome: SpoolOutcome) -> None:
|
|
461
|
+
"""Записывает исход задания.
|
|
462
|
+
|
|
463
|
+
Аргументы:
|
|
464
|
+
outcome (SpoolOutcome): Что случилось с заданием.
|
|
465
|
+
|
|
466
|
+
Возвращает:
|
|
467
|
+
None
|
|
468
|
+
"""
|
|
469
|
+
payload = {
|
|
470
|
+
"idempotency_key": outcome.idempotency_key,
|
|
471
|
+
"state": outcome.state,
|
|
472
|
+
"detail": outcome.detail,
|
|
473
|
+
"at": outcome.at,
|
|
474
|
+
}
|
|
475
|
+
target = self._root / _DONE / f"{outcome.idempotency_key}.json"
|
|
476
|
+
# Через временное имя и переименование: читатель из другого процесса
|
|
477
|
+
# иначе застал бы файл наполовину записанным и счёл бы исход
|
|
478
|
+
# непригодным.
|
|
479
|
+
atomic_write(target, json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
|
480
|
+
|
|
481
|
+
@staticmethod
|
|
482
|
+
def _key_of(path: Path) -> str:
|
|
483
|
+
"""Достаёт ключ идемпотентности из имени файла.
|
|
484
|
+
|
|
485
|
+
Аргументы:
|
|
486
|
+
path (Path): Файл задания.
|
|
487
|
+
|
|
488
|
+
Возвращает:
|
|
489
|
+
str: Ключ.
|
|
490
|
+
"""
|
|
491
|
+
name = path.name.removesuffix(".json")
|
|
492
|
+
head, _, tail = name.partition("-")
|
|
493
|
+
return tail if head.isdigit() and tail else name
|
|
494
|
+
|
|
495
|
+
@staticmethod
|
|
496
|
+
def _read(path: Path) -> SendCommand | None:
|
|
497
|
+
"""Читает задание из файла.
|
|
498
|
+
|
|
499
|
+
Аргументы:
|
|
500
|
+
path (Path): Файл задания.
|
|
501
|
+
|
|
502
|
+
Возвращает:
|
|
503
|
+
SendCommand | None: Задание либо None, если файл непригоден.
|
|
504
|
+
"""
|
|
505
|
+
try:
|
|
506
|
+
raw: Any = load_json(path.read_text(encoding="utf-8"))
|
|
507
|
+
except (OSError, ValueError, RecursionError):
|
|
508
|
+
return None
|
|
509
|
+
if not isinstance(raw, dict):
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
chat_id = raw.get("chat_id")
|
|
513
|
+
text = raw.get("text")
|
|
514
|
+
key = raw.get("idempotency_key")
|
|
515
|
+
# Все три обязательны и все три строки. Приведения к строке нет:
|
|
516
|
+
# идентификатор диалога числом дал бы адрес, по которому мы не были.
|
|
517
|
+
if not isinstance(chat_id, str) or not chat_id.strip():
|
|
518
|
+
return None
|
|
519
|
+
if not isinstance(text, str) or not text:
|
|
520
|
+
return None
|
|
521
|
+
if not isinstance(key, str) or not _KEY.fullmatch(key):
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
if key != Spool._key_of(path):
|
|
525
|
+
return None
|
|
526
|
+
cold = raw.get("declared_cold", False)
|
|
527
|
+
if not isinstance(cold, bool):
|
|
528
|
+
return None
|
|
529
|
+
return SendCommand(
|
|
530
|
+
chat_id=chat_id,
|
|
531
|
+
text=text,
|
|
532
|
+
idempotency_key=key,
|
|
533
|
+
declared_cold=cold,
|
|
534
|
+
)
|