simbiot-harness/harness/compact.py
Simbiot Harness 2b266caab5 сжатие стора с подписью + «сначала ответ, потом работа» на мин-уровне
harness/compact.py + cli.py compact [--dry-run|--vacuum|--verify-archives]:
шум сек-уровня (CycleTicked sec, ReflexObserved без алерта/команд/каналов)
старше часа → архив jsonl.gz (0600, строки целиком со sig) → мост цепочки
(подписи унесённых строк, на которые ссылаются остающиеся, + маркеры
boundary:<id> для legacy-границ) → подписанный StoreCompacted с sha256
архива/строк/моста → удаление пачками. audit.verify_events принимает мост
только при совпадении хеша с подписанным событием; проекции подгружают
мост лениво, если хвост перекрыл сжатый диапазон (rebuild с нуля).
Репетиция на копии прода: 1389 → 200 МБ, verify 484 с/3.4 ГБ → 29 с/420 МБ,
архив 246 МБ — все 2.47 млн подписей проверены; rebuild_projections OK.
Найдено репетицией: legacy-граница (id 466633) и коллизия id сжатия в
одну секунду — оба покрыты тестом. scripts/compact-store.sh — процедура
с VACUUM под остановленными писателями.

мин-уровень: если до ответа нужна работа >30 с — сначала короткий
tg-reply, итог — cli.py tg-reply <id> --followup (в чат входящего:
группа → группа; идемпотентно по тексту). p90 «старт LLM → ответ» был 223 с.

hour: лимит 300 вызовов оставлен — реальный пик 205 (p90 169), 100
оборвал бы 8 из 19 тиков.
Тесты: test_compact 25 PASS, test_followup 10 PASS; полный набор — без регрессий.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:10:26 +00:00

247 lines
12 KiB
Python
Raw 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.

"""Simbiot Harness — сжатие стора с подписью (аудит 2026-10-16).
Сек-уровень до фикса писал CycleTicked + ReflexObserved каждую секунду:
~3 млн подписанных событий шума, 98% стора (1.37 ГБ), полный `cli.py
verify` — 8 минут и 3.4 ГБ памяти. Сжатие уносит этот шум в архив, не
ломая криптоаудит:
1. Кандидаты — только шум сек-уровня старше --older-than-hours (1 ч):
CycleTicked level=sec и ReflexObserved без алерта/команд/каналов.
Всё остальное (задачи, сообщения, трейсы, heartbeat'ы с событием) — в сторе.
2. Архив <home>/archive/events-<first>-<last>-<ts>.jsonl.gz (0600): строки
целиком, со sig/key_id/prev_sig — каждую можно проверить публичным
ключом (verify_archive). sha256 архива и потока строк фиксируются.
3. Мост: подписи удалённых строк, на которые ссылается prev_sig оставшихся
(цепочка иначе рвётся), — в таблицу compaction_bridge. Их sha256 (по
отсортированному списку) входит в подписанное событие StoreCompacted —
подменить мост без ключа харнеса нельзя; audit.verify_events проверяет
хеш моста против подписанного события и только тогда принимает ссылки.
4. Удаление пачками по BATCH строк короткими транзакциями — демоны
пишут дальше (busy_timeout 30 с с запасом). VACUUM — отдельно
(--vacuum), под остановленными писателями: он берёт эксклюзивный лок.
Повторный запуск безопасен: новые кандидаты → новое сжатие со своим
мостом; события append-only, критерий детерминирован.
"""
import gzip
import hashlib
import json
import os
import secrets
import sqlite3
import time
from datetime import datetime, timedelta, timezone
from . import es
from . import paths as _paths
BATCH = 20_000
NOISE_SQL = (
"((event_type='CycleTicked' AND json_extract(data,'$.level')='sec') OR "
" (event_type='ReflexObserved'"
" AND json_extract(data,'$.alert') IS NULL"
" AND json_extract(data,'$.commands') IS NULL"
" AND json_extract(data,'$.portal_channel') IS NULL"
" AND json_extract(data,'$.agent_channel') IS NULL))"
)
BRIDGE_DDL = ("CREATE TABLE IF NOT EXISTS compaction_bridge ("
"sig TEXT PRIMARY KEY, compaction TEXT NOT NULL)")
def archive_dir() -> str:
d = (os.environ.get("SIMBIOT_ARCHIVE_DIR", "").strip()
or os.path.join(_paths.home(), "archive"))
os.makedirs(d, mode=0o700, exist_ok=True)
return d
def bridge_hash(sigs) -> str:
"""sha256 моста: отсортированные подписи через \\n — каноника для события."""
return hashlib.sha256("\n".join(sorted(sigs)).encode()).hexdigest()
def _connect(db_path=None):
db = sqlite3.connect(db_path or es.DB_PATH, timeout=30)
db.execute("PRAGMA busy_timeout=30000")
db.row_factory = sqlite3.Row
return db
def _cutoff_id(db, older_than_hours: float) -> int:
"""Последний id старше порога, но никогда не голова стора (на неё ссылается
prev_sig следующей записи)."""
cutoff = (datetime.now(timezone.utc) - timedelta(hours=older_than_hours)).isoformat()
head = db.execute("SELECT MAX(id) FROM events").fetchone()[0] or 0
# id растёт вместе со временем: бинпоиск по PK вместо скана по timestamp
lo, hi = 0, head
while lo < hi:
mid = (lo + hi + 1) // 2
row = db.execute("SELECT timestamp FROM events WHERE id >= ? ORDER BY id LIMIT 1",
(mid,)).fetchone()
if row and row[0] < cutoff:
lo = mid
else:
hi = mid - 1
return min(lo, head - 1)
def plan(older_than_hours: float = 1.0, db_path=None) -> dict:
"""Сухой прогон: сколько уйдёт в архив."""
db = _connect(db_path)
upto = _cutoff_id(db, older_than_hours)
n, first, last = db.execute(
f"SELECT COUNT(*), MIN(id), MAX(id) FROM events WHERE id <= ? AND {NOISE_SQL}",
(upto,)).fetchone()
total = db.execute("SELECT COUNT(*) FROM events").fetchone()[0]
db.close()
return {"upto_id": upto, "candidates": n, "first_id": first, "last_id": last,
"total": total, "db_bytes": os.path.getsize(db_path or es.DB_PATH)}
def compact(older_than_hours: float = 1.0, log=print) -> dict:
"""Архив → мост + подписанный StoreCompacted → удаление пачками.
Работает со стором es.DB_PATH (SIMBIOT_DB / SIMBIOT_HOME) — тем же, куда
подписанный StoreCompacted пишет es.append_event."""
db = _connect()
db.execute(BRIDGE_DDL)
db.commit()
upto = _cutoff_id(db, older_than_hours)
# prev_sig всех строк, что ОСТАНУТСЯ: на какие подписи будут ссылки
kept_prev = {r[0] for r in db.execute(
f"SELECT prev_sig FROM events WHERE prev_sig IS NOT NULL AND prev_sig != '' "
f"AND NOT (id <= ? AND {NOISE_SQL})", (upto,))}
log(f"[compact] граница id≤{upto}; ссылок prev_sig у остающихся: {len(kept_prev)}")
stamp = datetime.now(timezone.utc).strftime("%y%m%d%H%M%S")
comp_id = f"c-{stamp}-{secrets.token_hex(3)}" # уникален и в пределах секунды
tmp_path = os.path.join(archive_dir(), f".{comp_id}.jsonl.gz.tmp")
rows_h = hashlib.sha256()
bridge, n, first, last = [], 0, None, None
cols = None
with gzip.open(tmp_path, "wt", encoding="utf-8", compresslevel=6) as f:
cur = db.execute(f"SELECT * FROM events WHERE id <= ? AND {NOISE_SQL} ORDER BY id",
(upto,))
for r in cur:
if cols is None:
cols = r.keys()
line = json.dumps({k: r[k] for k in cols}, ensure_ascii=False,
sort_keys=True, separators=(",", ":"))
f.write(line + "\n")
rows_h.update(line.encode() + b"\n")
n += 1
first = r["id"] if first is None else first
last = r["id"]
if r["sig"] and r["sig"] in kept_prev:
bridge.append(r["sig"])
if not n:
os.unlink(tmp_path)
db.close()
log("[compact] кандидатов нет")
return {"compaction": None, "removed": 0}
os.chmod(tmp_path, 0o600)
with open(tmp_path, "rb") as fh:
os.fsync(fh.fileno())
arch = os.path.join(archive_dir(), f"events-{first}-{last}-{comp_id}.jsonl.gz")
os.replace(tmp_path, arch)
arch_h = hashlib.sha256()
with open(arch, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
arch_h.update(chunk)
log(f"[compact] архив {arch}: {n} строк, {os.path.getsize(arch) / 1e6:.1f} МБ; мост {len(bridge)}")
# границы: подписанная строка с пустым prev_sig писалась после НЕподписанной
# (legacy) — легальная граница цепочки. Если та неподписанная уходит в
# архив, перед строкой окажется подписанная и link-check упадёт (кейс
# прод-репетиции, id 466633). Маркер boundary:<id> в мосте (под тем же
# подписанным хешем) — «граница здесь была, унесена сжатием».
for (rid,) in db.execute(
f"SELECT id FROM events WHERE sig IS NOT NULL AND sig NOT LIKE 'genesis:%' "
f"AND (prev_sig IS NULL OR prev_sig = '') AND NOT (id <= ? AND {NOISE_SQL})",
(upto,)).fetchall():
pred = db.execute(
f"SELECT id <= ? AND {NOISE_SQL} FROM events WHERE id < ? ORDER BY id DESC LIMIT 1",
(upto, rid)).fetchone()
if pred and pred[0]:
bridge.append(f"boundary:{rid}")
log(f"[compact] мост с маркерами границ: {len(bridge)}")
# мост ДО события: событие фиксирует его хеш; сбой события → мост убираем
db.executemany("INSERT OR IGNORE INTO compaction_bridge (sig, compaction) VALUES (?, ?)",
[(s, comp_id) for s in bridge])
db.commit()
info = {
"compaction": comp_id, "archive": os.path.basename(arch),
"archive_sha256": arch_h.hexdigest(), "rows_sha256": rows_h.hexdigest(),
"rows": n, "first_id": first, "last_id": last, "upto_id": upto,
"criteria": "sec-noise: CycleTicked level=sec, ReflexObserved без alert/commands/каналов",
"bridge_count": len(bridge), "bridge_sha256": bridge_hash(bridge),
}
res = es.append_event("harness", f"compaction:{comp_id}", "StoreCompacted", info)
if not res:
db.execute("DELETE FROM compaction_bridge WHERE compaction = ?", (comp_id,))
db.commit()
db.close()
raise RuntimeError("StoreCompacted не записан — сжатие отменено, архив оставлен")
removed = 0
for start in range(first, last + 1, BATCH):
cur = db.execute(f"DELETE FROM events WHERE id BETWEEN ? AND ? AND {NOISE_SQL}",
(start, min(start + BATCH - 1, last)))
db.commit()
removed += cur.rowcount
time.sleep(0.02) # окно писателям между пачками
db.close()
log(f"[compact] удалено {removed} строк; VACUUM — отдельно (--vacuum, писатели остановлены)")
return {**info, "removed": removed, "archive_path": arch}
def vacuum(db_path=None) -> dict:
"""VACUUM + checkpoint WAL: эксклюзивный лок — писатели должны стоять."""
path = db_path or es.DB_PATH
before = os.path.getsize(path)
db = sqlite3.connect(path, timeout=60)
db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
db.execute("VACUUM")
db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
db.close()
return {"before": before, "after": os.path.getsize(path)}
def verify_archive(path: str, expect_sha256: str = None, pub_b64: str = None) -> dict:
"""Архив сжатия: sha256 файла (против StoreCompacted) + подпись КАЖДОЙ строки."""
from . import audit
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
pub_b64 = pub_b64 or audit.public_key_b64()
rows = signed = genesis = legacy = 0
first_bad = None
with gzip.open(path, "rt", encoding="utf-8") as f:
for line in f:
r = json.loads(line)
rows += 1
sig = r.get("sig")
if not sig:
legacy += 1
continue
if sig.startswith("genesis:"):
genesis += 1
if sig != f"genesis:{r['id']}" and first_bad is None:
first_bad = r["id"]
continue
payload = audit.payload_bytes(r["aggregate"], r["aggregate_id"], r["event_type"],
r["data"], r["timestamp"], r["version"],
r.get("prev_sig") or None, r.get("key_id"))
if audit.verify_bytes(pub_b64, payload, sig):
signed += 1
elif first_bad is None:
first_bad = r["id"]
sha_ok = expect_sha256 is None or h.hexdigest() == expect_sha256
return {"rows": rows, "signed_ok": signed, "genesis": genesis, "legacy": legacy,
"first_bad": first_bad, "sha256": h.hexdigest(), "sha_ok": sha_ok,
"ok": first_bad is None and sha_ok}