test_aigc_outbox.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. from __future__ import annotations
  2. from contextlib import contextmanager
  3. from unittest.mock import MagicMock
  4. from sqlalchemy import create_engine, select
  5. from sqlalchemy.orm import sessionmaker
  6. from supply_infra.db.base import Base
  7. from supply_infra.db.models.pipeline_outbox import PipelineOutbox
  8. from supply_infra.db.models.pipeline_run import PipelineRun
  9. from supply_infra.db.models.pipeline_step_run import PipelineStepRun
  10. from supply_infra.pipeline import aigc_outbox
  11. from supply_infra.pipeline.contracts import StepContext
  12. from supply_infra.pipeline.enums import OutboxStatus
  13. def _payload() -> dict:
  14. return {
  15. "biz_dt": "20260730",
  16. "source_run_id": None,
  17. "plan_label": "plan-a",
  18. "produce_plan_id": "produce-1",
  19. "publish_plan_id": "publish-1",
  20. "aweme_ids": ["aweme-1"],
  21. "candidate_ids": [101],
  22. "plan_name": "【SupplyAgent】plan-a-20260730-stable",
  23. "batch_key": "a" * 64,
  24. }
  25. def _context(*, dry_run: bool = False) -> StepContext:
  26. return StepContext(
  27. run_id="run-1",
  28. step_run_id="step-1",
  29. step_key="aigc_write_record",
  30. biz_dt="20260730",
  31. date_snapshot={},
  32. config_snapshot={},
  33. input_snapshot={},
  34. dry_run=dry_run,
  35. )
  36. def _database(monkeypatch):
  37. engine = create_engine("sqlite+pysqlite:///:memory:")
  38. Base.metadata.create_all(
  39. engine,
  40. tables=[
  41. PipelineRun.__table__,
  42. PipelineStepRun.__table__,
  43. PipelineOutbox.__table__,
  44. ],
  45. )
  46. session_factory = sessionmaker(bind=engine, expire_on_commit=False)
  47. @contextmanager
  48. def get_test_session():
  49. with session_factory() as session:
  50. try:
  51. yield session
  52. session.commit()
  53. except Exception:
  54. session.rollback()
  55. raise
  56. monkeypatch.setattr(aigc_outbox, "get_session", get_test_session)
  57. return session_factory
  58. def _prepared() -> dict:
  59. return {
  60. "biz_dt": "20260730",
  61. "run_id": None,
  62. "plan_count": 1,
  63. "candidate_count": 1,
  64. "decision_buckets": ["primary"],
  65. "distribution": [],
  66. "batches": [_payload()],
  67. }
  68. def test_event_is_committed_before_external_call_and_replay_is_idempotent(
  69. monkeypatch,
  70. ) -> None:
  71. session_factory = _database(monkeypatch)
  72. monkeypatch.setattr(aigc_outbox, "prepare_publish_batches", lambda **_: _prepared())
  73. service = MagicMock()
  74. service.mark_candidates_aigc_plans.return_value = 1
  75. monkeypatch.setattr(aigc_outbox, "get_video_discovery_service", lambda: service)
  76. calls = {"create": 0}
  77. class Client:
  78. def create_video_crawler_plan(self, _aweme_ids, *, plan_name):
  79. calls["create"] += 1
  80. with session_factory() as session:
  81. row = session.scalar(select(PipelineOutbox))
  82. assert row is not None
  83. assert row.status == OutboxStatus.SENDING.value
  84. assert row.payload_json["plan_name"] == plan_name
  85. return {
  86. "success": True,
  87. "crawler_plan_id": "crawler-1",
  88. "crawler_plan_name": plan_name,
  89. }
  90. def bind_crawler_to_produce_plan(self, *_args, **_kwargs):
  91. return {"success": True}
  92. monkeypatch.setattr(aigc_outbox, "AigcClient", Client)
  93. first = aigc_outbox.execute_aigc_outbox(_context())
  94. second = aigc_outbox.execute_aigc_outbox(_context())
  95. assert first["success"] is True
  96. assert second["success"] is True
  97. assert calls["create"] == 1
  98. with session_factory() as session:
  99. row = session.scalar(select(PipelineOutbox))
  100. assert row is not None
  101. assert row.status == OutboxStatus.SUCCEEDED.value
  102. assert row.external_id == "crawler-1"
  103. assert row.attempt_count == 1
  104. def test_dry_run_records_batches_without_calling_aigc(monkeypatch) -> None:
  105. session_factory = _database(monkeypatch)
  106. monkeypatch.setattr(
  107. aigc_outbox,
  108. "prepare_publish_batches",
  109. lambda **_: _prepared(),
  110. )
  111. client = MagicMock(side_effect=AssertionError("AIGC must not be called"))
  112. monkeypatch.setattr(aigc_outbox, "AigcClient", client)
  113. result = aigc_outbox.execute_aigc_outbox(_context(dry_run=True))
  114. assert result["success"] is True
  115. assert result["dry_run"] is True
  116. assert result["effect_recorded"] is True
  117. assert result["external_request_made"] is False
  118. client.assert_not_called()
  119. with session_factory() as session:
  120. row = session.scalar(select(PipelineOutbox))
  121. assert row is not None
  122. assert row.dry_run is True
  123. assert row.status == OutboxStatus.SUCCEEDED.value
  124. assert row.attempt_count == 0
  125. assert row.external_id is None
  126. assert row.response_json["external_request_made"] is False
  127. def test_timeout_becomes_ambiguous_and_is_not_automatically_replayed(
  128. monkeypatch,
  129. ) -> None:
  130. session_factory = _database(monkeypatch)
  131. service = MagicMock()
  132. monkeypatch.setattr(aigc_outbox, "get_video_discovery_service", lambda: service)
  133. event_id = aigc_outbox._enqueue_batches(_context(), [_payload()])[0]
  134. class TimeoutClient:
  135. calls = 0
  136. def create_video_crawler_plan(self, *_args, **_kwargs):
  137. self.calls += 1
  138. raise TimeoutError("remote timeout")
  139. client = TimeoutClient()
  140. first = aigc_outbox.dispatch_aigc_outbox_event(event_id, client=client)
  141. second = aigc_outbox.dispatch_aigc_outbox_event(event_id, client=client)
  142. assert first["status"] == OutboxStatus.AMBIGUOUS.value
  143. assert second["status"] == OutboxStatus.AMBIGUOUS.value
  144. assert client.calls == 1
  145. service.mark_candidates_aigc_plans.assert_not_called()
  146. with session_factory() as session:
  147. row = session.get(PipelineOutbox, event_id)
  148. assert row is not None
  149. assert row.status == OutboxStatus.AMBIGUOUS.value
  150. def test_bind_retry_reuses_persisted_external_plan(monkeypatch) -> None:
  151. session_factory = _database(monkeypatch)
  152. service = MagicMock()
  153. service.mark_candidates_aigc_plans.return_value = 1
  154. monkeypatch.setattr(aigc_outbox, "get_video_discovery_service", lambda: service)
  155. event_id = aigc_outbox._enqueue_batches(_context(), [_payload()])[0]
  156. first_client = MagicMock()
  157. first_client.create_video_crawler_plan.return_value = {
  158. "success": True,
  159. "crawler_plan_id": "crawler-1",
  160. "crawler_plan_name": "stable-name",
  161. }
  162. first_client.bind_crawler_to_produce_plan.side_effect = TimeoutError("bind timeout")
  163. first = aigc_outbox.dispatch_aigc_outbox_event(event_id, client=first_client)
  164. assert first["status"] == OutboxStatus.RETRYABLE_FAILED.value
  165. retry_client = MagicMock()
  166. retry_client.bind_crawler_to_produce_plan.return_value = {"success": True}
  167. second = aigc_outbox.dispatch_aigc_outbox_event(event_id, client=retry_client)
  168. assert second["success"] is True
  169. retry_client.create_video_crawler_plan.assert_not_called()
  170. retry_client.bind_crawler_to_produce_plan.assert_called_once()
  171. with session_factory() as session:
  172. row = session.get(PipelineOutbox, event_id)
  173. assert row is not None
  174. assert row.status == OutboxStatus.SUCCEEDED.value
  175. assert row.external_id == "crawler-1"
  176. assert row.attempt_count == 2