落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
死信壳:失败任务落库 / 内存,不含自动重放业务。
|
||||
|
||||
本文件职责: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,
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user