Files
inquiry_robot/inquiry-agent/agent/jobs/dead_letter.py
T

147 lines
4.1 KiB
Python

"""
死信壳:失败任务落库 / 内存,不含自动重放业务。
本文件职责: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,
)
)