test_benchmark_reporting.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862
  1. import importlib
  2. import importlib.util
  3. import json
  4. import multiprocessing
  5. import threading
  6. from concurrent.futures import ThreadPoolExecutor
  7. from datetime import datetime, timezone
  8. from pathlib import Path
  9. import pytest
  10. from pydantic import ValidationError
  11. from agent_lab.application.benchmark import (
  12. BenchmarkCaseId,
  13. BenchmarkConfig,
  14. BenchmarkModelCallTiming,
  15. BenchmarkMode,
  16. BenchmarkRunResult,
  17. )
  18. def _reporting_module():
  19. spec = importlib.util.find_spec(
  20. "agent_lab.infrastructure.benchmark_reporting"
  21. )
  22. assert spec is not None, "benchmark_reporting module must be implemented"
  23. return importlib.import_module(
  24. "agent_lab.infrastructure.benchmark_reporting"
  25. )
  26. def _config(**overrides: object) -> BenchmarkConfig:
  27. payload = {
  28. "schema_version": 1,
  29. "base_url": "https://provider.example/v1",
  30. "model": "benchmark-model",
  31. "runs_per_case": 1,
  32. "cases": [BenchmarkCaseId.ORDINARY_CHAT],
  33. "modes": [BenchmarkMode.DUAL_AGENT],
  34. }
  35. payload.update(overrides)
  36. return BenchmarkConfig.model_validate(payload)
  37. def _run(
  38. *,
  39. case_id: BenchmarkCaseId = BenchmarkCaseId.ORDINARY_CHAT,
  40. mode: BenchmarkMode = BenchmarkMode.DUAL_AGENT,
  41. iteration: int = 1,
  42. status: str = "passed",
  43. initial_provider_ttft_ms: int | None = 10,
  44. visible_ttft_ms: int | None = 20,
  45. turn_wall_time_ms: int | None = 30,
  46. prompt_tokens: int | None = 2,
  47. completion_tokens: int | None = 3,
  48. total_tokens: int | None = 5,
  49. cached_tokens: int | None = 1,
  50. model_call_count: int | None = 1,
  51. fallback_count: int | None = 0,
  52. tool_count: int | None = 0,
  53. tool_latencies_ms: list[int | None] | None = None,
  54. semantic_failures: list[str] | None = None,
  55. error: str | None = None,
  56. model_calls: list[BenchmarkModelCallTiming] | None = None,
  57. ) -> BenchmarkRunResult:
  58. return BenchmarkRunResult(
  59. case_id=case_id,
  60. mode=mode,
  61. iteration=iteration,
  62. status=status,
  63. initial_provider_ttft_ms=initial_provider_ttft_ms,
  64. visible_ttft_ms=visible_ttft_ms,
  65. turn_wall_time_ms=turn_wall_time_ms,
  66. prompt_tokens=prompt_tokens,
  67. completion_tokens=completion_tokens,
  68. total_tokens=total_tokens,
  69. cached_tokens=cached_tokens,
  70. model_call_count=model_call_count,
  71. fallback_count=fallback_count,
  72. tool_count=tool_count,
  73. event_names=[],
  74. batch_event_names=[],
  75. tool_event_names=[],
  76. event_sources=[],
  77. tool_statuses=[],
  78. tool_latencies_ms=tool_latencies_ms or [],
  79. semantic_failures=semantic_failures or [],
  80. error=error,
  81. model_calls=model_calls or [],
  82. )
  83. def _model_call(
  84. call_index: int,
  85. elapsed_ms: int | None,
  86. ) -> BenchmarkModelCallTiming:
  87. return BenchmarkModelCallTiming(
  88. call_index=call_index,
  89. call_kind="chat_completion",
  90. first_item_kind="raw_chunk",
  91. provider_ttft_ms=1,
  92. visible_ttft_ms=2,
  93. elapsed_ms=elapsed_ms,
  94. usage=None,
  95. )
  96. def _marker_report(marker: str):
  97. reporting = _reporting_module()
  98. return reporting.build_benchmark_report(
  99. _config(model=marker),
  100. [_run()],
  101. mock=True,
  102. )
  103. def _process_report_writer(
  104. output_dir: str,
  105. marker: str,
  106. timestamp: str,
  107. first_temp_barrier,
  108. result_queue,
  109. ) -> None:
  110. reporting = _reporting_module()
  111. original_write_text = reporting._write_text
  112. first_write = True
  113. def synchronized_write(path: Path, content: str) -> tuple[int, int]:
  114. nonlocal first_write
  115. identity = original_write_text(path, content)
  116. if first_write:
  117. first_write = False
  118. first_temp_barrier.wait()
  119. return identity
  120. reporting._write_text = synchronized_write
  121. try:
  122. json_path, markdown_path = reporting.write_benchmark_report(
  123. _marker_report(marker),
  124. output_dir,
  125. timestamp=timestamp,
  126. )
  127. except BaseException as exc:
  128. result_queue.put(("error", marker, repr(exc)))
  129. return
  130. result_queue.put(
  131. ("ok", marker, json_path.name, markdown_path.name)
  132. )
  133. def _assert_concurrent_report_pairs(
  134. output_dir: Path,
  135. markers: list[str],
  136. ) -> None:
  137. json_paths = sorted(output_dir.glob("*.json"))
  138. markdown_paths = sorted(output_dir.glob("*.md"))
  139. assert len(json_paths) == len(markers)
  140. assert len(markdown_paths) == len(markers)
  141. assert {path.stem for path in json_paths} == {
  142. path.stem for path in markdown_paths
  143. }
  144. actual_markers = set()
  145. for json_path in json_paths:
  146. marker = json.loads(json_path.read_text())["target"]["model"]
  147. markdown = json_path.with_suffix(".md").read_text()
  148. actual_markers.add(marker)
  149. assert f"- Model: `{marker}`" in markdown
  150. assert actual_markers == set(markers)
  151. assert list(output_dir.glob("*.lock")) == []
  152. assert list(output_dir.glob("*.tmp")) == []
  153. @pytest.mark.parametrize(
  154. ("values", "quantile", "expected"),
  155. [
  156. ([], 0.50, None),
  157. ([7], 0.50, 7.0),
  158. ([7], 0.95, 7.0),
  159. ([1, 3, 5, 7], 0.50, 4.0),
  160. ([1, 3, 5, 7], 0.95, 6.7),
  161. ],
  162. )
  163. def test_percentile_uses_linear_interpolation(
  164. values: list[int], quantile: float, expected: float | None
  165. ):
  166. reporting = _reporting_module()
  167. assert reporting.percentile(values, quantile) == pytest.approx(expected)
  168. def test_report_models_are_strict_and_include_required_summary_fields():
  169. reporting = _reporting_module()
  170. generated_at = datetime(2026, 7, 14, 1, 2, 3, tzinfo=timezone.utc)
  171. report = reporting.build_benchmark_report(
  172. _config(),
  173. [_run()],
  174. mock=True,
  175. generated_at=generated_at,
  176. )
  177. assert isinstance(report, reporting.BenchmarkReport)
  178. assert report.schema_version == 1
  179. assert report.generated_at == generated_at
  180. assert report.target.model_dump() == {
  181. "base_url": "https://provider.example/v1",
  182. "model": "benchmark-model",
  183. "mock": True,
  184. }
  185. assert report.config.model_dump(mode="json") == {
  186. "runs_per_case": 1,
  187. "cases": ["ordinary_chat"],
  188. "modes": ["dual_agent"],
  189. }
  190. assert report.overall.model_dump() == {
  191. "attempted": 1,
  192. "passed": 1,
  193. "failed": 0,
  194. "warnings": report.overall.warnings,
  195. "system_errors": [],
  196. }
  197. with pytest.raises(ValidationError):
  198. reporting.BenchmarkReport.model_validate(
  199. report.model_dump() | {"schema_version": "1"}
  200. )
  201. with pytest.raises(ValidationError):
  202. reporting.BenchmarkAggregate.model_validate(
  203. report.aggregates[0].model_dump() | {"unexpected": True}
  204. )
  205. def test_aggregate_excludes_failed_and_null_samples_from_metrics():
  206. reporting = _reporting_module()
  207. runs = [
  208. _run(
  209. iteration=1,
  210. initial_provider_ttft_ms=10,
  211. visible_ttft_ms=20,
  212. turn_wall_time_ms=30,
  213. prompt_tokens=2,
  214. completion_tokens=3,
  215. total_tokens=5,
  216. cached_tokens=1,
  217. model_call_count=1,
  218. fallback_count=0,
  219. tool_count=2,
  220. ),
  221. _run(
  222. iteration=2,
  223. initial_provider_ttft_ms=None,
  224. visible_ttft_ms=40,
  225. turn_wall_time_ms=None,
  226. prompt_tokens=None,
  227. completion_tokens=7,
  228. total_tokens=7,
  229. cached_tokens=None,
  230. model_call_count=3,
  231. fallback_count=1,
  232. tool_count=None,
  233. ),
  234. _run(
  235. iteration=3,
  236. status="failed",
  237. initial_provider_ttft_ms=999,
  238. visible_ttft_ms=999,
  239. turn_wall_time_ms=999,
  240. prompt_tokens=999,
  241. completion_tokens=999,
  242. total_tokens=999,
  243. cached_tokens=999,
  244. model_call_count=999,
  245. fallback_count=999,
  246. tool_count=999,
  247. error="failed",
  248. ),
  249. ]
  250. aggregate = reporting.build_benchmark_report(
  251. _config(runs_per_case=3), runs, mock=True
  252. ).aggregates[0]
  253. assert (aggregate.attempted, aggregate.passed, aggregate.failed) == (3, 2, 1)
  254. assert aggregate.initial_provider_ttft_ms.model_dump() == {
  255. "count": 1,
  256. "p50": 10.0,
  257. "p95": 10.0,
  258. }
  259. assert aggregate.visible_ttft_ms.model_dump() == {
  260. "count": 2,
  261. "p50": 30.0,
  262. "p95": 39.0,
  263. }
  264. assert aggregate.turn_wall_time_ms.count == 1
  265. assert aggregate.prompt_tokens.model_dump() == {
  266. "count": 1,
  267. "total": 2,
  268. "mean": 2.0,
  269. }
  270. assert aggregate.completion_tokens.model_dump() == {
  271. "count": 2,
  272. "total": 10,
  273. "mean": 5.0,
  274. }
  275. assert aggregate.total_tokens.total == 12
  276. assert aggregate.cached_tokens.total == 1
  277. assert aggregate.model_call_count.model_dump() == {
  278. "count": 2,
  279. "total": 4,
  280. "mean": 2.0,
  281. }
  282. assert aggregate.fallback_count.total == 1
  283. assert aggregate.tool_count.model_dump() == {
  284. "count": 1,
  285. "total": 2,
  286. "mean": 2.0,
  287. }
  288. def test_aggregate_flattens_passed_model_and_tool_latency_samples():
  289. reporting = _reporting_module()
  290. runs = [
  291. _run(
  292. iteration=1,
  293. model_calls=[
  294. _model_call(1, 10),
  295. _model_call(2, None),
  296. _model_call(3, 30),
  297. ],
  298. tool_latencies_ms=[5, None, 15],
  299. ),
  300. _run(
  301. iteration=2,
  302. model_calls=[_model_call(1, 20)],
  303. tool_latencies_ms=[25],
  304. ),
  305. _run(
  306. iteration=3,
  307. status="failed",
  308. model_calls=[_model_call(1, 999)],
  309. tool_latencies_ms=[999],
  310. ),
  311. ]
  312. report = reporting.build_benchmark_report(
  313. _config(runs_per_case=3), runs, mock=True
  314. )
  315. aggregate = report.aggregates[0]
  316. markdown = reporting.render_markdown(report)
  317. assert aggregate.model_elapsed_ms.model_dump() == {
  318. "count": 3,
  319. "p50": 20.0,
  320. "p95": 29.0,
  321. }
  322. assert aggregate.tool_latency_ms.model_dump() == {
  323. "count": 3,
  324. "p50": 15.0,
  325. "p95": 24.0,
  326. }
  327. assert "Model elapsed p50/p95 ms" in markdown
  328. assert "Tool latency p50/p95 ms" in markdown
  329. assert "20.0/29.0" in markdown
  330. assert "15.0/24.0" in markdown
  331. def test_zero_latency_samples_emit_low_confidence_warnings():
  332. reporting = _reporting_module()
  333. aggregate = reporting.build_benchmark_report(
  334. _config(),
  335. [
  336. _run(
  337. initial_provider_ttft_ms=None,
  338. visible_ttft_ms=None,
  339. turn_wall_time_ms=None,
  340. model_calls=[],
  341. tool_latencies_ms=[],
  342. )
  343. ],
  344. mock=True,
  345. ).aggregates[0]
  346. assert {
  347. warning.split(" p95 low confidence:", 1)[0]
  348. for warning in aggregate.warnings
  349. } == {
  350. "ordinary_chat/dual_agent initial_provider_ttft_ms",
  351. "ordinary_chat/dual_agent visible_ttft_ms",
  352. "ordinary_chat/dual_agent turn_wall_time_ms",
  353. "ordinary_chat/dual_agent model_elapsed_ms",
  354. "ordinary_chat/dual_agent tool_latency_ms",
  355. }
  356. assert all("0 samples" in warning for warning in aggregate.warnings)
  357. def test_aggregates_are_stably_sorted_and_warn_for_low_confidence_p95():
  358. reporting = _reporting_module()
  359. runs = [
  360. _run(
  361. case_id=BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS,
  362. mode=BenchmarkMode.DUAL_AGENT,
  363. ),
  364. _run(
  365. case_id=BenchmarkCaseId.ORDINARY_CHAT,
  366. mode=BenchmarkMode.CHAT_AGENT_TOOLS,
  367. ),
  368. _run(
  369. case_id=BenchmarkCaseId.ORDINARY_CHAT,
  370. mode=BenchmarkMode.DUAL_AGENT,
  371. ),
  372. ]
  373. report = reporting.build_benchmark_report(
  374. _config(
  375. cases=[
  376. BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS,
  377. BenchmarkCaseId.ORDINARY_CHAT,
  378. ],
  379. modes=[
  380. BenchmarkMode.CHAT_AGENT_TOOLS,
  381. BenchmarkMode.DUAL_AGENT,
  382. ],
  383. ),
  384. runs,
  385. mock=True,
  386. )
  387. assert [(item.case_id.value, item.mode.value) for item in report.aggregates] == [
  388. ("ordinary_chat", "chat_agent_tools"),
  389. ("ordinary_chat", "dual_agent"),
  390. ("web_search_two_answers", "dual_agent"),
  391. ]
  392. assert all(
  393. any("p95 low confidence" in warning for warning in item.warnings)
  394. for item in report.aggregates
  395. )
  396. assert report.overall.warnings == [
  397. warning
  398. for aggregate in report.aggregates
  399. for warning in aggregate.warnings
  400. ]
  401. def test_errors_are_redacted_before_truncation_in_json_and_markdown():
  402. reporting = _reporting_module()
  403. real_key = "sk-live-secret-value"
  404. leaked_error = (
  405. f"Authorization: Bearer {real_key}; "
  406. f"API-Key={real_key}; "
  407. f"https://example.test/?api_key={real_key}&token={real_key}; "
  408. f'{{"access_token":"{real_key}","key":"{real_key}"}}; '
  409. + "x" * 3000
  410. )
  411. report = reporting.build_benchmark_report(
  412. _config(),
  413. [
  414. _run(
  415. status="failed",
  416. semantic_failures=[f"token={real_key}"],
  417. error=leaked_error,
  418. )
  419. ],
  420. mock=False,
  421. secrets=[real_key],
  422. )
  423. payload = report.model_dump(mode="json")
  424. json_text = reporting.report_to_json(report)
  425. markdown = reporting.render_markdown(report)
  426. assert len(report.runs[0].error) == 2048
  427. assert real_key not in json.dumps(payload)
  428. assert real_key not in json_text
  429. assert real_key not in markdown
  430. assert "[REDACTED]" in json_text
  431. assert "[REDACTED]" in markdown
  432. def test_error_truncation_is_utf8_safe_and_bounded_after_redaction():
  433. reporting = _reporting_module()
  434. real_key = "sk-byte-bound-secret"
  435. error = f"Bearer {real_key} " + "密" * 1000
  436. sanitized = reporting.sanitize_error(error, secrets=[real_key])
  437. redacted = reporting.redact_text(error, secrets=[real_key])
  438. expected = redacted.encode("utf-8")[:2048].decode("utf-8", errors="ignore")
  439. assert sanitized == expected
  440. assert real_key not in sanitized
  441. assert len(sanitized.encode("utf-8")) <= 2048
  442. assert "\ufffd" not in sanitized
  443. def test_configured_secret_is_redacted_from_all_report_string_surfaces():
  444. reporting = _reporting_module()
  445. real_key = "sk-everywhere-secret"
  446. run = _run().model_copy(
  447. update={
  448. "event_names": [real_key],
  449. "event_sources": [f"Bearer {real_key}"],
  450. "tool_statuses": [f"token={real_key}"],
  451. }
  452. )
  453. report = reporting.build_benchmark_report(
  454. _config(
  455. base_url=f"https://provider.example/{real_key}",
  456. model=f"model-{real_key}",
  457. ),
  458. [run],
  459. mock=False,
  460. secrets=[real_key],
  461. )
  462. rendered = reporting.report_to_json(report) + reporting.render_markdown(report)
  463. assert real_key not in rendered
  464. assert "[REDACTED]" in rendered
  465. def test_system_errors_are_typed_redacted_counted_and_rendered():
  466. reporting = _reporting_module()
  467. real_key = "sk-runner-system-secret"
  468. report = reporting.build_benchmark_report(
  469. _config(),
  470. [],
  471. mock=False,
  472. secrets=[real_key],
  473. system_errors=[f"Authorization: Bearer {real_key}"],
  474. )
  475. payload = json.loads(reporting.report_to_json(report))
  476. markdown = reporting.render_markdown(report)
  477. assert report.overall.model_dump() == {
  478. "attempted": 1,
  479. "passed": 0,
  480. "failed": 1,
  481. "warnings": [],
  482. "system_errors": ["Authorization: [REDACTED] [REDACTED]"],
  483. }
  484. assert payload["overall"]["system_errors"] == report.overall.system_errors
  485. assert "## System Errors" in markdown
  486. assert "Authorization: [REDACTED] [REDACTED]" in markdown
  487. assert real_key not in reporting.report_to_json(report)
  488. assert real_key not in markdown
  489. def test_markdown_is_rendered_from_the_same_json_facts():
  490. reporting = _reporting_module()
  491. report = reporting.build_benchmark_report(
  492. _config(runs_per_case=2),
  493. [
  494. _run(iteration=1, visible_ttft_ms=10, total_tokens=5),
  495. _run(iteration=2, visible_ttft_ms=30, total_tokens=9),
  496. ],
  497. mock=True,
  498. generated_at=datetime(2026, 7, 14, tzinfo=timezone.utc),
  499. )
  500. payload = json.loads(reporting.report_to_json(report))
  501. markdown = reporting.render_markdown(report)
  502. aggregate = payload["aggregates"][0]
  503. assert str(aggregate["visible_ttft_ms"]["p50"]) in markdown
  504. assert str(aggregate["visible_ttft_ms"]["p95"]) in markdown
  505. assert str(aggregate["total_tokens"]["total"]) in markdown
  506. assert str(aggregate["total_tokens"]["mean"]) in markdown
  507. assert payload == report.model_dump(mode="json")
  508. def test_atomic_write_creates_matching_unique_json_and_markdown_paths(tmp_path: Path):
  509. reporting = _reporting_module()
  510. report = reporting.build_benchmark_report(
  511. _config(), [_run()], mock=True
  512. )
  513. timestamp = "20260714T010203Z"
  514. (tmp_path / f"{timestamp}.json").write_text("existing")
  515. (tmp_path / f"{timestamp}.md").write_text("existing")
  516. json_path, markdown_path = reporting.write_benchmark_report(
  517. report,
  518. tmp_path,
  519. timestamp=timestamp,
  520. )
  521. assert json_path.name == f"{timestamp}-1.json"
  522. assert markdown_path.name == f"{timestamp}-1.md"
  523. assert json.loads(json_path.read_text()) == report.model_dump(mode="json")
  524. assert markdown_path.read_text() == reporting.render_markdown(report)
  525. assert list(tmp_path.glob("*.tmp")) == []
  526. def test_second_placeholder_collision_cleans_first_and_uses_suffix(
  527. tmp_path: Path,
  528. ):
  529. reporting = _reporting_module()
  530. timestamp = "20260714T011223Z"
  531. external_markdown = tmp_path / f"{timestamp}.md"
  532. external_markdown.write_text("external markdown")
  533. json_path, markdown_path = reporting.write_benchmark_report(
  534. _marker_report("second-placeholder-collision"),
  535. tmp_path,
  536. timestamp=timestamp,
  537. )
  538. assert external_markdown.read_text() == "external markdown"
  539. assert not (tmp_path / f"{timestamp}.json").exists()
  540. assert json_path.name == f"{timestamp}-1.json"
  541. assert markdown_path.name == f"{timestamp}-1.md"
  542. assert list(tmp_path.glob("*.lock")) == []
  543. def test_atomic_write_removes_temps_and_published_half_on_replace_failure(
  544. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  545. ):
  546. reporting = _reporting_module()
  547. report = reporting.build_benchmark_report(
  548. _config(), [_run()], mock=True
  549. )
  550. real_replace = reporting.os.replace
  551. calls = 0
  552. def fail_second_replace(source: Path, destination: Path) -> None:
  553. nonlocal calls
  554. calls += 1
  555. if calls == 2:
  556. raise OSError("simulated markdown publish failure")
  557. real_replace(source, destination)
  558. monkeypatch.setattr(reporting.os, "replace", fail_second_replace)
  559. with pytest.raises(
  560. reporting.BenchmarkReportWriteError,
  561. match="failed to write benchmark reports",
  562. ):
  563. reporting.write_benchmark_report(
  564. report,
  565. tmp_path,
  566. timestamp="20260714T010203Z",
  567. )
  568. assert list(tmp_path.iterdir()) == []
  569. def test_temp_fsync_failure_removes_owned_temp_and_placeholders(
  570. tmp_path: Path,
  571. monkeypatch: pytest.MonkeyPatch,
  572. ):
  573. reporting = _reporting_module()
  574. def failing_fsync(file_descriptor: int) -> None:
  575. del file_descriptor
  576. raise OSError("simulated temp fsync failure")
  577. monkeypatch.setattr(reporting.os, "fsync", failing_fsync)
  578. with pytest.raises(
  579. reporting.BenchmarkReportWriteError,
  580. match="failed to write benchmark reports",
  581. ):
  582. reporting.write_benchmark_report(
  583. _marker_report("temp-fsync-failure"),
  584. tmp_path,
  585. timestamp="20260714T012334Z",
  586. )
  587. assert list(tmp_path.iterdir()) == []
  588. def test_second_placeholder_failure_removes_first_placeholder(
  589. tmp_path: Path,
  590. monkeypatch: pytest.MonkeyPatch,
  591. ):
  592. reporting = _reporting_module()
  593. original_open = reporting.os.open
  594. timestamp = "20260714T040506Z"
  595. markdown_path = tmp_path / f"{timestamp}.md"
  596. def failing_open(path, flags, mode=0o777):
  597. if Path(path) == markdown_path:
  598. raise OSError("simulated second placeholder failure")
  599. return original_open(path, flags, mode)
  600. monkeypatch.setattr(reporting.os, "open", failing_open)
  601. with pytest.raises(
  602. reporting.BenchmarkReportWriteError,
  603. match="failed to write benchmark reports",
  604. ):
  605. reporting.write_benchmark_report(
  606. _marker_report("reservation-failure"),
  607. tmp_path,
  608. timestamp=timestamp,
  609. )
  610. assert list(tmp_path.iterdir()) == []
  611. def test_reservation_uses_final_placeholders_without_sidecar_lock(
  612. tmp_path: Path,
  613. monkeypatch: pytest.MonkeyPatch,
  614. ):
  615. reporting = _reporting_module()
  616. timestamp = "20260714T050607Z"
  617. first_temp_written = threading.Event()
  618. allow_publish = threading.Event()
  619. original_write_text = reporting._write_text
  620. first_write = True
  621. def paused_write(path: Path, content: str) -> tuple[int, int]:
  622. nonlocal first_write
  623. identity = original_write_text(path, content)
  624. if first_write:
  625. first_write = False
  626. first_temp_written.set()
  627. assert allow_publish.wait(timeout=10)
  628. return identity
  629. monkeypatch.setattr(reporting, "_write_text", paused_write)
  630. with ThreadPoolExecutor(max_workers=1) as executor:
  631. future = executor.submit(
  632. reporting.write_benchmark_report,
  633. _marker_report("placeholder-owner"),
  634. tmp_path,
  635. timestamp=timestamp,
  636. )
  637. assert first_temp_written.wait(timeout=10)
  638. json_placeholder = tmp_path / f"{timestamp}.json"
  639. markdown_placeholder = tmp_path / f"{timestamp}.md"
  640. try:
  641. assert json_placeholder.exists()
  642. assert markdown_placeholder.exists()
  643. assert json_placeholder.read_bytes() == b""
  644. assert markdown_placeholder.read_bytes() == b""
  645. assert list(tmp_path.glob("*.lock")) == []
  646. finally:
  647. allow_publish.set()
  648. json_path, markdown_path = future.result(timeout=10)
  649. assert json_path == json_placeholder
  650. assert markdown_path == markdown_placeholder
  651. @pytest.mark.parametrize("competed_suffix", [".json", ".md"])
  652. def test_external_replacement_of_reserved_final_is_not_overwritten_or_deleted(
  653. tmp_path: Path,
  654. monkeypatch: pytest.MonkeyPatch,
  655. competed_suffix: str,
  656. ):
  657. reporting = _reporting_module()
  658. timestamp = "20260714T060708Z"
  659. first_temp_written = threading.Event()
  660. allow_publish = threading.Event()
  661. original_write_text = reporting._write_text
  662. first_write = True
  663. def paused_write(path: Path, content: str) -> tuple[int, int]:
  664. nonlocal first_write
  665. identity = original_write_text(path, content)
  666. if first_write:
  667. first_write = False
  668. first_temp_written.set()
  669. assert allow_publish.wait(timeout=10)
  670. return identity
  671. monkeypatch.setattr(reporting, "_write_text", paused_write)
  672. competed_path = tmp_path / f"{timestamp}{competed_suffix}"
  673. competitor_temp = tmp_path / f"competitor{competed_suffix}"
  674. competitor_content = b"external competitor content"
  675. with ThreadPoolExecutor(max_workers=1) as executor:
  676. future = executor.submit(
  677. reporting.write_benchmark_report,
  678. _marker_report("must-not-overwrite"),
  679. tmp_path,
  680. timestamp=timestamp,
  681. )
  682. assert first_temp_written.wait(timeout=10)
  683. competitor_temp.write_bytes(competitor_content)
  684. reporting.os.replace(competitor_temp, competed_path)
  685. allow_publish.set()
  686. with pytest.raises(
  687. reporting.BenchmarkReportWriteError,
  688. match="failed to write benchmark reports",
  689. ):
  690. future.result(timeout=10)
  691. assert competed_path.read_bytes() == competitor_content
  692. other_suffix = ".md" if competed_suffix == ".json" else ".json"
  693. assert not (tmp_path / f"{timestamp}{other_suffix}").exists()
  694. assert list(tmp_path.glob("*.lock")) == []
  695. assert list(tmp_path.glob("*.tmp")) == []
  696. def test_concurrent_thread_writers_reserve_distinct_matching_pairs(
  697. tmp_path: Path,
  698. monkeypatch: pytest.MonkeyPatch,
  699. ):
  700. reporting = _reporting_module()
  701. writer_count = 8
  702. markers = [f"thread-writer-{index}" for index in range(writer_count)]
  703. first_temp_barrier = threading.Barrier(writer_count, timeout=15)
  704. original_write_text = reporting._write_text
  705. thread_state = threading.local()
  706. def synchronized_write(path: Path, content: str) -> tuple[int, int]:
  707. identity = original_write_text(path, content)
  708. if not getattr(thread_state, "first_write_done", False):
  709. thread_state.first_write_done = True
  710. first_temp_barrier.wait()
  711. return identity
  712. monkeypatch.setattr(reporting, "_write_text", synchronized_write)
  713. with ThreadPoolExecutor(max_workers=writer_count) as executor:
  714. results = list(
  715. executor.map(
  716. lambda marker: reporting.write_benchmark_report(
  717. _marker_report(marker),
  718. tmp_path,
  719. timestamp="20260714T020304Z",
  720. ),
  721. markers,
  722. )
  723. )
  724. assert len({json_path.stem for json_path, _ in results}) == writer_count
  725. _assert_concurrent_report_pairs(tmp_path, markers)
  726. def test_concurrent_process_writers_reserve_distinct_matching_pairs(
  727. tmp_path: Path,
  728. ):
  729. context = multiprocessing.get_context("spawn")
  730. writer_count = 4
  731. markers = [f"process-writer-{index}" for index in range(writer_count)]
  732. first_temp_barrier = context.Barrier(writer_count, timeout=20)
  733. result_queue = context.Queue()
  734. processes = [
  735. context.Process(
  736. target=_process_report_writer,
  737. args=(
  738. str(tmp_path),
  739. marker,
  740. "20260714T030405Z",
  741. first_temp_barrier,
  742. result_queue,
  743. ),
  744. )
  745. for marker in markers
  746. ]
  747. for process in processes:
  748. process.start()
  749. results = [result_queue.get(timeout=30) for _ in processes]
  750. for process in processes:
  751. process.join(timeout=30)
  752. assert all(process.exitcode == 0 for process in processes)
  753. assert all(result[0] == "ok" for result in results), results
  754. assert len({result[2][:-5] for result in results}) == writer_count
  755. _assert_concurrent_report_pairs(tmp_path, markers)