test_timeout_hardening.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. """超时硬化 / 有界等待 / 僵尸线程清理 的单测(修永久卡死)。"""
  2. from __future__ import annotations
  3. import threading
  4. import time
  5. import httpx
  6. from content_agent.business_modules.content_discovery import pattern_recall
  7. from content_agent.business_modules.content_discovery.pattern_recall import recall_decision
  8. from content_agent.integrations import (
  9. crawapi_http,
  10. oss_upload,
  11. timeout_config,
  12. video_fetch,
  13. )
  14. from content_agent.integrations.bounded_pool import DaemonThreadPoolExecutor, run_bounded
  15. from content_agent.integrations.runtime_files import LocalRuntimeFileStore
  16. from content_agent import flow_ledger_service as fls
  17. from tests.gemini_helpers import FakeGeminiVideoClient, fake_gemini_pool
  18. # ---------- timeout_config ----------
  19. def test_total_timeout_defaults_match_user_caps():
  20. env = {}
  21. assert timeout_config.total_timeout("oss", env=env) == 300.0
  22. assert timeout_config.total_timeout("video_download", env=env) == 600.0
  23. assert timeout_config.total_timeout("video_llm", env=env) == 600.0
  24. assert timeout_config.total_timeout("crawapi", env=env) == 180.0
  25. assert timeout_config.total_timeout("query_llm", env=env) == 120.0
  26. assert timeout_config.total_timeout("pg", env=env) == 30.0
  27. def test_total_timeout_env_override_and_hard_ceiling():
  28. assert timeout_config.total_timeout("oss", env={"CONTENT_AGENT_OSS_TIMEOUT_SECONDS": "120"}) == 120.0
  29. # env 想配 9999 也被硬上限钳到 600,杜绝再现 3600。
  30. assert timeout_config.total_timeout("oss", env={"CONTENT_AGENT_OSS_TIMEOUT_SECONDS": "9999"}) == 600.0
  31. # 坏值忽略,回默认。
  32. assert timeout_config.total_timeout("oss", env={"CONTENT_AGENT_OSS_TIMEOUT_SECONDS": "abc"}) == 300.0
  33. def test_httpx_timeout_is_segmented_with_short_read():
  34. t = timeout_config.httpx_timeout("video_download", env={})
  35. assert isinstance(t, httpx.Timeout)
  36. assert t.read == 120.0 # read 短,停吐字节即抛
  37. assert t.write == 600.0 # write 承载总时长
  38. assert t.connect == timeout_config.CONNECT_TIMEOUT_SECONDS
  39. video_llm = timeout_config.httpx_timeout("video_llm", env={})
  40. assert video_llm.read == 120.0 # Qwen/Gemini 停吐字节 120s 即抛
  41. def test_as_httpx_timeout_read_capped_by_total():
  42. t = timeout_config.as_httpx_timeout(5.0, read=60.0)
  43. assert t.read == 5.0 # read 不超过总时长
  44. assert t.write == 5.0
  45. # ---------- bounded_pool ----------
  46. def test_run_bounded_results_aligned_by_offset():
  47. items = [1, 2, 3, 4]
  48. out = run_bounded(items, lambda x: x * 10, max_workers=3, per_future_timeout=5.0, on_timeout=lambda i, o: -1)
  49. assert out == [10, 20, 30, 40]
  50. def test_run_bounded_single_timeout_skips_and_does_not_hang():
  51. started = time.monotonic()
  52. def work(x):
  53. if x == "slow":
  54. time.sleep(2.0) # 远超 per_future_timeout;daemon 线程,被放弃
  55. return f"ok:{x}"
  56. out = run_bounded(
  57. ["a", "slow", "b"],
  58. work,
  59. max_workers=3,
  60. per_future_timeout=0.1,
  61. on_timeout=lambda item, offset: f"timeout:{item}",
  62. )
  63. elapsed = time.monotonic() - started
  64. assert out[0] == "ok:a"
  65. assert out[1] == "timeout:slow" # 单条超时记占位
  66. assert out[2] == "ok:b" # 其余正常
  67. assert elapsed < 1.5 # 主线程不被卡死 worker 拖住(不等满 2s)
  68. def test_run_bounded_worker_exception_becomes_placeholder():
  69. def work(x):
  70. if x == "boom":
  71. raise RuntimeError("worker exploded")
  72. return f"ok:{x}"
  73. out = run_bounded(
  74. ["a", "boom"],
  75. work,
  76. max_workers=2,
  77. per_future_timeout=5.0,
  78. on_timeout=lambda item, offset: f"failed:{item}",
  79. )
  80. assert out == ["ok:a", "failed:boom"]
  81. def test_daemon_thread_pool_executor_threads_are_daemon():
  82. with DaemonThreadPoolExecutor(max_workers=1, thread_name_prefix="t") as pool:
  83. is_daemon = pool.submit(lambda: threading.current_thread().daemon).result(timeout=5)
  84. assert is_daemon is True
  85. # ---------- recall_decision: 单条判定超时跳过、run 不中止 ----------
  86. class _SlowForOneClient(FakeGeminiVideoClient):
  87. def __init__(self, slow_id: str, sleep_s: float = 2.0):
  88. super().__init__()
  89. self.slow_id = slow_id
  90. self.sleep_s = sleep_s
  91. def analyze(self, content, media, source_context):
  92. if str(content.get("platform_content_id")) == self.slow_id:
  93. time.sleep(self.sleep_s)
  94. return super().analyze(content, media, source_context)
  95. def test_one_slow_video_judge_times_out_and_run_continues(tmp_path, monkeypatch):
  96. monkeypatch.setattr(recall_decision, "_resolve_max_workers", lambda: 4)
  97. monkeypatch.setattr(recall_decision, "JUDGE_WORKER_RESULT_TIMEOUT_SECONDS", 0.1)
  98. runtime = LocalRuntimeFileStore(tmp_path)
  99. runtime.prepare_run("run_001")
  100. ids = ["content_000", "content_001", "content_002"]
  101. items = [{"platform_content_id": cid, "platform": "douyin"} for cid in ids]
  102. media = [{"platform_content_id": cid} for cid in ids]
  103. bundles = [{"content": {"platform_content_id": cid}} for cid in ids]
  104. started = time.monotonic()
  105. recalled = pattern_recall.run(
  106. "run_001", "policy_run_001", items, media, bundles, {}, runtime,
  107. _SlowForOneClient("content_001", sleep_s=2.0),
  108. )
  109. elapsed = time.monotonic() - started
  110. by_id = {row["platform_content_id"]: row for row in recalled["pattern_recall_evidence"]}
  111. assert by_id["content_001"]["evidence_summary"]["final_status"] == "failed"
  112. assert by_id["content_001"]["evidence_summary"]["failure_type"] == "video_judge_timeout"
  113. assert by_id["content_000"]["evidence_summary"]["final_status"] == "ok"
  114. assert by_id["content_002"]["evidence_summary"]["final_status"] == "ok"
  115. assert elapsed < 1.5 # 不等满那条 2s 的慢 worker
  116. # ---------- flow_ledger 新失败类型展示登记 ----------
  117. def test_flow_ledger_registers_new_timeout_failure_types():
  118. assert fls._technical_retry_stage("video_judge_timeout") == "video_judge"
  119. assert fls._technical_retry_stage("oss_worker_timeout") == "oss" # startswith oss_
  120. assert fls._technical_retry_stage_label("video_judge_timeout") == "视频判定调度"
  121. assert fls._technical_retry_failure_label("video_judge_timeout") == "视频判定调度超时"
  122. assert fls._technical_retry_failure_label("oss_worker_timeout") == "OSS 归档 worker 超时"
  123. assert "超时" in fls._technical_retry_brief_reason("video_judge_timeout", {}, {})
  124. assert "超时" in fls._technical_retry_brief_reason("oss_worker_timeout", {}, {})
  125. # ---------- 各 client 的 httpx.Timeout 真生效(代表性 2 处) ----------
  126. def test_oss_upload_passes_segmented_timeout():
  127. captured = {}
  128. def fake_post(url, *, json, timeout):
  129. captured["timeout"] = timeout
  130. return httpx.Response(200, json={"oss_object": {"cdn_url": "x"}}, request=httpx.Request("POST", url))
  131. oss_upload.upload_video_from_url("http://v/1.mp4", http_post=fake_post)
  132. assert isinstance(captured["timeout"], httpx.Timeout)
  133. assert captured["timeout"].read == timeout_config.read_timeout("oss") # 60
  134. assert captured["timeout"].write == 300.0
  135. def test_crawapi_post_passes_segmented_timeout():
  136. captured = {}
  137. class FakeClient:
  138. def post(self, url, *, json, headers, timeout):
  139. captured["timeout"] = timeout
  140. return httpx.Response(200, json={"code": 0, "data": {}}, request=httpx.Request("POST", url))
  141. crawapi_http.post_crawapi_json(
  142. http_client=FakeClient(),
  143. base_url="https://crawler.example/",
  144. path="search",
  145. payload={},
  146. operation="search",
  147. timeout_seconds=180.0,
  148. business_codes=set(),
  149. )
  150. assert isinstance(captured["timeout"], httpx.Timeout)
  151. assert captured["timeout"].read == timeout_config.read_timeout("crawapi") # 60
  152. assert captured["timeout"].write == 180.0
  153. def test_video_download_default_timeout_lowered():
  154. assert video_fetch.DOWNLOAD_TIMEOUT_SECONDS == 600.0