ADR-0004: Claim и execution задач — раздельные транзакции
Status
accepted
Context
При реализации worker loop в Week 1.2 первоначальный подход был:
один context manager claim_next_task, внутри которого держится
FOR UPDATE на строке tasks и выполняется агент, включая запись
run’а в таблицу runs.
Проблема обнаружилась при smoke-тестах: вставка в runs выполняет
FK-проверку (task_id REFERENCES tasks(id)), которая требует прочитать
заблокированную строку tasks. В рамках той же транзакции это работает,
но record_run и mark_task_done открывают отдельные соединения
из пула — и именно эти соединения блокируются на FK-проверке, ожидая
снятия FOR UPDATE лока. Результат — deadlock: claim держит лок и
ждёт завершения execute, а execute ждёт снятия лока для FK check.
Чем дольше execute агента, тем дольше другие воркеры не могут даже
пропустить эту строку через SKIP LOCKED — она залочена на всё время
выполнения.
Decision
Разделить claim и execute на две фазы с отдельными транзакциями:
-
Claim phase:
claim_next_taskатомарно ставитstatus = 'running'и коммитит. Транзакция закрывается, лок снимается. -
Execute phase: агент выполняется вне claim context’а.
record_runиmark_task_done/mark_task_failedработают с собственными соединениями без конфликтов.
# До (deadlock):
with claim_next_task(agents) as task:
if task:
self._execute_agent(agent, task) # ← внутри claim TX
# После (fix):
with claim_next_task(agents) as task:
claimed_task = task
# ← claim TX закоммичена, лок снят
if claimed_task:
self._execute_agent(agent, claimed_task) # ← отдельные TXAlternatives Considered
Option A: Единая транзакция (claim + execute + run) ← rejected
- Pros: атомарность — если execute упал, task автоматически откатится в pending
- Cons: deadlock из-за FK check на другом соединении, длительный hold на
FOR UPDATE, блокирует SKIP LOCKED для других воркеров
Option B: Убрать FK constraint на runs.task_id ← rejected
- Pros: устраняет deadlock без изменения архитектуры
- Cons: теряем referential integrity, orphan runs, усложняет debug
Option C: Раздельные транзакции ← chosen
- Pros: нет deadlock, короткий hold на строке tasks (только UPDATE SET running), FK checks работают штатно, другие воркеры быстрее пропускают running задачи
- Cons: если воркер crash после claim но до mark_done — задача застревает в ‘running’
- Why chosen: единственный вариант без потери целостности данных. Stuck tasks решается watchdog’ом (Phase 2).
Consequences
Positive
- Нет deadlock при записи run’ов
FOR UPDATEлок держится миллисекунды (только UPDATE SET running), а не секунды/минуты- Другие воркеры не простаивают на SKIP LOCKED
- FK constraints сохранены
Negative / Trade-offs
- Task может застрять в ‘running’ если воркер упал между claim и mark_done/failed
- Нет автоматического rollback в pending при crash
Mitigations
- Watchdog (Phase 2):
UPDATE tasks SET status = 'failed' WHERE status = 'running' AND started_at < now() - interval '5 minutes' - Retry logic:
retry_count < max_retriesпозволяет перезапустить stuck задачи - Мониторинг: alert на tasks в ‘running’ дольше
budget_seconds
Follow-ups
- Watchdog для stuck tasks (Phase 2)
- Тест: concurrent workers на одной очереди (race condition check)
- Метрика: среднее время hold на FOR UPDATE
References
- ADR-0001-Use-Postgres-As-Queue — основное решение по очереди
- Orchestration-Model — модель оркестрации
src/synth_brain/agents/runner.py— реализация fixsrc/synth_brain/queue/tasks.py— claim_next_task- https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS