- es: TaskClosed → статус closed (терминальный, обратимый через requeue; closed_by manual|archive) - commands: close_task (ручной, только failed, идемпотентен), archive_stale_failed(max_age_hours=24, now=None) — демон-архиватор - scheduler._delivery_and_orphans_loop: каждый проход (15с) архивирует failed>24ч - requeue принимает closed → ready (доработка из архива) - cli: simbiot close <id> [-r reason] - dashboard: порядок ready→running→failed→done→архив, счётчик «архив» в статусбаре, схема закрытых фейлов под одной строкой-счётчиком (тумблер показывает полностью), pill.closed - тест tests/test_close_stale_failed.py: 15 проверок (закрытие, порог через симуляцию +25ч, done не трогается, идемпотентность, полный цикл closed→ready→failed→closed)
131 lines
No EOL
6.9 KiB
Python
131 lines
No EOL
6.9 KiB
Python
"""Авто-схлопывание старых фейлов (просьба Alex 03.10, e-261003094432).
|
||
|
||
Проверяем три правила новой стейт-машины задач:
|
||
1) close_task: failed → closed (терминально, но обратимо через requeue);
|
||
близ/close на ready|running|done — ошибка/но-op.
|
||
2) archive_stale_failed: failed старше 24ч → closed автоматически,
|
||
свежие failed не трогаются.
|
||
3) requeue_task: closed → ready (осознанная доработка из архива).
|
||
|
||
Hermetic: своя БД /tmp/simbiot-test-close-stale.db, подпись — реальный
|
||
audit-модуль умеет работать без ключа (событие уходит без sig, verifier
|
||
помечает legacy) — это не влияет на проекции.
|
||
"""
|
||
import os
|
||
import sys
|
||
import json
|
||
import time
|
||
|
||
os.environ["SIMBIOT_DB"] = "/tmp/simbiot-test-close-stale.db"
|
||
# hermeticity: протухшая /tmp-БД не должна влиять
|
||
import glob as _glob
|
||
for _p in _glob.glob("/tmp/simbiot-test-close-stale.db*"):
|
||
os.unlink(_p)
|
||
|
||
sys.path.insert(0, "/root/simbiot-harness")
|
||
|
||
fails = []
|
||
|
||
|
||
def check(name, cond, extra=""):
|
||
print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "")
|
||
if not cond:
|
||
fails.append(name)
|
||
|
||
|
||
import harness.es as es
|
||
import harness.commands as cmds
|
||
|
||
es.init_db()
|
||
|
||
# ── 1. close_task: failed → closed ──────────────────────────────────────
|
||
tid, _size = cmds.submit_task("тест close: failed-задача", "details", minutes=5)
|
||
es.append_event("task", tid, "TaskClaimed", {"level": "hour", "session_id": "s-test"})
|
||
es.append_event("task", tid, "TaskFailed", {"error": "timeout"})
|
||
model = es.run_projections()
|
||
check("1: подготовка — задача failed", model["tasks"][tid]["status"] == "failed")
|
||
|
||
cmds.close_task(tid, "тестовое закрытие")
|
||
model = es.run_projections()
|
||
check("1: failed → closed", model["tasks"][tid]["status"] == "closed", model["tasks"][tid]["status"])
|
||
check("1: closed_by=manual", model["tasks"][tid].get("closed_by") == "manual")
|
||
|
||
tid2 = cmds.close_task(tid, "повторное закрытие")
|
||
check("1: повторный close — no-op (идемпотентно)",
|
||
model["tasks"][tid]["status"] == "closed" and tid2 == tid)
|
||
|
||
# close на не-failed:
|
||
tid_r, _size = cmds.submit_task("тест close: ready-задача", "details", minutes=5)
|
||
try:
|
||
cmds.close_task(tid_r, "не должен пройти")
|
||
check("1: close на ready отклонён", False)
|
||
except ValueError as e:
|
||
check("1: close на ready отклонён", "close только для failed" in str(e), str(e)[:100])
|
||
|
||
# ── 2. archive_stale_failed: только failed>24ч ──────────────────────────
|
||
from datetime import datetime, timezone, timedelta
|
||
now0 = datetime.now(timezone.utc)
|
||
|
||
old_id, fresh_id = "t-old-failed", "t-fresh-failed"
|
||
# порог тестируем через now=now0+25ч (симуляция «прошли сутки» — события
|
||
# подделывать не нужно: now — параметр архиватора, добавлен для тестов)
|
||
es.append_event("task", old_id, "TaskSubmitted",
|
||
{"title": "старый дубли-фейл", "details": "", "size": "S"})
|
||
es.append_event("task", old_id, "TaskClaimed", {"level": "hour", "session_id": "s-test"})
|
||
es.append_event("task", old_id, "TaskFailed", {"error": "timeout"})
|
||
# свежий фейл — тоже с реальным timestamp; в фазе A возраст ~0 у обоих
|
||
es.append_event("task", fresh_id, "TaskSubmitted",
|
||
{"title": "свежий фейл", "details": "", "size": "S"})
|
||
es.append_event("task", fresh_id, "TaskClaimed", {"level": "hour", "session_id": "s-test"})
|
||
es.append_event("task", fresh_id, "TaskFailed", {"error": "boom"})
|
||
|
||
model = es.run_projections()
|
||
check("2: подготовка — оба failed",
|
||
model["tasks"][old_id]["status"] == "failed" and model["tasks"][fresh_id]["status"] == "failed")
|
||
|
||
# фаза A: реальное now, порог 24ч — возраст секунд, никого не трогает
|
||
closed = cmds.archive_stale_failed(max_age_hours=24.0)
|
||
check("2: свежие failed не архивируются (порог 24ч, возраст ~0)",
|
||
not closed, str(closed))
|
||
check("2: свежий остался failed", es.run_projections()["tasks"][fresh_id]["status"] == "failed")
|
||
|
||
# фаза B: сутки спустя (симуляция) — оба failed>24ч уходят в closed
|
||
sim = now0 + timedelta(hours=25)
|
||
closed = cmds.archive_stale_failed(max_age_hours=24.0, now=sim)
|
||
check("2: сутки спустя оба failed>24ч → closed",
|
||
set(closed) == {old_id, fresh_id}, str(closed))
|
||
m2 = es.run_projections()
|
||
check("2: оба в closed", m2["tasks"][old_id]["status"] == "closed"
|
||
and m2["tasks"][fresh_id]["status"] == "closed")
|
||
check("2: closed_by=archive", m2["tasks"][old_id].get("closed_by") == "archive")
|
||
|
||
# повторный проход — no-op (уже closed, не failed)
|
||
closed = cmds.archive_stale_failed(max_age_hours=24.0, now=sim)
|
||
check("2: повторный проход — пусто (идемпотентно)", not closed, str(closed))
|
||
|
||
# done-задачи архиватор не трогает
|
||
tid_d, _size = cmds.submit_task("тест close: done", "details", minutes=5)
|
||
es.append_event("task", tid_d, "TaskClaimed", {"level": "hour", "session_id": "s-test"})
|
||
es.append_event("task", tid_d, "TaskCompleted", {"result_ref": "r", "answer": "ok"})
|
||
cmds.archive_stale_failed(max_age_hours=0.001)
|
||
check("2: done не тронут", es.run_projections()["tasks"][tid_d]["status"] == "done")
|
||
|
||
# ── 3. requeue из closed → ready ────────────────────────────────────────
|
||
cmds.requeue_task(old_id, "понадобилась снова")
|
||
m3 = es.run_projections()
|
||
check("3: closed → ready через requeue", m3["tasks"][old_id]["status"] == "ready",
|
||
m3["tasks"][old_id]["status"])
|
||
|
||
# и цепочка full-loop: ready → claim → failed → close(no-op, уже closed-история? нет:
|
||
# requeue вернул в ready, поэтому снова failed → close проходит)
|
||
es.append_event("task", old_id, "TaskClaimed", {"level": "hour", "session_id": "s-test"})
|
||
es.append_event("task", old_id, "TaskFailed", {"error": "timeout2"})
|
||
cmds.close_task(old_id, "снова в архив")
|
||
check("3: полный цикл closed→ready→failed→closed",
|
||
es.run_projections()["tasks"][old_id]["status"] == "closed")
|
||
|
||
print()
|
||
if fails:
|
||
print("FAILS:", len(fails))
|
||
sys.exit(1)
|
||
print("OK: авто-схлопывание старых фейлов — все проверки прошли") |