test_event_batch.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. from __future__ import annotations
  2. import asyncio
  3. import threading
  4. from collections.abc import Callable
  5. from typing import Any
  6. import pytest
  7. import agent_lab.application.events as events_module
  8. from agent_lab.application.events import (
  9. EventDefinition,
  10. EventKernel,
  11. EventRegistry,
  12. EventRequest,
  13. EventSource,
  14. EventStatus,
  15. )
  16. from agent_lab.application.tools import ToolDefinition, ToolRegistry
  17. from agent_lab.domain.events import ToolCallEvent
  18. def _executor_type():
  19. executor_type = getattr(events_module, "EventBatchExecutor", None)
  20. assert executor_type is not None, "EventBatchExecutor is not implemented"
  21. return executor_type
  22. def _definition(
  23. name: str,
  24. handler: Callable[[EventRequest], Any],
  25. *,
  26. conflict_keys: tuple[str, ...] = (),
  27. timeout_seconds: float | None = None,
  28. terminal: bool = False,
  29. normalizer: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
  30. ) -> EventDefinition:
  31. return EventDefinition(
  32. name=name,
  33. description=f"Execute {name}.",
  34. parameters={"type": "object"},
  35. handler=handler,
  36. conflict_keys=conflict_keys,
  37. timeout_seconds=timeout_seconds,
  38. terminal=terminal,
  39. normalizer=normalizer,
  40. )
  41. def _request(event_id: str, name: str, arguments: dict[str, Any] | None = None):
  42. return EventRequest(
  43. id=event_id,
  44. name=name,
  45. arguments=arguments or {},
  46. )
  47. def _executor(
  48. definitions: list[EventDefinition],
  49. *,
  50. max_parallel_events: int = 4,
  51. batch_timeout_seconds: float = 1.0,
  52. **options: Any,
  53. ):
  54. return _executor_type()(
  55. EventKernel(EventRegistry(definitions)),
  56. max_parallel_events=max_parallel_events,
  57. batch_timeout_seconds=batch_timeout_seconds,
  58. **options,
  59. )
  60. @pytest.mark.asyncio
  61. async def test_batch_executor_bounds_global_parallelism():
  62. active = 0
  63. max_active = 0
  64. async def handler(request: EventRequest) -> dict[str, Any]:
  65. nonlocal active, max_active
  66. active += 1
  67. max_active = max(max_active, active)
  68. await asyncio.sleep(0.02)
  69. active -= 1
  70. return {"event_id": request.id}
  71. executor = _executor(
  72. [_definition("example.work", handler)],
  73. max_parallel_events=2,
  74. )
  75. batch = await executor.execute(
  76. [
  77. _request(str(index), "example.work", {"index": index})
  78. for index in range(4)
  79. ],
  80. scope="session-1:turn-1",
  81. )
  82. assert max_active == 2
  83. assert [result.status for result in batch.results] == [
  84. EventStatus.SUCCESS,
  85. ] * 4
  86. @pytest.mark.asyncio
  87. async def test_batch_executor_preserves_input_order_when_completion_order_differs():
  88. release_first = asyncio.Event()
  89. second_finished = asyncio.Event()
  90. async def handler(request: EventRequest) -> dict[str, Any]:
  91. if request.id == "first":
  92. await release_first.wait()
  93. else:
  94. second_finished.set()
  95. return {"event_id": request.id}
  96. executor = _executor([_definition("example.work", handler)])
  97. task = asyncio.create_task(
  98. executor.execute(
  99. [
  100. _request("first", "example.work", {"order": 1}),
  101. _request("second", "example.work", {"order": 2}),
  102. ],
  103. scope="session-1:turn-1",
  104. )
  105. )
  106. await asyncio.wait_for(second_finished.wait(), timeout=0.2)
  107. release_first.set()
  108. batch = await asyncio.wait_for(task, timeout=0.2)
  109. assert [result.event_id for result in batch.results] == ["first", "second"]
  110. @pytest.mark.asyncio
  111. async def test_batch_executor_serializes_shared_conflict_keys():
  112. active = 0
  113. max_active = 0
  114. async def handler(request: EventRequest) -> dict[str, Any]:
  115. nonlocal active, max_active
  116. active += 1
  117. max_active = max(max_active, active)
  118. await asyncio.sleep(0.02)
  119. active -= 1
  120. return {"event_id": request.id}
  121. executor = _executor(
  122. [
  123. _definition(
  124. "example.write",
  125. handler,
  126. conflict_keys=("shared-resource",),
  127. )
  128. ]
  129. )
  130. await executor.execute(
  131. [
  132. _request("first", "example.write", {"order": 1}),
  133. _request("second", "example.write", {"order": 2}),
  134. ],
  135. scope="session-1:turn-1",
  136. )
  137. assert max_active == 1
  138. @pytest.mark.asyncio
  139. async def test_batch_executor_isolates_per_event_timeout_from_successful_sibling():
  140. async def slow_handler(request: EventRequest) -> dict[str, Any]:
  141. await asyncio.sleep(1)
  142. return {"event_id": request.id}
  143. executor = _executor(
  144. [
  145. _definition(
  146. "example.slow",
  147. slow_handler,
  148. timeout_seconds=0.01,
  149. ),
  150. _definition(
  151. "example.fast",
  152. lambda request: {"event_id": request.id},
  153. ),
  154. ],
  155. batch_timeout_seconds=0.2,
  156. )
  157. batch = await executor.execute(
  158. [
  159. _request("slow", "example.slow"),
  160. _request("fast", "example.fast"),
  161. ],
  162. scope="session-1:turn-1",
  163. )
  164. assert [result.status for result in batch.results] == [
  165. EventStatus.TIMEOUT,
  166. EventStatus.SUCCESS,
  167. ]
  168. assert batch.deadline_exceeded is False
  169. @pytest.mark.asyncio
  170. async def test_batch_executor_applies_overall_deadline_to_waiting_work():
  171. async def blocked_handler(request: EventRequest) -> dict[str, Any]:
  172. await asyncio.Event().wait()
  173. return {"event_id": request.id}
  174. executor = _executor(
  175. [_definition("example.blocked", blocked_handler)],
  176. max_parallel_events=1,
  177. batch_timeout_seconds=0.02,
  178. )
  179. batch = await executor.execute(
  180. [
  181. _request("first", "example.blocked"),
  182. _request("second", "example.blocked"),
  183. ],
  184. scope="session-1:turn-1",
  185. )
  186. assert [result.status for result in batch.results] == [
  187. EventStatus.TIMEOUT,
  188. EventStatus.TIMEOUT,
  189. ]
  190. assert batch.deadline_exceeded is True
  191. @pytest.mark.asyncio
  192. async def test_batch_executor_coalesces_exact_duplicates_inside_one_batch():
  193. calls = 0
  194. def handler(request: EventRequest) -> dict[str, Any]:
  195. nonlocal calls
  196. calls += 1
  197. return {"event_id": request.id, "arguments": request.arguments}
  198. executor = _executor([_definition("example.write", handler)])
  199. batch = await executor.execute(
  200. [
  201. _request("same", "example.write", {"a": 1, "b": 2}),
  202. _request("same", "example.write", {"b": 2, "a": 1}),
  203. ],
  204. scope="session-1:turn-1",
  205. )
  206. assert calls == 1
  207. assert batch.coalesced_count == 1
  208. assert batch.results[0] == batch.results[1]
  209. @pytest.mark.asyncio
  210. async def test_batch_executor_replay_key_includes_scope_event_id_and_request():
  211. calls = 0
  212. def handler(request: EventRequest) -> dict[str, Any]:
  213. nonlocal calls
  214. calls += 1
  215. return {"call": calls, "arguments": request.arguments}
  216. executor = _executor([_definition("example.write", handler)])
  217. original = _request("same", "example.write", {"a": 1, "b": 2})
  218. canonical_replay = _request(
  219. "same",
  220. "example.write",
  221. {"b": 2, "a": 1},
  222. )
  223. first = await executor.execute([original], scope="session-1:turn-1")
  224. replay = await executor.execute(
  225. [canonical_replay],
  226. scope="session-1:turn-1",
  227. )
  228. changed_request = await executor.execute(
  229. [_request("same", "example.write", {"a": 2})],
  230. scope="session-1:turn-1",
  231. )
  232. changed_id = await executor.execute(
  233. [_request("other", "example.write", {"a": 1, "b": 2})],
  234. scope="session-1:turn-1",
  235. )
  236. changed_scope = await executor.execute(
  237. [original],
  238. scope="session-1:turn-2",
  239. )
  240. assert first.results[0] == replay.results[0]
  241. assert replay.replayed_count == 1
  242. assert changed_request.replayed_count == 0
  243. assert changed_id.replayed_count == 0
  244. assert changed_scope.replayed_count == 0
  245. assert calls == 4
  246. @pytest.mark.asyncio
  247. async def test_batch_executor_runs_terminal_events_after_all_nonterminal_results():
  248. observed: list[str] = []
  249. async def nonterminal(request: EventRequest) -> dict[str, Any]:
  250. observed.append(f"start:{request.id}")
  251. await asyncio.sleep(0.01)
  252. observed.append(f"finish:{request.id}")
  253. return {"event_id": request.id}
  254. def terminal(request: EventRequest) -> dict[str, Any]:
  255. observed.append(f"terminal:{request.id}")
  256. return {"event_id": request.id}
  257. executor = _executor(
  258. [
  259. _definition("example.work", nonterminal),
  260. _definition("example.terminate", terminal, terminal=True),
  261. ]
  262. )
  263. batch = await executor.execute(
  264. [
  265. _request("terminate", "example.terminate"),
  266. _request("first", "example.work", {"order": 1}),
  267. _request("second", "example.work", {"order": 2}),
  268. ],
  269. scope="session-1:turn-1",
  270. )
  271. terminal_index = observed.index("terminal:terminate")
  272. assert observed.index("finish:first") < terminal_index
  273. assert observed.index("finish:second") < terminal_index
  274. assert [result.event_id for result in batch.results] == [
  275. "terminate",
  276. "first",
  277. "second",
  278. ]
  279. @pytest.mark.asyncio
  280. async def test_batch_executor_does_not_swallow_external_cancellation():
  281. started = asyncio.Event()
  282. async def handler(request: EventRequest) -> dict[str, Any]:
  283. started.set()
  284. await asyncio.Event().wait()
  285. return {"event_id": request.id}
  286. executor = _executor([_definition("example.blocked", handler)])
  287. task = asyncio.create_task(
  288. executor.execute(
  289. [_request("blocked", "example.blocked")],
  290. scope="session-1:turn-1",
  291. )
  292. )
  293. await asyncio.wait_for(started.wait(), timeout=0.2)
  294. task.cancel()
  295. with pytest.raises(asyncio.CancelledError):
  296. await task
  297. @pytest.mark.asyncio
  298. async def test_batch_executor_coalesces_normalized_provider_arguments_across_ids():
  299. calls: list[EventRequest] = []
  300. def normalize(arguments: dict[str, Any]) -> dict[str, Any]:
  301. value = arguments["value"]
  302. return {"value": int(value) if isinstance(value, float) else value}
  303. executor = _executor(
  304. [
  305. _definition(
  306. "example.normalized",
  307. lambda request: calls.append(request) or {"value": request.arguments["value"]},
  308. normalizer=normalize,
  309. )
  310. ]
  311. )
  312. requests = [
  313. EventRequest(
  314. id="integer-id",
  315. name="example.normalized",
  316. arguments={"value": 30},
  317. source=EventSource.PROVIDER_RESOLVED,
  318. raw_arguments='{"value":30}',
  319. ),
  320. EventRequest(
  321. id="float-id",
  322. name="example.normalized",
  323. arguments={"value": 30.0},
  324. source=EventSource.PROVIDER_RESOLVED,
  325. raw_arguments='{"value":30.0}',
  326. ),
  327. ]
  328. batch = await executor.execute(requests, scope="normalized-scope")
  329. assert len(calls) == 1
  330. assert batch.coalesced_count == 1
  331. assert [result.event_id for result in batch.results] == [
  332. "integer-id",
  333. "float-id",
  334. ]
  335. assert batch.results[0].payload == {"value": 30}
  336. assert batch.results[1].payload == {
  337. "value": 30,
  338. "deduplicated_from": "integer-id",
  339. }
  340. @pytest.mark.asyncio
  341. async def test_batch_executor_tool_replies_keep_each_coalesced_call_id():
  342. registry = ToolRegistry(
  343. [
  344. ToolDefinition(
  345. name="example.tool",
  346. description="Execute a coalesced tool.",
  347. parameters={"type": "object"},
  348. handler=lambda event: {"handled_by": event.id},
  349. )
  350. ]
  351. )
  352. executor = _executor_type()(
  353. EventKernel(registry.event_registry),
  354. batch_timeout_seconds=1,
  355. )
  356. events = [
  357. ToolCallEvent(id=event_id, name="example.tool", arguments={}, raw_arguments="{}")
  358. for event_id in ("first-id", "second-id")
  359. ]
  360. batch = await executor.execute(
  361. [
  362. registry.event_request(event, source=EventSource.PROVIDER_RESOLVED)
  363. for event in events
  364. ],
  365. scope="tool-scope",
  366. )
  367. replies = registry.tool_replies(events, batch)
  368. assert [reply.tool_call_id for reply in replies] == ["first-id", "second-id"]
  369. assert [result.event_id for result in batch.results] == [
  370. "first-id",
  371. "second-id",
  372. ]
  373. @pytest.mark.asyncio
  374. async def test_batch_key_falls_back_to_original_arguments_when_normalizer_fails():
  375. calls = 0
  376. def normalize(arguments: dict[str, Any]) -> dict[str, Any]:
  377. if isinstance(arguments.get("value"), str):
  378. raise ValueError("unsupported value")
  379. return {"value": int(arguments["value"])}
  380. def handler(request: EventRequest) -> dict[str, Any]:
  381. nonlocal calls
  382. calls += 1
  383. return {"value": request.arguments["value"]}
  384. executor = _executor(
  385. [_definition("example.normalized", handler, normalizer=normalize)]
  386. )
  387. batch = await executor.execute(
  388. [
  389. EventRequest(
  390. id="valid",
  391. name="example.normalized",
  392. arguments={"value": 30},
  393. source=EventSource.PROVIDER_RESOLVED,
  394. ),
  395. EventRequest(
  396. id="invalid",
  397. name="example.normalized",
  398. arguments={"value": "30"},
  399. source=EventSource.PROVIDER_RESOLVED,
  400. ),
  401. ],
  402. scope="normalizer-failure",
  403. )
  404. assert batch.coalesced_count == 0
  405. assert [result.status for result in batch.results] == [
  406. EventStatus.SUCCESS,
  407. EventStatus.INVALID_ARGUMENTS,
  408. ]
  409. assert calls == 1
  410. @pytest.mark.asyncio
  411. async def test_concurrent_replay_waiters_share_execution_and_cancel_independently():
  412. calls = 0
  413. started = asyncio.Event()
  414. release = asyncio.Event()
  415. async def handler(request: EventRequest) -> dict[str, Any]:
  416. nonlocal calls
  417. calls += 1
  418. started.set()
  419. await release.wait()
  420. return {"event_id": request.id}
  421. executor = _executor([_definition("example.shared", handler)])
  422. request = _request("same-id", "example.shared")
  423. first = asyncio.create_task(executor.execute([request], scope="shared-scope"))
  424. second = asyncio.create_task(executor.execute([request], scope="shared-scope"))
  425. await asyncio.wait_for(started.wait(), timeout=0.2)
  426. first.cancel()
  427. with pytest.raises(asyncio.CancelledError):
  428. await first
  429. release.set()
  430. second_batch = await asyncio.wait_for(second, timeout=0.2)
  431. assert calls == 1
  432. assert second_batch.results[0].status is EventStatus.SUCCESS
  433. @pytest.mark.asyncio
  434. async def test_release_scope_removes_replay_entries_for_reuse():
  435. calls = 0
  436. def handler(request: EventRequest) -> dict[str, Any]:
  437. nonlocal calls
  438. calls += 1
  439. return {"call": calls}
  440. executor = _executor([_definition("example.cached", handler)])
  441. request = _request("same-id", "example.cached")
  442. await executor.execute([request], scope="released-scope")
  443. executor.release_scope("released-scope")
  444. batch = await executor.execute([request], scope="released-scope")
  445. assert calls == 2
  446. assert batch.replayed_count == 0
  447. @pytest.mark.asyncio
  448. async def test_replay_cache_stays_bounded_across_250_scopes():
  449. executor = _executor(
  450. [_definition("example.cached", lambda request: {"id": request.id})],
  451. max_replay_entries=100,
  452. )
  453. request = _request("same-id", "example.cached")
  454. for index in range(250):
  455. await executor.execute([request], scope=f"scope-{index}")
  456. assert len(executor._replay_results) <= 100
  457. @pytest.mark.asyncio
  458. async def test_terminal_uses_independent_grace_after_sibling_batch_timeout():
  459. terminal_calls: list[str] = []
  460. async def blocked(request: EventRequest) -> dict[str, Any]:
  461. await asyncio.Event().wait()
  462. return {"event_id": request.id}
  463. def terminate(request: EventRequest) -> dict[str, Any]:
  464. terminal_calls.append(request.id)
  465. return {"terminated": True}
  466. executor = _executor(
  467. [
  468. _definition("example.blocked", blocked),
  469. _definition(
  470. "example.terminate",
  471. terminate,
  472. terminal=True,
  473. timeout_seconds=0.05,
  474. ),
  475. ],
  476. batch_timeout_seconds=0.01,
  477. terminal_grace_seconds=0.1,
  478. )
  479. batch = await executor.execute(
  480. [
  481. _request("sibling", "example.blocked"),
  482. _request("terminal", "example.terminate"),
  483. ],
  484. scope="terminal-grace",
  485. )
  486. assert [result.status for result in batch.results] == [
  487. EventStatus.TIMEOUT,
  488. EventStatus.SUCCESS,
  489. ]
  490. assert batch.deadline_exceeded is True
  491. assert terminal_calls == ["terminal"]
  492. @pytest.mark.asyncio
  493. async def test_terminal_grace_is_bounded_by_terminal_event_timeout():
  494. async def blocked_terminal(request: EventRequest) -> dict[str, Any]:
  495. await asyncio.Event().wait()
  496. return {"event_id": request.id}
  497. executor = _executor(
  498. [
  499. _definition(
  500. "example.terminate",
  501. blocked_terminal,
  502. terminal=True,
  503. timeout_seconds=0.01,
  504. )
  505. ],
  506. terminal_grace_seconds=0.2,
  507. )
  508. batch = await executor.execute(
  509. [_request("terminal", "example.terminate")],
  510. scope="terminal-timeout",
  511. )
  512. assert batch.results[0].status is EventStatus.TIMEOUT
  513. assert batch.results[0].error == "event timed out after 0.01 seconds"
  514. assert batch.deadline_exceeded is False
  515. @pytest.mark.asyncio
  516. async def test_blocking_sync_handler_timeout_returns_before_thread_finishes():
  517. started = threading.Event()
  518. release = threading.Event()
  519. finished = threading.Event()
  520. def blocking_handler(request: EventRequest) -> dict[str, Any]:
  521. started.set()
  522. release.wait(timeout=1)
  523. finished.set()
  524. return {"event_id": request.id}
  525. executor = _executor(
  526. [
  527. _definition(
  528. "example.blocking",
  529. blocking_handler,
  530. timeout_seconds=0.02,
  531. )
  532. ],
  533. batch_timeout_seconds=0.2,
  534. )
  535. loop = asyncio.get_running_loop()
  536. started_at = loop.time()
  537. batch = await executor.execute(
  538. [_request("blocking", "example.blocking")],
  539. scope="blocking-handler",
  540. )
  541. elapsed = loop.time() - started_at
  542. release.set()
  543. await asyncio.to_thread(finished.wait, 0.2)
  544. assert started.is_set()
  545. assert batch.results[0].status is EventStatus.TIMEOUT
  546. assert elapsed < 0.1
  547. assert finished.is_set()