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 на две фазы с отдельными транзакциями:

  1. Claim phase: claim_next_task атомарно ставит status = 'running' и коммитит. Транзакция закрывается, лок снимается.

  2. 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)  # ← отдельные TX

Alternatives 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