test_concurrency_consistency.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. """V3-M5: 并发判定的确定性验收——并发结果必须与串行逐条一致。
  2. 固定 run_id(R9)+ 确定性 fake 下,max_workers=1(串行)与 4(并发)产出的核心产物
  3. 剔除时间戳键后逐条相等;Jittered fake 强制乱序完成以暴露 offset 错位;
  4. analyze 意外异常经 _safe_analyze 兜底不炸 run。
  5. """
  6. from __future__ import annotations
  7. from typing import Any
  8. from content_agent.business_modules.content_discovery import pattern_recall
  9. from content_agent.business_modules.content_discovery.pattern_recall import recall_decision
  10. from content_agent.integrations.runtime_files import LocalRuntimeFileStore
  11. from tests.gemini_helpers import (
  12. FakeGeminiVideoClient,
  13. JitteredFakeGeminiVideoClient,
  14. fake_gemini_pool,
  15. )
  16. from tests.replay_harness import replay_case
  17. _CORE_FILES = [
  18. "discovered_content_items.jsonl",
  19. "pattern_recall_evidence.jsonl",
  20. "rule_decisions.jsonl",
  21. "walk_actions.jsonl",
  22. "source_path_records.jsonl",
  23. ]
  24. _TIMESTAMP_KEYS = {"created_at", "updated_at", "started_at", "ended_at", "duration_ms"}
  25. def _scrub(value: Any) -> Any:
  26. """递归剔除时间戳键(两次 run 的 datetime.now 必然不同,不属确定性合同)。"""
  27. if isinstance(value, dict):
  28. return {k: _scrub(v) for k, v in value.items() if k not in _TIMESTAMP_KEYS}
  29. if isinstance(value, list):
  30. return [_scrub(item) for item in value]
  31. return value
  32. def test_serial_vs_concurrent_replay_identical(tmp_path, monkeypatch):
  33. run_id = "v1_run_m5fixed00001"
  34. artifacts = {}
  35. for label, workers in [("serial", 1), ("concurrent", 4)]:
  36. monkeypatch.setattr(recall_decision, "_resolve_max_workers", lambda workers=workers: workers)
  37. artifacts[label] = replay_case(
  38. "real_id45",
  39. runtime_root=tmp_path / label,
  40. gemini_video_client=FakeGeminiVideoClient(),
  41. run_id=run_id,
  42. )
  43. assert artifacts["serial"].run_id == artifacts["concurrent"].run_id == run_id
  44. for filename in _CORE_FILES:
  45. serial = _scrub(artifacts["serial"].files[filename])
  46. concurrent = _scrub(artifacts["concurrent"].files[filename])
  47. assert serial == concurrent, f"{filename} diverged between serial and concurrent"
  48. def _synthetic_recall_inputs(count: int) -> tuple[list, list, list]:
  49. items = [{"platform_content_id": f"content_{i:03d}", "platform": "douyin"} for i in range(count)]
  50. media = [{"platform_content_id": f"content_{i:03d}"} for i in range(count)]
  51. bundles = [{"content": {"platform_content_id": f"content_{i:03d}"}} for i in range(count)]
  52. return items, media, bundles
  53. def test_jittered_completion_preserves_offset_order(tmp_path):
  54. # 每条内容的预置结果不同;乱序完成后若 offset 错位,判定会张冠李戴。
  55. items, media, bundles = _synthetic_recall_inputs(8)
  56. results = {
  57. item["platform_content_id"]: {**fake_gemini_pool(), "query_relevance_score": 10 * (i + 1)}
  58. for i, item in enumerate(items)
  59. }
  60. runtime = LocalRuntimeFileStore(tmp_path / "rt")
  61. runtime.prepare_run("run_001")
  62. recalled = pattern_recall.run(
  63. "run_001", "policy_run_001", items, media, bundles, {}, runtime,
  64. JitteredFakeGeminiVideoClient(result_by_content_id=results),
  65. )
  66. for i, updated in enumerate(recalled["discovered_content_items"]):
  67. expected = results[updated["platform_content_id"]]["query_relevance_score"]
  68. assert updated["pattern_match_result"]["query_relevance_score"] == expected
  69. assert updated["pattern_match_result"]["pattern_recall_evidence_id"] == f"recall_{i + 1:03d}"
  70. def test_all_items_are_submitted_without_quota_truncation(tmp_path, monkeypatch):
  71. items, media, bundles = _synthetic_recall_inputs(5)
  72. statuses = {}
  73. for label, workers in [("serial", 1), ("concurrent", 4)]:
  74. monkeypatch.setattr(recall_decision, "_resolve_max_workers", lambda workers=workers: workers)
  75. runtime = LocalRuntimeFileStore(tmp_path / label)
  76. runtime.prepare_run("run_001")
  77. client = FakeGeminiVideoClient()
  78. recalled = pattern_recall.run(
  79. "run_001", "policy_run_001", items, media, bundles, {}, runtime, client,
  80. )
  81. statuses[label] = [
  82. (
  83. row["pattern_match_result"]["judge_status"],
  84. row["pattern_match_result"]["query_relevance_reason"],
  85. )
  86. for row in recalled["discovered_content_items"]
  87. ]
  88. assert len(client.calls) == 5
  89. assert statuses["serial"] == statuses["concurrent"]
  90. assert [status for status, _ in statuses["serial"]] == ["ok", "ok", "ok", "ok", "ok"]
  91. def test_analyze_exception_does_not_break_run(tmp_path):
  92. class RaisingGeminiVideoClient(FakeGeminiVideoClient):
  93. def analyze(self, content, media, source_context):
  94. raise RuntimeError("boom")
  95. artifacts = replay_case(
  96. "real_id45",
  97. runtime_root=tmp_path / "rt",
  98. gemini_video_client=RaisingGeminiVideoClient(),
  99. )
  100. # _safe_analyze 兜底:意外异常转 failed 判定,run 本身不崩(与串行 analyze 自吞语义一致)。
  101. assert artifacts.state["status"] == "success"
  102. assert artifacts.files["pattern_recall_evidence.jsonl"]
  103. assert all(
  104. row["evidence_summary"]["final_status"] == "failed"
  105. and row["evidence_summary"]["failure_type"].startswith("analyze_raised")
  106. for row in artifacts.files["pattern_recall_evidence.jsonl"]
  107. )