simbiot-harness/tests/test_delivery_idempotency.py
Simbiot Harness 1bc33d5678 fix(delivery): идемпотентная автодоставка артефактов в TG — три атомарных барьера против дублей (t-260930083937)
Инцидент: Alex получал презентацию трижды (m-516173239/241). Две причины:
1) гонка push (тик) vs pull (delivery-демон): оба проходили проверку
   delivery_status != 'sent', пока TG-отправка (до 35с) была в полёте;
2) ad-hoc curl sendDocument из мин-тиков вообще без метки delivered.

Фикс — delivery_ledger (INSERT OR IGNORE: факт доставки раз и навсегда)
+ delivery_locks (атомарный claim ДО отправки, TTL 120с; одно SQLite-
выражение = нет гонок между потоками и процессами). deliver_result /
deliver_artifact: ledger → claim → отправка → запись; сбой → release,
демон досыплет. Requeue сбрасывает ledger (доработка доставляется заново).

Новое: cli.py tg-deliver-file (идемпотентная отправка файлов, --force),
tg-notify -k <ключ>, tg_inbox.send_document (multipart на чистом urllib),
deliver_pending pre-filter по ledger, мандаты уровней: прямые
curl/api.telegram отправки запрещены. DeliverySkipped логирует пропуски.

Тесты: tests/test_delivery_idempotency.py — 49 проверок (гонка потоков,
межпроцессный claim, сбои+ретраи, requeue, CLI). Регрессия: 6/6 тестов ОК.
2026-09-30 13:41:34 +00:00

301 lines
No EOL
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""t-260930083937: идемпотентная автодоставка артефактов в TG (hermetic, без LLM/TG).
Запуск: ./venv/bin/python tests/test_delivery_idempotency.py
Дубли в чат (m-516173239/241 — «Ты мне её уже три раза отправил»)
приходили из гонки уровней: push в тике и delivery-демон оба проходили
проверку delivery_status != 'sent', пока TG-отправка была в полёте
(23–34с между дублями DeliverySent в сторе). Плюс ad-hoc curl sendDocument
вообще без метки delivered — каждый тик слал файл заново.
Проверяет: ledger (первая запись только одна), атомарный claim
(живой/истёкший лок, release, межпроцессная атомарность), детерминированную
гонку deliver_result (push в полёте + параллельный демон → одна отправка),
SKIP с событием, сбой TG → release → досылка демоном, requeue со сбросом
ledger и флагов модели, pre-filter deliver_pending, deliver_artifact
(dedup/force/сбой+ретрай), CLI tg-notify -k / tg-deliver-file (--force).
"""
import contextlib
import io
import json
import os
import subprocess
import sys
import threading
# ── изоляция: своя БД в /tmp (prod не трогаем) ──
_DST = "/tmp/simbiot-test-delivery-idem.db"
for p in (_DST, _DST + "-wal", _DST + "-shm"):
if os.path.exists(p):
os.unlink(p)
os.environ["SIMBIOT_DB"] = _DST
sys.path.insert(0, "/root/simbiot-harness")
from harness import es, tg_inbox
from harness import commands
from harness.scheduler import deliver_pending
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "")
if not cond:
fails.append(name)
def tg_sends():
"""Все события фактической отправки (DeliverySent/ArtifactDelivered) в сторе."""
db = es._conn()
try:
rows = db.execute(
"SELECT aggregate_id, event_type FROM events "
"WHERE event_type IN ('DeliverySent','ArtifactDelivered')").fetchall()
return [(r["aggregate_id"], r["event_type"]) for r in rows]
finally:
db.close()
def count_skips(aggregate_id):
db = es._conn()
try:
return db.execute(
"SELECT COUNT(*) AS n FROM events WHERE aggregate='task' "
"AND aggregate_id=? AND event_type='DeliverySkipped'",
(aggregate_id,)).fetchone()["n"]
finally:
db.close()
def run_cli(*argv):
"""CLI-команда in-process (stdout перехвачен) — моки tg_inbox действуют."""
import cli as cli_mod
old = sys.argv
sys.argv = ["cli.py"] + list(argv)
buf = io.StringIO()
try:
with contextlib.redirect_stdout(buf):
cli_mod.main()
finally:
sys.argv = old
return buf.getvalue()
# ═══════════════ 1. ledger: доставлено ровно один раз ═══════════════
es.init_db()
aid = "file:/tmp/pres.html"
check("ledger: первая запись", es.record_delivery(aid, "file") is True)
check("ledger: дубль записи отклонён", es.record_delivery(aid, "file") is False)
check("ledger: recorded видит запись", es.delivery_recorded(aid) is True)
check("ledger: отсутствующий не recorded",
es.delivery_recorded("file:/tmp/none.html") is False)
# ═══════════════ 2. claim: атомарный захват ДО отправки ═══════════════
check("claim: первый захват", es.delivery_claim("lock-1", owner="A", ttl_sec=60) is True)
check("claim: живой лок блокирует", es.delivery_claim("lock-1", owner="B", ttl_sec=60) is False)
es.delivery_claim("lock-exp", owner="A", ttl_sec=-1) # уже истёк
check("claim: истёкший лок перехватывается",
es.delivery_claim("lock-exp", owner="B", ttl_sec=60) is True)
es.delivery_release("lock-1")
check("claim: release открывает повторный захват",
es.delivery_claim("lock-1", owner="B", ttl_sec=60) is True)
# межпроцессная атомарность: два дочерних процесса тянут один claim
code = ("import os,sys;os.environ['SIMBIOT_DB']=%r;"
"sys.path.insert(0,'/root/simbiot-harness');"
"from harness import es;"
"print('1' if es.delivery_claim('lock-xproc', owner='child', ttl_sec=60) else '0')"
% _DST)
r1 = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
r2 = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
wins = (r1.stdout.strip() + r2.stdout.strip()).replace("\n", "")
check("claim: межпроцессно ровно один победитель", sorted(wins) == ["0", "1"], wins)
# ═══════════════ 3. deliver_artifact: dedup / force / сбой+ретрай ═══════════════
calls = []
ok1 = commands.deliver_artifact("art-1", "file", lambda: (calls.append(1) or True),
owner="test", caption="презентация")
ok2 = commands.deliver_artifact("art-1", "file", lambda: (calls.append(1) or True),
owner="test")
check("artifact: первая доставка True", ok1 is True)
check("artifact: повтор False", ok2 is False)
check("artifact: send вызван один раз", len(calls) == 1, f"calls={len(calls)}")
check("artifact: ledger записан", es.delivery_recorded("art-1") is True)
check("artifact: событие ArtifactDelivered есть",
("art-1", "ArtifactDelivered") in tg_sends())
okf = commands.deliver_artifact("art-1", "file", lambda: (calls.append(1) or True),
owner="test", force=True)
check("artifact: force пробивает живой лок", okf is True)
check("artifact: force — второй send", len(calls) == 2, f"calls={len(calls)}")
check("artifact: force не «молодит» ledger (IGNORE)",
es.record_delivery("art-1", "file") is False)
flaky = {"n": 0}
def _flaky():
flaky["n"] += 1
if flaky["n"] == 1:
raise RuntimeError("TG упал")
return True
try:
commands.deliver_artifact("art-flaky", "file", _flaky, owner="test")
check("artifact: сбой прокинут", False)
except RuntimeError:
check("artifact: сбой прокинут", True)
check("artifact: сбой не записан в ledger",
es.delivery_recorded("art-flaky") is False)
check("artifact: ретрай после сбоя успешен",
commands.deliver_artifact("art-flaky", "file", _flaky, owner="test") is True)
# ═══════════════ 4. deliver_result: гонка уровней = одна отправка ═══════════════
# инцидент: push в тике и delivery-демон параллельно → 2× DeliverySent.
# Детерминированная гонка: поток A (тик) берёт claim и «висит в отправке»
# (как TG до 35с), B (демон) идёт в это же время → claim отклоняет.
tid, _ = commands.submit_task("Задача с автодоставкой", "детали", 5)
commands.mark_awaiting_delivery(tid, reason="тест")
es.append_event("task", tid, "TaskClaimed", {"level": "hour", "session_id": "s-test"})
es.append_event("task", tid, "TaskCompleted", {"answer": "ответ задачи"})
_orig_notify = tg_inbox.notify_chat
in_flight = threading.Event() # держим «отправку» потока A
started = threading.Event() # A дошёл до notify
def _slow_notify(text):
started.set()
in_flight.wait(timeout=10) # имитация полёта TG-отправки
return True
res_a = {}
def _deliver_a():
res_a["r"] = commands.deliver_result(tid, "ответ задачи", owner="tick:hour")
tg_inbox.notify_chat = _slow_notify
try:
ta = threading.Thread(target=_deliver_a)
ta.start()
check("гонка: A дошёл до отправки (claim взят)", started.wait(timeout=5))
# параллельный уровень/демон в то же время: claim жив → пропуск
res_b = commands.deliver_result(tid, "ответ задачи", owner="daemon")
in_flight.set() # отпускаем A: отправка завершается
ta.join(timeout=10)
finally:
tg_inbox.notify_chat = _orig_notify
sent = [a for a, t in tg_sends() if a == tid and t == "DeliverySent"]
check("гонка: ровно один DeliverySent", len(sent) == 1, f"sent={len(sent)}")
check("гонка: B (демон) пропущен claim'ом", res_b is False, f"res_b={res_b}")
check("гонка: A доставил (True)", res_a.get("r") is True, f"res_a={res_a}")
check("гонка: ledger записан", es.delivery_recorded(tid) is True)
# повтор при записанном ledger (stale read model): SKIP с событием
before = count_skips(tid)
check("stale: повтор SKIP", commands.deliver_result(tid, "ответ") is False)
check("stale: SKIP залогирован DeliverySkipped",
count_skips(tid) == before + 1, f"skips={count_skips(tid)}")
model = es.run_projections()
check("stale: проекция delivery_skips видна",
model["tasks"][tid].get("delivery_skips") == 1,
model["tasks"][tid].get("delivery_skips"))
# ═══════════════ 5. сбой TG: release → демон досылает ═══════════════
tid2, _ = commands.submit_task("Задача с падением TG", "детали", 5)
commands.mark_awaiting_delivery(tid2, reason="тест")
es.append_event("task", tid2, "TaskClaimed", {"level": "hour", "session_id": "s-test2"})
es.append_event("task", tid2, "TaskCompleted", {"answer": "ответ 2"})
def _dead_notify(text):
raise RuntimeError("TG недоступен")
tg_inbox.notify_chat = _dead_notify
try:
check("сбой TG: deliver_result False",
commands.deliver_result(tid2, "ответ 2", owner="daemon") is False)
finally:
tg_inbox.notify_chat = _orig_notify
check("сбой TG: ledger пуст", es.delivery_recorded(tid2) is False)
db = es._conn()
try:
row = db.execute("SELECT COUNT(*) AS n FROM delivery_locks WHERE artifact_id=?",
(tid2,)).fetchone()
check("сбой TG: лок освобождён (не ждём TTL)", row["n"] == 0, row["n"])
finally:
db.close()
# TG ожил: демон досылает на следующем проходе
tg_inbox.notify_chat = lambda text: True
try:
delivered = deliver_pending()
finally:
tg_inbox.notify_chat = _orig_notify
check("сбой TG: демон дослал на след. проходе", tid2 in delivered, delivered)
check("сбой TG: DeliverySent появился",
(tid2, "DeliverySent") in tg_sends())
# ═══════════════ 6. requeue: сброс ledger и флагов → новая доставка ═══════════════
commands.requeue_task(tid, "доработка по просьбе Alex")
check("requeue: ledger сброшен", es.delivery_recorded(tid) is False)
model = es.run_projections()
check("requeue: флаг модели сброшен в await (не 'sent' — досылка не порезана)",
model["tasks"][tid].get("delivery_status") == "await",
model["tasks"][tid].get("delivery_status"))
check("requeue: задача снова ready", model["tasks"][tid]["status"] == "ready")
# доработали: новый результат должен уйти (мок)
es.append_event("task", tid, "TaskClaimed", {"level": "hour", "session_id": "s-rq"})
es.append_event("task", tid, "TaskCompleted", {"answer": "ответ после доработки"})
tg_inbox.notify_chat = lambda text: True
try:
redone = commands.deliver_result(tid, "ответ после доработки", owner="tick:hour")
finally:
tg_inbox.notify_chat = _orig_notify
check("requeue: новый результат доставлен", redone is True)
check("requeue: после доработки ровно +1 DeliverySent (2 всего)",
len([1 for a, t in tg_sends() if a == tid and t == "DeliverySent"]) == 2)
# ═══════════════ 7. deliver_pending: pre-filter без дублей ═══════════════
tg_inbox.notify_chat = lambda text: True
try:
delivered2 = deliver_pending()
finally:
tg_inbox.notify_chat = _orig_notify
check("pending: уже доставленное не досылается", delivered2 == [], f"got={delivered2}")
# 2 задачи: tid дважды (гонка + доработка после requeue) и tid2 раз → 3
check("pending: DeliverySent не размножены (3 всего: 2×tid + 1×tid2)",
len([1 for a, t in tg_sends() if t == "DeliverySent"]) == 3)
# ═══════════════ 8. CLI: tg-notify -k / tg-deliver-file ═══════════════
tg_inbox.notify_chat = lambda text: True
tg_inbox.send_document_to_chat = lambda path, caption="": True
try:
r1 = run_cli("tg-notify", "тестовое уведомление", "-k", "cli-key-1")
r2 = run_cli("tg-notify", "тестовое уведомление", "-k", "cli-key-1")
finally:
tg_inbox.notify_chat = _orig_notify
check("cli: tg-notify -k первая — OK", "OK" in r1, r1)
check("cli: tg-notify -k повтор — SKIP", "SKIP" in r2, r2)
check("cli: ledger по ключу записан",
es.delivery_recorded("msg:cli-key-1") is True)
open("/tmp/simbiot-test-dl.txt", "w").write("файл-артефакт")
_orig_send_doc = tg_inbox.send_document_to_chat
tg_inbox.notify_chat = lambda text: True
tg_inbox.send_document_to_chat = lambda path, caption="": True
try:
r3 = run_cli("tg-deliver-file", "/tmp/simbiot-test-dl.txt", "-c", "подпись")
r4 = run_cli("tg-deliver-file", "/tmp/simbiot-test-dl.txt", "-c", "подпись")
r5 = run_cli("tg-deliver-file", "/tmp/simbiot-test-dl.txt", "-c", "повтор",
"--force")
finally:
tg_inbox.notify_chat = _orig_notify
tg_inbox.send_document_to_chat = _orig_send_doc
expected_aid = f"file:{os.path.abspath('/tmp/simbiot-test-dl.txt')}"
check("cli: tg-deliver-file первая — OK", "OK" in r3, r3)
check("cli: tg-deliver-file повтор — SKIP", "SKIP" in r4, r4)
check("cli: --force пробивает SKIP", "OK" in r5, r5)
check("cli: ledger по файлу записан",
es.delivery_recorded(expected_aid) is True)
# ═══════════════ итог ═══════════════
print()
if fails:
print(f"FAILED: {len(fails)} проверок провалено: {fails}")
sys.exit(1)
print("OK: идемпотентная доставка — все проверки прошли")