feat: add memory sync outbox worker

This commit is contained in:
张胜宇
2026-09-11 20:07:55 +08:00
parent f167390a8a
commit 6c605ec3c1
2 changed files with 188 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
"""画像投影 Outbox 消费器。
外部存储通过 handler 注入;本模块只负责领取、重试、死信和 MySQL 状态更新。
"""
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.infrastructure.db import SessionFactory
from app.model.memory import MemorySyncOutbox
ProjectionHandler = Callable[[dict[str, Any]], Awaitable[Any]]
MAX_RETRY_COUNT = 5
class MemorySyncOutboxWorker:
"""按目标存储独立消费画像投影事件。"""
def __init__(
self,
handlers: dict[str, ProjectionHandler],
*,
session_factory: Callable[[], AsyncSession] = SessionFactory,
) -> None:
self.handlers = handlers
self.session_factory = session_factory
async def run_once(self, *, target_store: str | None = None) -> bool:
"""领取并处理一条到期事件;没有可处理事件时返回 False。"""
if not self.handlers:
return False
async with self.session_factory() as session:
now = datetime.now(UTC).replace(tzinfo=None)
conditions: list[Any] = [
MemorySyncOutbox.status.in_({"pending", "failed"}),
MemorySyncOutbox.next_retry_at.is_(None)
| (MemorySyncOutbox.next_retry_at <= now),
]
if target_store is not None:
conditions.append(MemorySyncOutbox.target_store == target_store)
event = await session.scalar(
select(MemorySyncOutbox)
.where(*conditions)
.order_by(MemorySyncOutbox.id)
.limit(1)
.with_for_update(skip_locked=True)
)
if event is None:
await session.rollback()
return False
handler = self.handlers.get(event.target_store)
if handler is None:
self._fail(event, "target_handler_not_configured", now, dead=True)
await session.commit()
return True
try:
await handler(event.payload)
except Exception as exc:
self._fail(event, type(exc).__name__, now)
else:
event.status = "processed"
event.processed_at = datetime.now(UTC).replace(tzinfo=None)
event.last_error = None
event.next_retry_at = None
await session.commit()
return True
@staticmethod
def _fail(
event: MemorySyncOutbox,
reason: str,
now: datetime,
*,
dead: bool = False,
) -> None:
"""写入可重试失败或死信状态,不吞掉失败事实。"""
event.retry_count = int(event.retry_count) + 1
event.last_error = reason[:500]
event.status = "dead" if dead or event.retry_count >= MAX_RETRY_COUNT else "failed"
event.next_retry_at = (
None
if event.status == "dead"
else now + timedelta(seconds=min(300, 2 ** int(event.retry_count)))
)