147 lines
4.1 KiB
Python
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,
|
|
)
|
|
)
|