test_event_batch.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. from __future__ import annotations
  2. import asyncio
  3. from collections.abc import Callable
  4. from typing import Any
  5. import pytest
  6. import agent_lab.application.events as events_module
  7. from agent_lab.application.events import (
  8. EventDefinition,
  9. EventKernel,
  10. EventRegistry,
  11. EventRequest,
  12. EventStatus,
  13. )
  14. def _executor_type():
  15. executor_type = getattr(events_module, "EventBatchExecutor", None)
  16. assert executor_type is not None, "EventBatchExecutor is not implemented"
  17. return executor_type
  18. def _definition(
  19. name: str,
  20. handler: Callable[[EventRequest], Any],
  21. *,
  22. conflict_keys: tuple[str, ...] = (),
  23. timeout_seconds: float | None = None,
  24. terminal: bool = False,
  25. ) -> EventDefinition:
  26. return EventDefinition(
  27. name=name,
  28. description=f"Execute {name}.",
  29. parameters={"type": "object"},
  30. handler=handler,
  31. conflict_keys=conflict_keys,
  32. timeout_seconds=timeout_seconds,
  33. terminal=terminal,
  34. )
  35. def _request(event_id: str, name: str, arguments: dict[str, Any] | None = None):
  36. return EventRequest(
  37. id=event_id,
  38. name=name,
  39. arguments=arguments or {},
  40. )
  41. def _executor(
  42. definitions: list[EventDefinition],
  43. *,
  44. max_parallel_events: int = 4,
  45. batch_timeout_seconds: float = 1.0,
  46. ):
  47. return _executor_type()(
  48. EventKernel(EventRegistry(definitions)),
  49. max_parallel_events=max_parallel_events,
  50. batch_timeout_seconds=batch_timeout_seconds,
  51. )
  52. @pytest.mark.asyncio
  53. async def test_batch_executor_bounds_global_parallelism():
  54. active = 0
  55. max_active = 0
  56. async def handler(request: EventRequest) -> dict[str, Any]:
  57. nonlocal active, max_active
  58. active += 1
  59. max_active = max(max_active, active)
  60. await asyncio.sleep(0.02)
  61. active -= 1
  62. return {"event_id": request.id}
  63. executor = _executor(
  64. [_definition("example.work", handler)],
  65. max_parallel_events=2,
  66. )
  67. batch = await executor.execute(
  68. [_request(str(index), "example.work") for index in range(4)],
  69. scope="session-1:turn-1",
  70. )
  71. assert max_active == 2
  72. assert [result.status for result in batch.results] == [
  73. EventStatus.SUCCESS,
  74. ] * 4
  75. @pytest.mark.asyncio
  76. async def test_batch_executor_preserves_input_order_when_completion_order_differs():
  77. release_first = asyncio.Event()
  78. second_finished = asyncio.Event()
  79. async def handler(request: EventRequest) -> dict[str, Any]:
  80. if request.id == "first":
  81. await release_first.wait()
  82. else:
  83. second_finished.set()
  84. return {"event_id": request.id}
  85. executor = _executor([_definition("example.work", handler)])
  86. task = asyncio.create_task(
  87. executor.execute(
  88. [
  89. _request("first", "example.work"),
  90. _request("second", "example.work"),
  91. ],
  92. scope="session-1:turn-1",
  93. )
  94. )
  95. await asyncio.wait_for(second_finished.wait(), timeout=0.2)
  96. release_first.set()
  97. batch = await asyncio.wait_for(task, timeout=0.2)
  98. assert [result.event_id for result in batch.results] == ["first", "second"]
  99. @pytest.mark.asyncio
  100. async def test_batch_executor_serializes_shared_conflict_keys():
  101. active = 0
  102. max_active = 0
  103. async def handler(request: EventRequest) -> dict[str, Any]:
  104. nonlocal active, max_active
  105. active += 1
  106. max_active = max(max_active, active)
  107. await asyncio.sleep(0.02)
  108. active -= 1
  109. return {"event_id": request.id}
  110. executor = _executor(
  111. [
  112. _definition(
  113. "example.write",
  114. handler,
  115. conflict_keys=("shared-resource",),
  116. )
  117. ]
  118. )
  119. await executor.execute(
  120. [
  121. _request("first", "example.write"),
  122. _request("second", "example.write"),
  123. ],
  124. scope="session-1:turn-1",
  125. )
  126. assert max_active == 1
  127. @pytest.mark.asyncio
  128. async def test_batch_executor_isolates_per_event_timeout_from_successful_sibling():
  129. async def slow_handler(request: EventRequest) -> dict[str, Any]:
  130. await asyncio.sleep(1)
  131. return {"event_id": request.id}
  132. executor = _executor(
  133. [
  134. _definition(
  135. "example.slow",
  136. slow_handler,
  137. timeout_seconds=0.01,
  138. ),
  139. _definition(
  140. "example.fast",
  141. lambda request: {"event_id": request.id},
  142. ),
  143. ],
  144. batch_timeout_seconds=0.2,
  145. )
  146. batch = await executor.execute(
  147. [
  148. _request("slow", "example.slow"),
  149. _request("fast", "example.fast"),
  150. ],
  151. scope="session-1:turn-1",
  152. )
  153. assert [result.status for result in batch.results] == [
  154. EventStatus.TIMEOUT,
  155. EventStatus.SUCCESS,
  156. ]
  157. assert batch.deadline_exceeded is False
  158. @pytest.mark.asyncio
  159. async def test_batch_executor_applies_overall_deadline_to_waiting_work():
  160. async def blocked_handler(request: EventRequest) -> dict[str, Any]:
  161. await asyncio.Event().wait()
  162. return {"event_id": request.id}
  163. executor = _executor(
  164. [_definition("example.blocked", blocked_handler)],
  165. max_parallel_events=1,
  166. batch_timeout_seconds=0.02,
  167. )
  168. batch = await executor.execute(
  169. [
  170. _request("first", "example.blocked"),
  171. _request("second", "example.blocked"),
  172. ],
  173. scope="session-1:turn-1",
  174. )
  175. assert [result.status for result in batch.results] == [
  176. EventStatus.TIMEOUT,
  177. EventStatus.TIMEOUT,
  178. ]
  179. assert batch.deadline_exceeded is True
  180. @pytest.mark.asyncio
  181. async def test_batch_executor_coalesces_exact_duplicates_inside_one_batch():
  182. calls = 0
  183. def handler(request: EventRequest) -> dict[str, Any]:
  184. nonlocal calls
  185. calls += 1
  186. return {"event_id": request.id, "arguments": request.arguments}
  187. executor = _executor([_definition("example.write", handler)])
  188. batch = await executor.execute(
  189. [
  190. _request("same", "example.write", {"a": 1, "b": 2}),
  191. _request("same", "example.write", {"b": 2, "a": 1}),
  192. ],
  193. scope="session-1:turn-1",
  194. )
  195. assert calls == 1
  196. assert batch.coalesced_count == 1
  197. assert batch.results[0] == batch.results[1]
  198. @pytest.mark.asyncio
  199. async def test_batch_executor_replay_key_includes_scope_event_id_and_request():
  200. calls = 0
  201. def handler(request: EventRequest) -> dict[str, Any]:
  202. nonlocal calls
  203. calls += 1
  204. return {"call": calls, "arguments": request.arguments}
  205. executor = _executor([_definition("example.write", handler)])
  206. original = _request("same", "example.write", {"a": 1, "b": 2})
  207. canonical_replay = _request(
  208. "same",
  209. "example.write",
  210. {"b": 2, "a": 1},
  211. )
  212. first = await executor.execute([original], scope="session-1:turn-1")
  213. replay = await executor.execute(
  214. [canonical_replay],
  215. scope="session-1:turn-1",
  216. )
  217. changed_request = await executor.execute(
  218. [_request("same", "example.write", {"a": 2})],
  219. scope="session-1:turn-1",
  220. )
  221. changed_id = await executor.execute(
  222. [_request("other", "example.write", {"a": 1, "b": 2})],
  223. scope="session-1:turn-1",
  224. )
  225. changed_scope = await executor.execute(
  226. [original],
  227. scope="session-1:turn-2",
  228. )
  229. assert first.results[0] == replay.results[0]
  230. assert replay.replayed_count == 1
  231. assert changed_request.replayed_count == 0
  232. assert changed_id.replayed_count == 0
  233. assert changed_scope.replayed_count == 0
  234. assert calls == 4
  235. @pytest.mark.asyncio
  236. async def test_batch_executor_runs_terminal_events_after_all_nonterminal_results():
  237. observed: list[str] = []
  238. async def nonterminal(request: EventRequest) -> dict[str, Any]:
  239. observed.append(f"start:{request.id}")
  240. await asyncio.sleep(0.01)
  241. observed.append(f"finish:{request.id}")
  242. return {"event_id": request.id}
  243. def terminal(request: EventRequest) -> dict[str, Any]:
  244. observed.append(f"terminal:{request.id}")
  245. return {"event_id": request.id}
  246. executor = _executor(
  247. [
  248. _definition("example.work", nonterminal),
  249. _definition("example.terminate", terminal, terminal=True),
  250. ]
  251. )
  252. batch = await executor.execute(
  253. [
  254. _request("terminate", "example.terminate"),
  255. _request("first", "example.work"),
  256. _request("second", "example.work"),
  257. ],
  258. scope="session-1:turn-1",
  259. )
  260. terminal_index = observed.index("terminal:terminate")
  261. assert observed.index("finish:first") < terminal_index
  262. assert observed.index("finish:second") < terminal_index
  263. assert [result.event_id for result in batch.results] == [
  264. "terminate",
  265. "first",
  266. "second",
  267. ]
  268. @pytest.mark.asyncio
  269. async def test_batch_executor_does_not_swallow_external_cancellation():
  270. started = asyncio.Event()
  271. async def handler(request: EventRequest) -> dict[str, Any]:
  272. started.set()
  273. await asyncio.Event().wait()
  274. return {"event_id": request.id}
  275. executor = _executor([_definition("example.blocked", handler)])
  276. task = asyncio.create_task(
  277. executor.execute(
  278. [_request("blocked", "example.blocked")],
  279. scope="session-1:turn-1",
  280. )
  281. )
  282. await asyncio.wait_for(started.wait(), timeout=0.2)
  283. task.cancel()
  284. with pytest.raises(asyncio.CancelledError):
  285. await task