aigc_outbox.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import logging
  5. from typing import Any
  6. from supply_infra.aigc.client import AigcClient
  7. from supply_infra.db.repositories.pipeline_outbox_repo import PipelineOutboxRepository
  8. from supply_infra.db.session import get_session
  9. from supply_infra.pipeline.contracts import StepContext
  10. from supply_infra.pipeline.dates import china_now
  11. from supply_infra.pipeline.enums import OutboxStatus
  12. from supply_infra.scheduler.jobs.publish_videos_from_discovery import (
  13. prepare_publish_batches,
  14. )
  15. from supply_infra.services.video_discovery_service import (
  16. get_video_discovery_service,
  17. )
  18. logger = logging.getLogger(__name__)
  19. _EFFECT_TYPE = "aigc_publish_batch"
  20. _CLAIMABLE_STATUSES = {
  21. OutboxStatus.PENDING.value,
  22. OutboxStatus.CREATED.value,
  23. OutboxStatus.RETRYABLE_FAILED.value,
  24. }
  25. def _canonical_payload(payload: dict[str, Any]) -> tuple[str, str]:
  26. canonical = json.dumps(
  27. payload,
  28. ensure_ascii=False,
  29. sort_keys=True,
  30. separators=(",", ":"),
  31. default=str,
  32. )
  33. return canonical, hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  34. def _enqueue_batches(
  35. context: StepContext,
  36. batches: list[dict[str, Any]],
  37. ) -> list[str]:
  38. outbox_ids: list[str] = []
  39. with get_session() as session:
  40. repo = PipelineOutboxRepository(session)
  41. for payload in batches:
  42. _, payload_hash = _canonical_payload(payload)
  43. batch_key = str(payload["batch_key"])
  44. mode = "dry_run" if context.dry_run else "live"
  45. idempotency_key = (
  46. f"aigc_publish:{context.biz_dt}:{mode}:{batch_key}"
  47. )
  48. row = repo.enqueue(
  49. run_id=context.run_id,
  50. step_run_id=context.step_run_id,
  51. effect_type=_EFFECT_TYPE,
  52. idempotency_key=idempotency_key,
  53. payload_hash=payload_hash,
  54. payload=payload,
  55. dry_run=context.dry_run,
  56. )
  57. outbox_ids.append(str(row.outbox_id))
  58. return outbox_ids
  59. def _mark_ambiguous(outbox_id: str, error: str) -> dict[str, Any]:
  60. with get_session() as session:
  61. repo = PipelineOutboxRepository(session)
  62. row = repo.get(outbox_id, for_update=True)
  63. if row is None:
  64. raise ValueError(f"outbox event not found: {outbox_id}")
  65. repo.mark_ambiguous(row, error=error)
  66. return {
  67. "success": False,
  68. "outbox_id": outbox_id,
  69. "status": OutboxStatus.AMBIGUOUS.value,
  70. "error_code": "aigc_outbox_ambiguous",
  71. "error": error,
  72. }
  73. def _mark_retryable(
  74. outbox_id: str,
  75. error: str,
  76. response: dict[str, Any] | None = None,
  77. ) -> dict[str, Any]:
  78. with get_session() as session:
  79. repo = PipelineOutboxRepository(session)
  80. row = repo.get(outbox_id, for_update=True)
  81. if row is None:
  82. raise ValueError(f"outbox event not found: {outbox_id}")
  83. repo.mark_retryable_failed(row, error=error, response=response)
  84. return {
  85. "success": False,
  86. "outbox_id": outbox_id,
  87. "status": OutboxStatus.RETRYABLE_FAILED.value,
  88. "error_code": "aigc_outbox_retryable",
  89. "error": error,
  90. }
  91. def dispatch_aigc_outbox_event(
  92. outbox_id: str,
  93. *,
  94. client: AigcClient | None = None,
  95. ) -> dict[str, Any]:
  96. """
  97. Dispatch one durable event.
  98. A stale ``sending`` event is deliberately moved to ``ambiguous`` instead
  99. of being replayed: the remote create request may have succeeded before the
  100. process died. This trades automatic recovery for duplicate-action safety.
  101. """
  102. with get_session() as session:
  103. repo = PipelineOutboxRepository(session)
  104. row = repo.get(outbox_id, for_update=True)
  105. if row is None:
  106. raise ValueError(f"outbox event not found: {outbox_id}")
  107. if row.dry_run:
  108. repo.mark_succeeded(
  109. row,
  110. response={
  111. "dry_run": True,
  112. "external_request_made": False,
  113. "message": "AIGC dispatch intentionally skipped",
  114. },
  115. )
  116. return {
  117. "success": True,
  118. "outbox_id": outbox_id,
  119. "status": OutboxStatus.SUCCEEDED.value,
  120. "dry_run": True,
  121. "external_request_made": False,
  122. }
  123. if row.status == OutboxStatus.SUCCEEDED.value:
  124. return {
  125. "success": True,
  126. "outbox_id": outbox_id,
  127. "status": row.status,
  128. "skipped_already_succeeded": True,
  129. "external_id": row.external_id,
  130. }
  131. if row.status == OutboxStatus.AMBIGUOUS.value:
  132. return {
  133. "success": False,
  134. "outbox_id": outbox_id,
  135. "status": row.status,
  136. "error_code": "aigc_outbox_ambiguous",
  137. "error": row.record_error or "外部请求结果不确定,需要人工核对",
  138. }
  139. if row.status == OutboxStatus.SENDING.value:
  140. repo.mark_ambiguous(
  141. row,
  142. error=(
  143. "检测到未确认的发送中事件;为避免重复创建 AIGC 计划,"
  144. "已停止自动重放,请按确定性计划名人工核对"
  145. ),
  146. )
  147. return {
  148. "success": False,
  149. "outbox_id": outbox_id,
  150. "status": OutboxStatus.AMBIGUOUS.value,
  151. "error_code": "aigc_outbox_ambiguous",
  152. "error": row.record_error,
  153. }
  154. if row.status not in _CLAIMABLE_STATUSES:
  155. return {
  156. "success": False,
  157. "outbox_id": outbox_id,
  158. "status": row.status,
  159. "error_code": "aigc_outbox_not_dispatchable",
  160. "error": f"事件状态不允许发送: {row.status}",
  161. }
  162. payload = dict(row.payload_json or {})
  163. external_id = str(row.external_id or "")
  164. external_name = str(row.external_name or "")
  165. repo.mark_sending(row, now=china_now())
  166. required = {
  167. "aweme_ids",
  168. "candidate_ids",
  169. "plan_name",
  170. "plan_label",
  171. "produce_plan_id",
  172. "publish_plan_id",
  173. }
  174. missing = sorted(required - payload.keys())
  175. if missing:
  176. return _mark_retryable(
  177. outbox_id,
  178. f"Outbox payload 缺少字段: {', '.join(missing)}",
  179. )
  180. active_client = client or AigcClient()
  181. if not external_id:
  182. try:
  183. create_result = active_client.create_video_crawler_plan(
  184. [str(value) for value in payload["aweme_ids"]],
  185. plan_name=str(payload["plan_name"]),
  186. )
  187. except Exception as exc:
  188. logger.exception("AIGC create request outcome is ambiguous: outbox=%s", outbox_id)
  189. return _mark_ambiguous(
  190. outbox_id,
  191. f"创建请求异常,远端是否成功未知: {exc}",
  192. )
  193. if not create_result.get("success"):
  194. return _mark_retryable(
  195. outbox_id,
  196. str(create_result.get("error") or "创建爬取计划失败"),
  197. response=create_result,
  198. )
  199. external_id = str(create_result.get("crawler_plan_id") or "")
  200. external_name = str(
  201. create_result.get("crawler_plan_name") or payload["plan_name"]
  202. )
  203. if not external_id:
  204. return _mark_ambiguous(
  205. outbox_id,
  206. "AIGC 返回成功但未提供 crawler_plan_id,无法安全重试",
  207. )
  208. with get_session() as session:
  209. repo = PipelineOutboxRepository(session)
  210. row = repo.get(outbox_id, for_update=True)
  211. if row is None:
  212. raise ValueError(f"outbox event not found: {outbox_id}")
  213. repo.mark_created(
  214. row,
  215. external_id=external_id,
  216. external_name=external_name,
  217. response=create_result,
  218. )
  219. try:
  220. bind_result = active_client.bind_crawler_to_produce_plan(
  221. external_id,
  222. str(payload["produce_plan_id"]),
  223. crawler_plan_name=external_name or str(payload["plan_name"]),
  224. )
  225. except Exception as exc:
  226. logger.exception("AIGC bind failed after create: outbox=%s", outbox_id)
  227. return _mark_retryable(outbox_id, f"绑定生成计划异常: {exc}")
  228. if not bind_result.get("success"):
  229. return _mark_retryable(
  230. outbox_id,
  231. str(bind_result.get("error") or "绑定生成计划失败"),
  232. response=bind_result,
  233. )
  234. try:
  235. updated = get_video_discovery_service().mark_candidates_aigc_plans(
  236. [int(value) for value in payload["candidate_ids"]],
  237. crawler_plan_id=external_id,
  238. produce_plan_id=str(payload["produce_plan_id"]),
  239. publish_plan_id=str(payload["publish_plan_id"]),
  240. plan_label=str(payload["plan_label"]),
  241. )
  242. except Exception as exc:
  243. logger.exception("Failed to persist AIGC result: outbox=%s", outbox_id)
  244. return _mark_retryable(outbox_id, f"本地保存 AIGC 结果失败: {exc}")
  245. final_response = {
  246. "create": {
  247. "crawler_plan_id": external_id,
  248. "crawler_plan_name": external_name,
  249. },
  250. "bind": bind_result,
  251. "candidate_rows_updated": updated,
  252. }
  253. with get_session() as session:
  254. repo = PipelineOutboxRepository(session)
  255. row = repo.get(outbox_id, for_update=True)
  256. if row is None:
  257. raise ValueError(f"outbox event not found: {outbox_id}")
  258. repo.mark_succeeded(row, response=final_response)
  259. return {
  260. "success": True,
  261. "outbox_id": outbox_id,
  262. "status": OutboxStatus.SUCCEEDED.value,
  263. "external_id": external_id,
  264. "candidate_rows_updated": updated,
  265. }
  266. def execute_aigc_outbox(context: StepContext) -> dict[str, Any]:
  267. prepared = prepare_publish_batches(biz_dt=context.biz_dt)
  268. outbox_ids = _enqueue_batches(context, list(prepared["batches"]))
  269. # A retry can happen after candidate rows were marked but before the event
  270. # was acknowledged. Include all nonterminal events belonging to this run.
  271. with get_session() as session:
  272. for row in PipelineOutboxRepository(session).list_for_run(context.run_id):
  273. if (
  274. row.effect_type == _EFFECT_TYPE
  275. and row.status != OutboxStatus.SUCCEEDED.value
  276. ):
  277. outbox_ids.append(str(row.outbox_id))
  278. ordered_ids = list(dict.fromkeys(outbox_ids))
  279. results = [dispatch_aigc_outbox_event(outbox_id) for outbox_id in ordered_ids]
  280. failures = [result for result in results if result.get("success") is False]
  281. return {
  282. "success": not failures,
  283. "effect_recorded": bool(ordered_ids),
  284. "external_request_made": bool(ordered_ids) and not context.dry_run,
  285. "dry_run": context.dry_run,
  286. "biz_dt": context.biz_dt,
  287. "candidate_count": prepared["candidate_count"],
  288. "batch_count": len(ordered_ids),
  289. "failed_batch_count": len(failures),
  290. "outbox_ids": ordered_ids,
  291. "batches": results,
  292. "error_code": (
  293. str(failures[0].get("error_code") or "aigc_publish_failed")
  294. if failures
  295. else None
  296. ),
  297. "error": (
  298. str(failures[0].get("error") or "AIGC Outbox 发送失败")
  299. if failures
  300. else None
  301. ),
  302. }