worker.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. """持久 outbox 至少一次消费;租约用于恢复崩溃任务,业务副作用另做幂等。"""
  2. import asyncio
  3. import logging
  4. from datetime import timedelta
  5. from sqlalchemy import select, update, or_
  6. from app.domain.models import ApprovalRecord, ApprovalRound, AuditRun, KnowledgeChunk, KnowledgeDocument, OutboxEvent, Reimbursement
  7. from app.application.services import alert, get, log
  8. from app.infrastructure.ai import ai
  9. from app.infrastructure.database import Session, transaction
  10. from app.shared.common import BusinessError, utcnow
  11. from app.shared.config import settings
  12. logger = logging.getLogger("audit.worker")
  13. async def index_document(document_id: int, version: int):
  14. """向量先在事务外生成,版本未变化才整批写入;任何失败不写随机向量。"""
  15. async with Session() as session:
  16. document = await get(session, KnowledgeDocument, document_id)
  17. if document.version != version:
  18. return
  19. blocks = (await session.scalars(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document_id, KnowledgeChunk.document_version == version).order_by(KnowledgeChunk.position))).all()
  20. identifiers, texts = [item.id for item in blocks], [item.text for item in blocks]
  21. vectors = []
  22. for offset in range(0, len(texts), 32):
  23. vectors.extend(await ai.embeddings(texts[offset:offset + 32]))
  24. async with transaction() as session:
  25. document = await get(session, KnowledgeDocument, document_id)
  26. if document.version != version:
  27. return
  28. for identifier, vector in zip(identifiers, vectors):
  29. block = await get(session, KnowledgeChunk, identifier)
  30. block.vector_json, block.dimension = vector, settings.embedding_dimension
  31. document.index_status, document.embedding_model = "indexed", settings.embedding_model
  32. log(session, None, "knowledge.indexed", "knowledge", document_id, after={"version": version, "chunks": len(vectors)})
  33. class Worker:
  34. def __init__(self):
  35. self.task = None
  36. self.stop_event = asyncio.Event()
  37. self.last_scan = None
  38. self.current = None
  39. async def start(self):
  40. """重启时本部署只有一个消费者,旧 processing 租约可安全回收。"""
  41. self.stop_event.clear()
  42. async with transaction() as session:
  43. await session.execute(update(OutboxEvent).where(OutboxEvent.status == "processing")
  44. .values(status="pending", lease_until=None, next_attempt_at=utcnow()))
  45. if settings.worker_enabled:
  46. self.task = asyncio.create_task(self.loop())
  47. async def close(self):
  48. """给当前任务短暂收尾机会,超时取消保留租约,下一次启动继续处理。"""
  49. self.stop_event.set()
  50. if self.task:
  51. try:
  52. await asyncio.wait_for(asyncio.shield(self.task), 10)
  53. except asyncio.TimeoutError:
  54. self.task.cancel()
  55. await asyncio.gather(self.task, return_exceptions=True)
  56. async def claim(self):
  57. async with transaction() as session:
  58. now = utcnow()
  59. row = await session.scalar(select(OutboxEvent).where(
  60. or_(OutboxEvent.status == "pending", (OutboxEvent.status == "processing") & (OutboxEvent.lease_until < now)),
  61. OutboxEvent.next_attempt_at <= now).order_by(OutboxEvent.id).limit(1))
  62. if not row:
  63. return None
  64. row.status, row.lease_until = "processing", now + timedelta(minutes=30)
  65. row.attempts += 1
  66. return {"id": row.id, "type": row.event_type, "payload": row.payload_json, "attempts": row.attempts}
  67. async def process_one(self):
  68. """返回是否领取事件,供实际消费者和集成测试共用同一个入口。"""
  69. event = await self.claim()
  70. if not event:
  71. return False
  72. self.current = event["id"]
  73. try:
  74. if event["type"] == "audit":
  75. from app.application.pipeline import run_audit
  76. await run_audit(event["payload"]["audit_id"])
  77. elif event["type"] == "approval":
  78. from app.application.approvals import start_approval
  79. await start_approval(event["payload"]["audit_id"])
  80. elif event["type"] == "index":
  81. await index_document(event["payload"]["document_id"], event["payload"]["version"])
  82. else:
  83. raise BusinessError("未知事件类型", code="EVENT_INVALID")
  84. async with transaction() as session:
  85. row = await get(session, OutboxEvent, event["id"])
  86. row.status, row.lease_until, row.error_code = "done", None, None
  87. except Exception as exc:
  88. code = exc.code if isinstance(exc, BusinessError) else type(exc).__name__
  89. logger.error("event_failed id=%s code=%s", event["id"], code)
  90. exhausted = event["attempts"] >= settings.outbox_attempts
  91. async with transaction() as session:
  92. row = await get(session, OutboxEvent, event["id"])
  93. row.status, row.lease_until, row.error_code = "failed" if exhausted else "pending", None, code
  94. row.next_attempt_at = utcnow() + timedelta(seconds=2 ** event["attempts"])
  95. audit_id = event["payload"].get("audit_id")
  96. audit = await session.get(AuditRun, audit_id) if audit_id else None
  97. if exhausted and audit and event["type"] == "audit":
  98. reimbursement = await get(session, Reimbursement, audit.reimbursement_id)
  99. # 撤回或新轮次已使任务失效时,失败处理也不能覆盖取消的终态。
  100. if reimbursement.revision == audit.revision and reimbursement.status == "pending_audit" and audit.status not in {"completed", "cancelled"}:
  101. audit.status, audit.error_code, audit.finished_at = "failed", code, utcnow()
  102. if exhausted and event["type"] == "index":
  103. document = await get(session, KnowledgeDocument, event["payload"]["document_id"])
  104. if document.version == event["payload"]["version"]:
  105. document.index_status = "failed"
  106. if exhausted:
  107. await alert(session, f"event:{event['id']}", "task_failed", "任务多次失败,错误码:" + code,
  108. "error", audit_id, audit.reimbursement_id if audit else None)
  109. finally:
  110. self.current = None
  111. return True
  112. async def scan_timeouts(self):
  113. """只扫描当前 active 轮次未决记录,退回/驳回后的旧记录不会继续催办。"""
  114. cutoff = utcnow() - timedelta(hours=settings.approval_timeout_hours)
  115. async with transaction() as session:
  116. records = (await session.scalars(select(ApprovalRecord).join(ApprovalRound, ApprovalRecord.round_id == ApprovalRound.id)
  117. .where(ApprovalRound.status == "active", ApprovalRecord.result.is_(None), ApprovalRecord.created_at < cutoff))).all()
  118. for record in records:
  119. round_item = await get(session, ApprovalRound, record.round_id)
  120. await alert(session, f"timeout:{record.id}", "approval_timeout", "审批记录超过配置时限,请指定审批人处理", "warning", round_item.audit_id, round_item.reimbursement_id)
  121. self.last_scan = utcnow()
  122. async def loop(self):
  123. while not self.stop_event.is_set():
  124. try:
  125. if self.last_scan is None or (utcnow() - self.last_scan).total_seconds() >= settings.timeout_scan_seconds:
  126. await self.scan_timeouts()
  127. if await self.process_one():
  128. continue
  129. except Exception:
  130. logger.exception("worker_loop_failed")
  131. try:
  132. await asyncio.wait_for(self.stop_event.wait(), timeout=settings.worker_interval)
  133. except asyncio.TimeoutError:
  134. pass
  135. worker = Worker()