""" 死信壳:失败任务落库 / 内存,不含自动重放业务。 本文件职责:enqueue_dead_letter;正式重放由运维/后续模块显式触发。 禁止:把死信成功当成业务成功。 """ from __future__ import annotations import json import logging import threading import uuid from dataclasses import dataclass, field from typing import Any, Optional from agent.runtime_db import assert_safe_database, connect_psycopg logger = logging.getLogger(__name__) @dataclass class DeadLetterRecord: id: str source: str source_id: str = "" job_type: str = "" payload: dict[str, Any] = field(default_factory=dict) error: str = "" attempts: int = 0 class MemoryDeadLetterStore: """内存死信壳。""" def __init__(self) -> None: self._rows: list[DeadLetterRecord] = [] self._lock = threading.Lock() def enqueue(self, record: DeadLetterRecord) -> str: with self._lock: if not record.id: record.id = str(uuid.uuid4()) self._rows.append(record) logger.info( "dead_letter.memory source=%s type=%s id=%s", record.source, record.job_type, record.id, ) return record.id def list_recent(self, limit: int = 20) -> list[DeadLetterRecord]: with self._lock: return list(reversed(self._rows[-limit:])) class PostgresDeadLetterStore: """PG agent_dead_letter 壳(须 002 迁移)。""" def __init__(self, dsn: str) -> None: assert_safe_database(dsn) self._dsn = dsn def enqueue(self, record: DeadLetterRecord) -> str: did = record.id or str(uuid.uuid4()) with connect_psycopg(self._dsn) as conn: conn.execute( """ INSERT INTO agent_dead_letter ( id, source, source_id, job_type, payload, error, attempts ) VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s) """, ( did, record.source, record.source_id, record.job_type, json.dumps(record.payload, ensure_ascii=False), record.error, record.attempts, ), ) conn.commit() logger.info("dead_letter.pg id=%s source=%s", did, record.source) return did def list_recent(self, limit: int = 20) -> list[DeadLetterRecord]: with connect_psycopg(self._dsn) as conn: rows = conn.execute( """ SELECT id, source, source_id, job_type, payload, error, attempts FROM agent_dead_letter ORDER BY created_at DESC LIMIT %s """, (limit,), ).fetchall() return [ DeadLetterRecord( id=str(r[0]), source=str(r[1]), source_id=str(r[2] or ""), job_type=str(r[3] or ""), payload=dict(r[4] or {}), error=str(r[5] or ""), attempts=int(r[6] or 0), ) for r in rows ] def create_dead_letter_store(*, dsn: str = "", backend: str = "auto"): """工厂。""" if backend == "memory" or not dsn: return MemoryDeadLetterStore() return PostgresDeadLetterStore(dsn) def enqueue_dead_letter( *, source: str, error: str, job_type: str = "", source_id: str = "", payload: Optional[dict[str, Any]] = None, attempts: int = 0, store=None, ) -> str: """ 统一入死信入口(框架)。 store 为空则用内存临时店(调用方应注入进程级单例)。 """ st = store or MemoryDeadLetterStore() return st.enqueue( DeadLetterRecord( id=str(uuid.uuid4()), source=source, source_id=source_id, job_type=job_type, payload=dict(payload or {}), error=error, attempts=attempts, ) )