fix(hub): сбой резервной копии не блокирует выдачу, SAVEPOINT для вложенных транзакций, настоящий тест исключительной выдачи
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
parent
f8867939d5
commit
68b73439a6
@ -1,4 +1,5 @@
|
|||||||
"""Соединение с SQLite и схема хаба. Схема создаётся при первом подключении; версия — в setting.schema_version."""
|
"""Соединение с SQLite и схема хаба. Схема создаётся при первом подключении; версия — в setting.schema_version."""
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@ -126,13 +127,27 @@ def migrate(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)")
|
conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)")
|
||||||
|
|
||||||
|
|
||||||
|
_savepoints = itertools.count(1)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def transaction(conn: sqlite3.Connection):
|
def transaction(conn: sqlite3.Connection):
|
||||||
"""BEGIN IMMEDIATE … COMMIT; при исключении — ROLLBACK. Нужна там, где читаем и пишем вместе (claim, отчёт).
|
"""BEGIN IMMEDIATE … COMMIT; при исключении — ROLLBACK. Нужна там, где читаем и пишем вместе (claim, отчёт).
|
||||||
Вложенный вызов (транзакция уже открыта) входит во внешнюю: своих BEGIN/COMMIT/ROLLBACK не делает,
|
Вложенный вызов (транзакция уже открыта) идёт через SAVEPOINT: при успехе RELEASE, при исключении
|
||||||
исключение уходит внешнему блоку, который откатит всё."""
|
ROLLBACK TO + RELEASE и исключение пробрасывается — вызывающий может его перехватить, внешняя
|
||||||
|
транзакция при этом остаётся целой, а записи вложенного блока не сохраняются.
|
||||||
|
Соединение не должно делиться между потоками: каждый поток/запрос открывает своё."""
|
||||||
if conn.in_transaction:
|
if conn.in_transaction:
|
||||||
yield conn
|
name = f"sp_{next(_savepoints)}"
|
||||||
|
conn.execute(f"SAVEPOINT {name}")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
except BaseException:
|
||||||
|
conn.execute(f"ROLLBACK TO {name}")
|
||||||
|
conn.execute(f"RELEASE {name}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
conn.execute(f"RELEASE {name}")
|
||||||
return
|
return
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление.
|
"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление.
|
||||||
Хаб никогда не повторяет команды сам (design.md, решение 4)."""
|
Хаб никогда не повторяет команды сам (design.md, решение 4)."""
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
|
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
|
||||||
from . import commands as C
|
from . import commands as C
|
||||||
from . import db, mirror, settings
|
from . import db, mirror, settings
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"}
|
TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"}
|
||||||
STALE_MINUTES = 10 # взятая команда старше — не блокирует выдачу, показывается как «нет ответа»
|
STALE_MINUTES = 10 # взятая команда старше — не блокирует выдачу, показывается как «нет ответа»
|
||||||
ONLINE_MINUTES = 5
|
ONLINE_MINUTES = 5
|
||||||
@ -162,7 +165,10 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non
|
|||||||
if first and has_writes:
|
if first and has_writes:
|
||||||
to_backup = f"batch-{cmd['batch_id']}"
|
to_backup = f"batch-{cmd['batch_id']}"
|
||||||
if to_backup and backup:
|
if to_backup and backup:
|
||||||
backup(to_backup)
|
try:
|
||||||
|
backup(to_backup)
|
||||||
|
except Exception: # сбой копии не должен держать очередь: команда уже взята
|
||||||
|
log.warning("резервная копия %s не создана, команда %s выдаётся без неё", to_backup, cmd["id"], exc_info=True)
|
||||||
return {"id": cmd["id"], "type": cmd["type"], "params": cmd["params"], "pause_seconds": pause, "batch_id": cmd["batch_id"]}
|
return {"id": cmd["id"], "type": cmd["type"], "params": cmd["params"], "pause_seconds": pause, "batch_id": cmd["batch_id"]}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -56,3 +56,16 @@ def test_nested_transaction_error_propagates_to_outer(tmp_path):
|
|||||||
def test_parse_and_utcnow():
|
def test_parse_and_utcnow():
|
||||||
assert db.parse("2020-01-01T00:00:00").tzinfo is not None
|
assert db.parse("2020-01-01T00:00:00").tzinfo is not None
|
||||||
assert db.parse(db.now()) <= db.utcnow()
|
assert db.parse(db.now()) <= db.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_transaction_rollback_is_local(tmp_path):
|
||||||
|
conn = db.connect(tmp_path / "hub.sqlite")
|
||||||
|
with db.transaction(conn):
|
||||||
|
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('outer', 'y', 'z')")
|
||||||
|
try:
|
||||||
|
with db.transaction(conn):
|
||||||
|
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('inner', 'y', 'z')")
|
||||||
|
raise RuntimeError("inner")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
assert [r["title"] for r in conn.execute("SELECT title FROM batch")] == ["outer"]
|
||||||
|
|||||||
@ -59,11 +59,37 @@ def test_claim_order_pause_and_report():
|
|||||||
assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running"
|
assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running"
|
||||||
|
|
||||||
|
|
||||||
def test_claim_is_exclusive():
|
def test_claim_is_exclusive(tmp_path):
|
||||||
|
import threading
|
||||||
|
path = tmp_path / "hub.sqlite"
|
||||||
|
setup = db.connect(path)
|
||||||
|
settings.ensure_defaults(setup)
|
||||||
|
queue.create_batch(setup, "x", "claude", [fields("A-1")])
|
||||||
|
conns = [db.connect(path), db.connect(path)]
|
||||||
|
barrier, results = threading.Barrier(2), []
|
||||||
|
|
||||||
|
def worker(c):
|
||||||
|
barrier.wait()
|
||||||
|
results.append(queue.claim(c))
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=worker, args=(c,)) for c in conns]
|
||||||
|
[t.start() for t in threads]
|
||||||
|
[t.join() for t in threads]
|
||||||
|
assert sorted(r is None for r in results) == [False, True]
|
||||||
|
assert setup.execute("SELECT COUNT(*) AS n FROM command WHERE status = 'claimed'").fetchone()["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_failure_does_not_block_claim(caplog):
|
||||||
conn = fresh()
|
conn = fresh()
|
||||||
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||||
first, second = queue.claim(conn), queue.claim(conn)
|
|
||||||
assert first is not None and second is None
|
def broken(label):
|
||||||
|
raise OSError("диск полон")
|
||||||
|
|
||||||
|
c = queue.claim(conn, backup=broken)
|
||||||
|
assert c is not None and c["params"]["key"] == "A-1"
|
||||||
|
assert queue.get_command(conn, c["id"])["status"] == "claimed"
|
||||||
|
assert "не создана" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_stale_claimed_does_not_block():
|
def test_stale_claimed_does_not_block():
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user