Files
cleveragents-core/tools/controller/db/dequeue.py
T
drew 36b133ec5e feat(controller): Phase 1b — DB schema + dequeue helper + payload guard
Five SQLAlchemy 2.0 declarative models implementing plan v6/v9's
unified workflow schema. Cross-dialect (SQLite for tests + local dev,
Postgres for multi-machine production). Lock columns on
workflow_attempts implement the multi-machine-safe dequeue protocol
(plan v5).

Modules:

- tools/controller/db/models.py:
  - Workflow (kind discriminator pr/issue, unique on owner+repo+kind+
    entity_number, parent_workflow_id FK for issue→PR linkage)
  - WorkflowAttempt (status/locked_by_instance/locked_at/
    lock_heartbeat_at/lock_ttl_seconds/pickup_count + CHECK
    constraints on status enum and pickup_count≥0; partial indexes
    on the pending/in_progress/complete hot paths)
  - ControllerEvent (Forgejo-write replay support kept in-schema even
    though v9 simplified to Forgejo-first protocol; allows v3-style
    upgrade later without migration)
  - FlakeHistory (composite PK; supports the v6 flake-learning
    heuristic)
  - CIObservation (raw CI state history; 90-day retention to be
    enforced by a sweep task)
  - AutoincrementPk variant (Integer on SQLite where it autoincrements
    via rowid; BigInteger on Postgres for BIGSERIAL); JsonColumn
    variant (JSON on SQLite, JSONB on Postgres)

- tools/controller/db/session.py: build_engine (per-dialect tuning —
  SQLite WAL + foreign_keys + busy_timeout; Postgres pool_pre_ping);
  create_all (idempotent); session_scope (transactional context).

- tools/controller/db/dequeue.py: dequeue_one (one row atomically;
  Postgres path uses SELECT FOR UPDATE SKIP LOCKED, SQLite path uses
  UPDATE-WHERE-id-IN-SELECT-LIMIT-1 with RETURNING). Bumps pickup_count
  on dequeue; respects max_pickups guard (default 3 per v6 blocker fix).
  Returns DequeueResult dataclass with role/tier/pickup_count and
  reason on miss.

- tools/controller/db/payload_guard.py: enforce_input_payload_size
  with 4MB cap and 5-step truncation priority (older_summary → oldest
  verbatim → comments → full_diff → CI failure excerpts). Raises
  PayloadTooLargeError after all steps exhausted; master maps to
  workflow STUCK with reason='input-too-large'.

- pyproject.toml: new optional extras `controller-db` pinning
  sqlalchemy + psycopg2-binary (latter installed only for prod
  multi-machine deploy; tests use stdlib sqlite3 via SQLAlchemy's
  SQLite dialect which is already pulled in transitively via alembic).

37 new tests across test_db_schema/dequeue/payload_guard; 141
controller tests total; full auto_agents suite 2503 pass (no
regressions).
2026-05-18 13:01:55 -04:00

205 lines
6.9 KiB
Python

"""Worker dequeue helper.
The worker picks one pending ``workflow_attempts`` row, marks it
``in_progress`` with lock metadata, and returns it. The atomic
"pick + mark" is the core multi-machine coordination primitive.
Per plan v5/v6:
- Postgres: ``SELECT … FOR UPDATE SKIP LOCKED LIMIT 1`` is the
canonical primitive. Multiple workers race; only one acquires
each row.
- SQLite: no SKIP LOCKED. Workers serialise via ``BEGIN IMMEDIATE``
+ ``UPDATE … WHERE status='pending'`` (RETURNING clause; SQLite
3.35+). For single-machine SQLite tests this is fine; multi-machine
SQLite over NFS is explicitly NOT supported (per plan v9: Postgres
is required for multi-machine).
Pickup-count guard (v6 blocker): the dequeue UPDATE bumps
``pickup_count``. A separate query (master-side) finds attempts
where ``pickup_count >= MAX_PICKUPS`` and transitions the workflow
to STUCK.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import bindparam, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from .models import WorkflowAttempt
# Per-attempt pickup limit. If a worker dequeues an attempt and
# fails (status='failed' outcome='worker-internal-error'), it can be
# re-pended; pickup_count++. Beyond MAX_PICKUPS, the master transitions
# the workflow to STUCK with reason='attempt-pickup-exhausted'.
DEFAULT_MAX_PICKUPS = int(os.environ.get("CONTROLLER_MAX_ATTEMPT_PICKUPS", "3"))
@dataclass
class DequeueResult:
"""What the worker gets back from dequeue.
``acquired`` is True iff a row was claimed. On False, no rows
available (worker sleeps + retries) — ``reason`` distinguishes
'empty queue' from 'all candidate rows exceeded pickup limit'.
"""
acquired: bool
attempt_id: int | None = None
workflow_id: int | None = None
role: str | None = None
tier: int | None = None
pickup_count: int | None = None
reason: str | None = None
def _now() -> datetime:
return datetime.now(timezone.utc)
def dequeue_one(
session: Session,
*,
instance_id: str,
roles: list[str],
max_pickups: int = DEFAULT_MAX_PICKUPS,
) -> DequeueResult:
"""Atomically pick the oldest pending attempt this worker can
handle and stamp it as in_progress with the worker's identity.
``instance_id`` format: ``{hostname}/{pid}/{worker_uuid}``
(v9: slash-delimited so IPv6 hostnames don't trip parsing).
``roles`` is the list of role values the worker accepts.
Returns ``DequeueResult(acquired=False)`` if no eligible row.
Returns ``DequeueResult(acquired=True, attempt_id=..., ...)``
on success — the row is now locked under ``instance_id`` and
the worker holds the heartbeat responsibility.
Dialect routing:
- Postgres: ``SELECT … FOR UPDATE SKIP LOCKED`` (atomic dequeue
across racing workers).
- SQLite: ``BEGIN IMMEDIATE`` was already applied by the caller's
session_scope; SQLite's locking serializes the SELECT + UPDATE.
The session is committed by the caller (``session_scope``).
"""
if not roles:
return DequeueResult(acquired=False, reason="no_roles_configured")
dialect = session.bind.dialect.name if session.bind else "sqlite"
now = _now()
if dialect == "postgresql":
return _dequeue_postgres(session, instance_id, roles, max_pickups, now)
# SQLite (or any other; fall through with a generic implementation)
return _dequeue_sqlite(session, instance_id, roles, max_pickups, now)
def _dequeue_postgres(
session: Session,
instance_id: str,
roles: list[str],
max_pickups: int,
now: datetime,
) -> DequeueResult:
"""Postgres path: SELECT FOR UPDATE SKIP LOCKED + UPDATE."""
# Two-statement transactional dequeue. We use raw SQL because
# SQLAlchemy ORM's auto-flush behavior would re-query.
select_sql = text("""
SELECT attempt_id, workflow_id, role, tier, pickup_count
FROM workflow_attempts
WHERE status = 'pending'
AND role = ANY(:roles)
AND pickup_count < :max_pickups
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
""").bindparams(bindparam("roles"), bindparam("max_pickups"))
row = session.execute(
select_sql, {"roles": roles, "max_pickups": max_pickups}
).first()
if row is None:
return DequeueResult(acquired=False, reason="no_pending_eligible")
update_sql = text("""
UPDATE workflow_attempts
SET status = 'in_progress',
locked_by_instance = :instance,
locked_at = :now,
lock_heartbeat_at = :now,
pickup_count = pickup_count + 1,
started_at = :now
WHERE attempt_id = :attempt_id
""")
session.execute(
update_sql,
{"instance": instance_id, "now": now, "attempt_id": row.attempt_id},
)
return DequeueResult(
acquired=True,
attempt_id=row.attempt_id,
workflow_id=row.workflow_id,
role=row.role,
tier=row.tier,
pickup_count=(row.pickup_count or 0) + 1,
)
def _dequeue_sqlite(
session: Session,
instance_id: str,
roles: list[str],
max_pickups: int,
now: datetime,
) -> DequeueResult:
"""SQLite path: serialise on BEGIN IMMEDIATE + UPDATE.
SQLite 3.35+ supports RETURNING, which makes this atomic in one
statement. Older SQLite would need a SELECT-then-UPDATE, accepting
a tight TOCTOU window — fine for tests but the test DB pins
sqlite ≥3.35 in practice (Python 3.13 ships 3.43+).
"""
# SQLite doesn't have ANY(:roles); emit an IN-clause via parameter
# expansion.
role_clauses = ",".join(f":role_{i}" for i in range(len(roles)))
update_sql = text(f"""
UPDATE workflow_attempts
SET status = 'in_progress',
locked_by_instance = :instance,
locked_at = :now,
lock_heartbeat_at = :now,
pickup_count = pickup_count + 1,
started_at = :now
WHERE attempt_id = (
SELECT attempt_id FROM workflow_attempts
WHERE status = 'pending'
AND role IN ({role_clauses})
AND pickup_count < :max_pickups
ORDER BY created_at ASC
LIMIT 1
)
RETURNING attempt_id, workflow_id, role, tier, pickup_count
""")
params = {
"instance": instance_id,
"now": now,
"max_pickups": max_pickups,
**{f"role_{i}": r for i, r in enumerate(roles)},
}
row = session.execute(update_sql, params).first()
if row is None:
return DequeueResult(acquired=False, reason="no_pending_eligible")
return DequeueResult(
acquired=True,
attempt_id=row.attempt_id,
workflow_id=row.workflow_id,
role=row.role,
tier=row.tier,
pickup_count=row.pickup_count,
)