test_event_batch.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335
  1. from __future__ import annotations
  2. import asyncio
  3. import json
  4. import threading
  5. from collections.abc import Callable
  6. from enum import IntEnum
  7. from typing import Any
  8. import pytest
  9. import agent_lab.application.events as events_module
  10. from agent_lab.application.events import (
  11. EventDefinition,
  12. EventKernel,
  13. EventRegistry,
  14. EventRequest,
  15. EventSource,
  16. EventStatus,
  17. ResultPolicy,
  18. )
  19. from agent_lab.application.tools import ToolDefinition, ToolRegistry
  20. from agent_lab.domain.events import ToolCallEvent
  21. def _executor_type():
  22. executor_type = getattr(events_module, "EventBatchExecutor", None)
  23. assert executor_type is not None, "EventBatchExecutor is not implemented"
  24. return executor_type
  25. def _definition(
  26. name: str,
  27. handler: Callable[[EventRequest], Any],
  28. *,
  29. conflict_keys: tuple[str, ...] = (),
  30. timeout_seconds: float | None = None,
  31. terminal: bool = False,
  32. normalizer: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
  33. ) -> EventDefinition:
  34. return EventDefinition(
  35. name=name,
  36. description=f"Execute {name}.",
  37. parameters={"type": "object"},
  38. handler=handler,
  39. conflict_keys=conflict_keys,
  40. timeout_seconds=timeout_seconds,
  41. terminal=terminal,
  42. normalizer=normalizer,
  43. )
  44. def _request(event_id: str, name: str, arguments: dict[str, Any] | None = None):
  45. return EventRequest(
  46. id=event_id,
  47. name=name,
  48. arguments=arguments or {},
  49. )
  50. def _executor(
  51. definitions: list[EventDefinition],
  52. *,
  53. max_parallel_events: int = 4,
  54. batch_timeout_seconds: float = 1.0,
  55. **options: Any,
  56. ):
  57. return _executor_type()(
  58. EventKernel(EventRegistry(definitions)),
  59. max_parallel_events=max_parallel_events,
  60. batch_timeout_seconds=batch_timeout_seconds,
  61. **options,
  62. )
  63. @pytest.mark.asyncio
  64. async def test_batch_executor_bounds_global_parallelism():
  65. active = 0
  66. max_active = 0
  67. async def handler(request: EventRequest) -> dict[str, Any]:
  68. nonlocal active, max_active
  69. active += 1
  70. max_active = max(max_active, active)
  71. await asyncio.sleep(0.02)
  72. active -= 1
  73. return {"event_id": request.id}
  74. executor = _executor(
  75. [_definition("example.work", handler)],
  76. max_parallel_events=2,
  77. )
  78. batch = await executor.execute(
  79. [
  80. _request(str(index), "example.work", {"index": index})
  81. for index in range(4)
  82. ],
  83. scope="session-1:turn-1",
  84. )
  85. assert max_active == 2
  86. assert [result.status for result in batch.results] == [
  87. EventStatus.SUCCESS,
  88. ] * 4
  89. @pytest.mark.asyncio
  90. async def test_batch_executor_preserves_input_order_when_completion_order_differs():
  91. release_first = asyncio.Event()
  92. second_finished = asyncio.Event()
  93. async def handler(request: EventRequest) -> dict[str, Any]:
  94. if request.id == "first":
  95. await release_first.wait()
  96. else:
  97. second_finished.set()
  98. return {"event_id": request.id}
  99. executor = _executor([_definition("example.work", handler)])
  100. task = asyncio.create_task(
  101. executor.execute(
  102. [
  103. _request("first", "example.work", {"order": 1}),
  104. _request("second", "example.work", {"order": 2}),
  105. ],
  106. scope="session-1:turn-1",
  107. )
  108. )
  109. await asyncio.wait_for(second_finished.wait(), timeout=0.2)
  110. release_first.set()
  111. batch = await asyncio.wait_for(task, timeout=0.2)
  112. assert [result.event_id for result in batch.results] == ["first", "second"]
  113. @pytest.mark.asyncio
  114. async def test_batch_executor_serializes_shared_conflict_keys():
  115. active = 0
  116. max_active = 0
  117. async def handler(request: EventRequest) -> dict[str, Any]:
  118. nonlocal active, max_active
  119. active += 1
  120. max_active = max(max_active, active)
  121. await asyncio.sleep(0.02)
  122. active -= 1
  123. return {"event_id": request.id}
  124. executor = _executor(
  125. [
  126. _definition(
  127. "example.write",
  128. handler,
  129. conflict_keys=("shared-resource",),
  130. )
  131. ]
  132. )
  133. await executor.execute(
  134. [
  135. _request("first", "example.write", {"order": 1}),
  136. _request("second", "example.write", {"order": 2}),
  137. ],
  138. scope="session-1:turn-1",
  139. )
  140. assert max_active == 1
  141. @pytest.mark.asyncio
  142. async def test_batch_executor_isolates_per_event_timeout_from_successful_sibling():
  143. async def slow_handler(request: EventRequest) -> dict[str, Any]:
  144. await asyncio.sleep(1)
  145. return {"event_id": request.id}
  146. executor = _executor(
  147. [
  148. _definition(
  149. "example.slow",
  150. slow_handler,
  151. timeout_seconds=0.01,
  152. ),
  153. _definition(
  154. "example.fast",
  155. lambda request: {"event_id": request.id},
  156. ),
  157. ],
  158. batch_timeout_seconds=0.2,
  159. )
  160. batch = await executor.execute(
  161. [
  162. _request("slow", "example.slow"),
  163. _request("fast", "example.fast"),
  164. ],
  165. scope="session-1:turn-1",
  166. )
  167. assert [result.status for result in batch.results] == [
  168. EventStatus.TIMEOUT,
  169. EventStatus.SUCCESS,
  170. ]
  171. assert batch.deadline_exceeded is False
  172. @pytest.mark.asyncio
  173. async def test_batch_executor_applies_overall_deadline_to_waiting_work():
  174. async def blocked_handler(request: EventRequest) -> dict[str, Any]:
  175. await asyncio.Event().wait()
  176. return {"event_id": request.id}
  177. executor = _executor(
  178. [_definition("example.blocked", blocked_handler)],
  179. max_parallel_events=1,
  180. batch_timeout_seconds=0.02,
  181. )
  182. batch = await executor.execute(
  183. [
  184. _request("first", "example.blocked"),
  185. _request("second", "example.blocked"),
  186. ],
  187. scope="session-1:turn-1",
  188. )
  189. assert [result.status for result in batch.results] == [
  190. EventStatus.TIMEOUT,
  191. EventStatus.TIMEOUT,
  192. ]
  193. assert batch.deadline_exceeded is True
  194. @pytest.mark.asyncio
  195. async def test_batch_executor_coalesces_exact_duplicates_inside_one_batch():
  196. calls = 0
  197. def handler(request: EventRequest) -> dict[str, Any]:
  198. nonlocal calls
  199. calls += 1
  200. return {"event_id": request.id, "arguments": request.arguments}
  201. executor = _executor([_definition("example.write", handler)])
  202. batch = await executor.execute(
  203. [
  204. _request("same", "example.write", {"a": 1, "b": 2}),
  205. _request("same", "example.write", {"b": 2, "a": 1}),
  206. ],
  207. scope="session-1:turn-1",
  208. )
  209. assert calls == 1
  210. assert batch.coalesced_count == 1
  211. assert batch.results[0].event_id == batch.results[1].event_id == "same"
  212. assert batch.results[0].payload == batch.results[1].payload
  213. assert batch.results[0].deduplicated_from is None
  214. assert batch.results[1].deduplicated_from == "same"
  215. @pytest.mark.asyncio
  216. async def test_batch_executor_replay_key_includes_scope_event_id_and_request():
  217. calls = 0
  218. def handler(request: EventRequest) -> dict[str, Any]:
  219. nonlocal calls
  220. calls += 1
  221. return {"call": calls, "arguments": request.arguments}
  222. executor = _executor([_definition("example.write", handler)])
  223. original = _request("same", "example.write", {"a": 1, "b": 2})
  224. canonical_replay = _request(
  225. "same",
  226. "example.write",
  227. {"b": 2, "a": 1},
  228. )
  229. first = await executor.execute([original], scope="session-1:turn-1")
  230. replay = await executor.execute(
  231. [canonical_replay],
  232. scope="session-1:turn-1",
  233. )
  234. changed_request = await executor.execute(
  235. [_request("same", "example.write", {"a": 2})],
  236. scope="session-1:turn-1",
  237. )
  238. changed_id = await executor.execute(
  239. [_request("other", "example.write", {"a": 1, "b": 2})],
  240. scope="session-1:turn-1",
  241. )
  242. changed_scope = await executor.execute(
  243. [original],
  244. scope="session-1:turn-2",
  245. )
  246. assert first.results[0] == replay.results[0]
  247. assert replay.replayed_count == 1
  248. assert changed_request.replayed_count == 0
  249. assert changed_id.replayed_count == 0
  250. assert changed_scope.replayed_count == 0
  251. assert calls == 4
  252. @pytest.mark.asyncio
  253. async def test_batch_executor_runs_terminal_events_after_all_nonterminal_results():
  254. observed: list[str] = []
  255. async def nonterminal(request: EventRequest) -> dict[str, Any]:
  256. observed.append(f"start:{request.id}")
  257. await asyncio.sleep(0.01)
  258. observed.append(f"finish:{request.id}")
  259. return {"event_id": request.id}
  260. def terminal(request: EventRequest) -> dict[str, Any]:
  261. observed.append(f"terminal:{request.id}")
  262. return {"event_id": request.id}
  263. executor = _executor(
  264. [
  265. _definition("example.work", nonterminal),
  266. _definition("example.terminate", terminal, terminal=True),
  267. ]
  268. )
  269. batch = await executor.execute(
  270. [
  271. _request("terminate", "example.terminate"),
  272. _request("first", "example.work", {"order": 1}),
  273. _request("second", "example.work", {"order": 2}),
  274. ],
  275. scope="session-1:turn-1",
  276. )
  277. terminal_index = observed.index("terminal:terminate")
  278. assert observed.index("finish:first") < terminal_index
  279. assert observed.index("finish:second") < terminal_index
  280. assert [result.event_id for result in batch.results] == [
  281. "terminate",
  282. "first",
  283. "second",
  284. ]
  285. @pytest.mark.asyncio
  286. async def test_batch_executor_does_not_swallow_external_cancellation():
  287. started = asyncio.Event()
  288. async def handler(request: EventRequest) -> dict[str, Any]:
  289. started.set()
  290. await asyncio.Event().wait()
  291. return {"event_id": request.id}
  292. executor = _executor([_definition("example.blocked", handler)])
  293. task = asyncio.create_task(
  294. executor.execute(
  295. [_request("blocked", "example.blocked")],
  296. scope="session-1:turn-1",
  297. )
  298. )
  299. await asyncio.wait_for(started.wait(), timeout=0.2)
  300. task.cancel()
  301. with pytest.raises(asyncio.CancelledError):
  302. await task
  303. @pytest.mark.asyncio
  304. async def test_batch_executor_coalesces_normalized_provider_arguments_across_ids():
  305. calls: list[EventRequest] = []
  306. def normalize(arguments: dict[str, Any]) -> dict[str, Any]:
  307. value = arguments["value"]
  308. return {"value": int(value) if isinstance(value, float) else value}
  309. executor = _executor(
  310. [
  311. _definition(
  312. "example.normalized",
  313. lambda request: calls.append(request) or {"value": request.arguments["value"]},
  314. normalizer=normalize,
  315. )
  316. ]
  317. )
  318. requests = [
  319. EventRequest(
  320. id="integer-id",
  321. name="example.normalized",
  322. arguments={"value": 30},
  323. source=EventSource.PROVIDER_RESOLVED,
  324. raw_arguments='{"value":30}',
  325. ),
  326. EventRequest(
  327. id="float-id",
  328. name="example.normalized",
  329. arguments={"value": 30.0},
  330. source=EventSource.PROVIDER_RESOLVED,
  331. raw_arguments='{"value":30.0}',
  332. ),
  333. ]
  334. batch = await executor.execute(requests, scope="normalized-scope")
  335. assert len(calls) == 1
  336. assert batch.coalesced_count == 1
  337. assert [result.event_id for result in batch.results] == [
  338. "integer-id",
  339. "float-id",
  340. ]
  341. assert batch.results[0].payload == {"value": 30}
  342. assert batch.results[1].payload == {"value": 30}
  343. assert getattr(batch.results[0], "deduplicated_from", None) is None
  344. assert getattr(batch.results[1], "deduplicated_from", None) == "integer-id"
  345. @pytest.mark.asyncio
  346. async def test_coalesced_clone_rewrites_only_correlated_payload_event_id():
  347. correlated = _executor(
  348. [
  349. _definition(
  350. "example.correlated",
  351. lambda request: {"event_id": request.id, "value": "same"},
  352. )
  353. ]
  354. )
  355. correlated_batch = await correlated.execute(
  356. [
  357. EventRequest(
  358. id="primary",
  359. name="example.correlated",
  360. arguments={},
  361. raw_arguments='{"call":"primary"}',
  362. ),
  363. EventRequest(
  364. id="duplicate",
  365. name="example.correlated",
  366. arguments={},
  367. raw_arguments='{"call":"duplicate"}',
  368. ),
  369. ],
  370. scope="correlated-payload",
  371. )
  372. primary, duplicate = correlated_batch.results
  373. assert primary.event_id == "primary"
  374. assert duplicate.event_id == "duplicate"
  375. assert duplicate.raw_arguments == '{"call":"duplicate"}'
  376. assert duplicate.payload == {"event_id": "duplicate", "value": "same"}
  377. assert getattr(duplicate, "deduplicated_from", None) == "primary"
  378. domain = _executor(
  379. [
  380. _definition(
  381. "example.domain",
  382. lambda request: {"event_id": "domain-object"},
  383. )
  384. ]
  385. )
  386. domain_batch = await domain.execute(
  387. [
  388. _request("primary", "example.domain"),
  389. _request("duplicate", "example.domain"),
  390. ],
  391. scope="domain-payload",
  392. )
  393. assert domain_batch.results[1].payload == {"event_id": "domain-object"}
  394. assert getattr(domain_batch.results[1], "deduplicated_from", None) == "primary"
  395. @pytest.mark.asyncio
  396. async def test_batch_executor_tool_replies_keep_each_coalesced_call_id():
  397. registry = ToolRegistry(
  398. [
  399. ToolDefinition(
  400. name="example.tool",
  401. description="Execute a coalesced tool.",
  402. parameters={"type": "object"},
  403. handler=lambda event: {"event_id": event.id},
  404. )
  405. ]
  406. )
  407. executor = _executor_type()(
  408. EventKernel(registry.event_registry),
  409. batch_timeout_seconds=1,
  410. )
  411. events = [
  412. ToolCallEvent(id=event_id, name="example.tool", arguments={}, raw_arguments="{}")
  413. for event_id in ("first-id", "second-id")
  414. ]
  415. batch = await executor.execute(
  416. [
  417. registry.event_request(event, source=EventSource.PROVIDER_RESOLVED)
  418. for event in events
  419. ],
  420. scope="tool-scope",
  421. )
  422. replies = registry.tool_replies(events, batch)
  423. assert [reply.tool_call_id for reply in replies] == ["first-id", "second-id"]
  424. assert [json.loads(reply.content) for reply in replies] == [
  425. {"event_id": "first-id"},
  426. {"event_id": "second-id"},
  427. ]
  428. assert [result.event_id for result in batch.results] == [
  429. "first-id",
  430. "second-id",
  431. ]
  432. @pytest.mark.asyncio
  433. async def test_batch_policy_and_compact_summary_skip_logical_duplicates():
  434. registry = ToolRegistry(
  435. [
  436. ToolDefinition(
  437. name="example.template",
  438. description="Render one template.",
  439. parameters={"type": "object"},
  440. handler=lambda event: {"event_id": event.id},
  441. result_policy=ResultPolicy.TEMPLATE_FOLLOW_UP,
  442. result_message_factory=lambda result: "Completed once.",
  443. )
  444. ]
  445. )
  446. executor = _executor_type()(EventKernel(registry.event_registry))
  447. events = [
  448. ToolCallEvent(
  449. id=event_id,
  450. name="example.template",
  451. arguments={},
  452. raw_arguments="{}",
  453. )
  454. for event_id in ("primary", "duplicate")
  455. ]
  456. batch = await executor.execute(
  457. [registry.event_request(event) for event in events],
  458. scope="policy-dedupe",
  459. )
  460. decision = registry.batch_decision(batch)
  461. summary = registry.compact_results_message(batch)
  462. assert decision.template_messages == ("Completed once.",)
  463. assert summary is not None
  464. assert len(summary.content.splitlines()) == 2
  465. @pytest.mark.asyncio
  466. async def test_batch_key_falls_back_to_original_arguments_when_normalizer_fails():
  467. calls = 0
  468. def normalize(arguments: dict[str, Any]) -> dict[str, Any]:
  469. if isinstance(arguments.get("value"), str):
  470. raise ValueError("unsupported value")
  471. return {"value": int(arguments["value"])}
  472. def handler(request: EventRequest) -> dict[str, Any]:
  473. nonlocal calls
  474. calls += 1
  475. return {"value": request.arguments["value"]}
  476. executor = _executor(
  477. [_definition("example.normalized", handler, normalizer=normalize)]
  478. )
  479. batch = await executor.execute(
  480. [
  481. EventRequest(
  482. id="valid",
  483. name="example.normalized",
  484. arguments={"value": 30},
  485. source=EventSource.PROVIDER_RESOLVED,
  486. ),
  487. EventRequest(
  488. id="invalid",
  489. name="example.normalized",
  490. arguments={"value": "30"},
  491. source=EventSource.PROVIDER_RESOLVED,
  492. ),
  493. ],
  494. scope="normalizer-failure",
  495. )
  496. assert batch.coalesced_count == 0
  497. assert [result.status for result in batch.results] == [
  498. EventStatus.SUCCESS,
  499. EventStatus.INVALID_ARGUMENTS,
  500. ]
  501. assert calls == 1
  502. @pytest.mark.asyncio
  503. @pytest.mark.parametrize(
  504. ("invalid_arguments", "raw_arguments"),
  505. [
  506. ({"value": float("nan")}, '{"value":NaN}'),
  507. ({"value": object()}, '{"value":"object"}'),
  508. ],
  509. )
  510. async def test_invalid_json_arguments_do_not_coalesce_with_valid_empty_object(
  511. invalid_arguments: dict[str, Any],
  512. raw_arguments: str,
  513. ):
  514. calls = 0
  515. def handler(request: EventRequest) -> dict[str, Any]:
  516. nonlocal calls
  517. calls += 1
  518. return {"event_id": request.id}
  519. executor = _executor([_definition("example.strict", handler)])
  520. batch = await executor.execute(
  521. [
  522. EventRequest(
  523. id="valid",
  524. name="example.strict",
  525. arguments={},
  526. source=EventSource.PROVIDER_RESOLVED,
  527. raw_arguments="{}",
  528. ),
  529. EventRequest(
  530. id="invalid",
  531. name="example.strict",
  532. arguments=invalid_arguments,
  533. source=EventSource.PROVIDER_RESOLVED,
  534. raw_arguments=raw_arguments,
  535. ),
  536. ],
  537. scope="strict-json-batch",
  538. )
  539. assert batch.coalesced_count == 0
  540. assert [result.status for result in batch.results] == [
  541. EventStatus.SUCCESS,
  542. EventStatus.INVALID_ARGUMENTS,
  543. ]
  544. assert calls == 1
  545. @pytest.mark.asyncio
  546. @pytest.mark.parametrize(
  547. ("invalid_arguments", "raw_arguments"),
  548. [
  549. ({"value": float("nan")}, '{"value":NaN}'),
  550. ({"value": object()}, '{"value":"object"}'),
  551. ],
  552. )
  553. async def test_invalid_json_arguments_do_not_replay_valid_success(
  554. invalid_arguments: dict[str, Any],
  555. raw_arguments: str,
  556. ):
  557. executor = _executor(
  558. [_definition("example.strict", lambda request: {"event_id": request.id})]
  559. )
  560. valid = EventRequest(
  561. id="same-id",
  562. name="example.strict",
  563. arguments={},
  564. source=EventSource.PROVIDER_RESOLVED,
  565. raw_arguments="{}",
  566. )
  567. invalid = EventRequest(
  568. id="same-id",
  569. name="example.strict",
  570. arguments=invalid_arguments,
  571. source=EventSource.PROVIDER_RESOLVED,
  572. raw_arguments=raw_arguments,
  573. )
  574. first = await executor.execute([valid], scope="strict-json-replay")
  575. second = await executor.execute([invalid], scope="strict-json-replay")
  576. assert first.results[0].status is EventStatus.SUCCESS
  577. assert second.replayed_count == 0
  578. assert second.results[0].status is EventStatus.INVALID_ARGUMENTS
  579. @pytest.mark.asyncio
  580. async def test_non_json_normalizer_output_has_distinct_canonical_key():
  581. calls = 0
  582. def normalize(arguments: dict[str, Any]) -> dict[str, Any]:
  583. if arguments.get("invalid"):
  584. return {"value": object()}
  585. return {}
  586. def handler(request: EventRequest) -> dict[str, Any]:
  587. nonlocal calls
  588. calls += 1
  589. return {"event_id": request.id}
  590. executor = _executor(
  591. [_definition("example.normalized", handler, normalizer=normalize)]
  592. )
  593. batch = await executor.execute(
  594. [
  595. EventRequest(
  596. id="valid",
  597. name="example.normalized",
  598. arguments={},
  599. source=EventSource.PROVIDER_RESOLVED,
  600. ),
  601. EventRequest(
  602. id="invalid",
  603. name="example.normalized",
  604. arguments={"invalid": True},
  605. source=EventSource.PROVIDER_RESOLVED,
  606. ),
  607. ],
  608. scope="normalizer-invalid-json",
  609. )
  610. assert batch.coalesced_count == 0
  611. assert [result.status for result in batch.results] == [
  612. EventStatus.SUCCESS,
  613. EventStatus.INVALID_ARGUMENTS,
  614. ]
  615. assert calls == 1
  616. @pytest.mark.asyncio
  617. @pytest.mark.parametrize(
  618. "wrap",
  619. [
  620. pytest.param(lambda value: {"value": value}, id="top-level"),
  621. pytest.param(
  622. lambda value: {"outer": {"value": value}},
  623. id="nested-dict",
  624. ),
  625. pytest.param(
  626. lambda value: {"outer": [value]},
  627. id="nested-list",
  628. ),
  629. ],
  630. )
  631. async def test_canonical_json_equality_requires_recursive_exact_types(wrap):
  632. class NumericEnum(IntEnum):
  633. ONE = 1
  634. calls: list[str] = []
  635. def handler(request: EventRequest) -> dict[str, Any]:
  636. calls.append(request.id)
  637. return {"event_id": request.id}
  638. executor = _executor([_definition("example.strict-types", handler)])
  639. values = [NumericEnum.ONE, True, 1, 1.0]
  640. requests = [
  641. EventRequest(
  642. id=event_id,
  643. name="example.strict-types",
  644. arguments=wrap(value),
  645. source=EventSource.PROVIDER_RESOLVED,
  646. raw_arguments=json.dumps(wrap(value)),
  647. )
  648. for event_id, value in zip(
  649. ("enum", "bool", "int", "float"),
  650. values,
  651. strict=True,
  652. )
  653. ]
  654. batch = await executor.execute(requests, scope=f"strict-types-{id(wrap)}")
  655. assert batch.coalesced_count == 0
  656. assert [result.status for result in batch.results] == [
  657. EventStatus.INVALID_ARGUMENTS,
  658. EventStatus.SUCCESS,
  659. EventStatus.SUCCESS,
  660. EventStatus.SUCCESS,
  661. ]
  662. assert calls == ["bool", "int", "float"]
  663. @pytest.mark.asyncio
  664. async def test_normalizer_explicitly_coalesces_valid_numeric_types_to_int():
  665. class NumericEnum(IntEnum):
  666. ONE = 1
  667. calls = 0
  668. def normalize(arguments: dict[str, Any]) -> dict[str, Any]:
  669. return {"value": int(arguments["value"])}
  670. def handler(request: EventRequest) -> dict[str, Any]:
  671. nonlocal calls
  672. calls += 1
  673. return {"event_id": request.id, "value": request.arguments["value"]}
  674. executor = _executor(
  675. [
  676. _definition(
  677. "example.normalized-types",
  678. handler,
  679. normalizer=normalize,
  680. )
  681. ]
  682. )
  683. requests = [
  684. EventRequest(
  685. id=event_id,
  686. name="example.normalized-types",
  687. arguments={"value": value},
  688. source=EventSource.PROVIDER_RESOLVED,
  689. raw_arguments=json.dumps({"value": value}),
  690. )
  691. for event_id, value in zip(
  692. ("enum", "bool", "int", "float"),
  693. (NumericEnum.ONE, True, 1, 1.0),
  694. strict=True,
  695. )
  696. ]
  697. batch = await executor.execute(requests, scope="normalized-strict-types")
  698. assert batch.coalesced_count == 2
  699. assert [result.status for result in batch.results] == [
  700. EventStatus.INVALID_ARGUMENTS,
  701. EventStatus.SUCCESS,
  702. EventStatus.SUCCESS,
  703. EventStatus.SUCCESS,
  704. ]
  705. assert calls == 1
  706. @pytest.mark.asyncio
  707. async def test_concurrent_replay_waiters_share_execution_and_cancel_independently():
  708. calls = 0
  709. started = asyncio.Event()
  710. release = asyncio.Event()
  711. async def handler(request: EventRequest) -> dict[str, Any]:
  712. nonlocal calls
  713. calls += 1
  714. started.set()
  715. await release.wait()
  716. return {"event_id": request.id}
  717. executor = _executor([_definition("example.shared", handler)])
  718. request = _request("same-id", "example.shared")
  719. first = asyncio.create_task(executor.execute([request], scope="shared-scope"))
  720. second = asyncio.create_task(executor.execute([request], scope="shared-scope"))
  721. await asyncio.wait_for(started.wait(), timeout=0.2)
  722. first.cancel()
  723. with pytest.raises(asyncio.CancelledError):
  724. await first
  725. release.set()
  726. second_batch = await asyncio.wait_for(second, timeout=0.2)
  727. assert calls == 1
  728. assert second_batch.results[0].status is EventStatus.SUCCESS
  729. scope_tokens = getattr(executor, "_scope_tokens", None)
  730. assert scope_tokens is not None
  731. assert len(scope_tokens) == 1
  732. assert scope_tokens["shared-scope"] > 0
  733. @pytest.mark.asyncio
  734. async def test_scope_tokens_are_globally_unique_removed_and_never_reused():
  735. async def handler(request: EventRequest) -> dict[str, Any]:
  736. return {"event_id": request.id}
  737. executor = _executor([_definition("example.token", handler)])
  738. request = _request("same-id", "example.token")
  739. await executor.execute([request], scope="scope-a")
  740. await executor.execute([request], scope="scope-b")
  741. scope_tokens = getattr(executor, "_scope_tokens", None)
  742. assert scope_tokens is not None
  743. first_a = scope_tokens["scope-a"]
  744. first_b = scope_tokens["scope-b"]
  745. assert first_a != first_b
  746. executor.release_scope("scope-a")
  747. assert "scope-a" not in scope_tokens
  748. await executor.execute([request], scope="scope-a")
  749. second_a = scope_tokens["scope-a"]
  750. assert second_a not in {first_a, first_b}
  751. executor.release_scope("scope-a")
  752. executor.release_scope("scope-b")
  753. assert scope_tokens == {}
  754. @pytest.mark.asyncio
  755. async def test_release_scope_removes_replay_entries_for_reuse():
  756. calls = 0
  757. def handler(request: EventRequest) -> dict[str, Any]:
  758. nonlocal calls
  759. calls += 1
  760. return {"call": calls}
  761. executor = _executor([_definition("example.cached", handler)])
  762. request = _request("same-id", "example.cached")
  763. await executor.execute([request], scope="released-scope")
  764. executor.release_scope("released-scope")
  765. batch = await executor.execute([request], scope="released-scope")
  766. assert calls == 2
  767. assert batch.replayed_count == 0
  768. @pytest.mark.asyncio
  769. async def test_release_scope_starts_new_generation_before_old_task_finishes():
  770. calls = 0
  771. started = [asyncio.Event(), asyncio.Event()]
  772. releases = [asyncio.Event(), asyncio.Event()]
  773. async def handler(request: EventRequest) -> dict[str, Any]:
  774. nonlocal calls
  775. call_index = calls
  776. calls += 1
  777. started[call_index].set()
  778. await releases[call_index].wait()
  779. return {"call": call_index + 1, "event_id": request.id}
  780. executor = _executor([_definition("example.generated", handler)])
  781. request = _request("same-id", "example.generated")
  782. first = asyncio.create_task(
  783. executor.execute([request], scope="generation-scope")
  784. )
  785. await asyncio.wait_for(started[0].wait(), timeout=0.2)
  786. executor.release_scope("generation-scope")
  787. second = asyncio.create_task(
  788. executor.execute([request], scope="generation-scope")
  789. )
  790. await asyncio.sleep(0.02)
  791. new_generation_started = started[1].is_set()
  792. releases[0].set()
  793. releases[1].set()
  794. first_batch, second_batch = await asyncio.gather(first, second)
  795. assert new_generation_started is True
  796. assert first_batch.results[0].payload["call"] == 1
  797. assert second_batch.results[0].payload["call"] == 2
  798. @pytest.mark.asyncio
  799. async def test_old_generation_completion_cannot_replace_new_inflight_or_cache():
  800. calls = 0
  801. started = [asyncio.Event(), asyncio.Event(), asyncio.Event()]
  802. releases = [asyncio.Event(), asyncio.Event(), asyncio.Event()]
  803. async def handler(request: EventRequest) -> dict[str, Any]:
  804. nonlocal calls
  805. call_index = calls
  806. calls += 1
  807. started[call_index].set()
  808. await releases[call_index].wait()
  809. return {"call": call_index + 1, "event_id": request.id}
  810. executor = _executor([_definition("example.generated", handler)])
  811. request = _request("same-id", "example.generated")
  812. old = asyncio.create_task(executor.execute([request], scope="reused-scope"))
  813. await asyncio.wait_for(started[0].wait(), timeout=0.2)
  814. executor.release_scope("reused-scope")
  815. current = asyncio.create_task(
  816. executor.execute([request], scope="reused-scope")
  817. )
  818. await asyncio.sleep(0.02)
  819. new_generation_started = started[1].is_set()
  820. if not new_generation_started:
  821. releases[0].set()
  822. await asyncio.gather(old, current)
  823. assert new_generation_started is True
  824. releases[0].set()
  825. old_batch = await asyncio.wait_for(old, timeout=0.2)
  826. waiter = asyncio.create_task(
  827. executor.execute([request], scope="reused-scope")
  828. )
  829. await asyncio.sleep(0.02)
  830. assert calls == 2
  831. releases[1].set()
  832. current_batch, waiter_batch = await asyncio.gather(current, waiter)
  833. replayed = await executor.execute([request], scope="reused-scope")
  834. assert old_batch.results[0].payload["call"] == 1
  835. assert current_batch.results[0].payload["call"] == 2
  836. assert waiter_batch.results[0].payload["call"] == 2
  837. assert replayed.replayed_count == 1
  838. assert replayed.results[0].payload["call"] == 2
  839. @pytest.mark.asyncio
  840. async def test_replay_cache_stays_bounded_across_250_scopes():
  841. executor = _executor(
  842. [_definition("example.cached", lambda request: {"id": request.id})],
  843. max_replay_entries=100,
  844. )
  845. request = _request("same-id", "example.cached")
  846. for index in range(250):
  847. await executor.execute([request], scope=f"scope-{index}")
  848. assert len(executor._replay_results) <= 100
  849. @pytest.mark.asyncio
  850. async def test_thousand_released_scopes_leave_no_token_or_replay_state():
  851. async def handler(request: EventRequest) -> dict[str, Any]:
  852. return {"event_id": request.id}
  853. executor = _executor([_definition("example.cleanup", handler)])
  854. request = _request("same-id", "example.cleanup")
  855. for index in range(1000):
  856. scope = f"released-{index}"
  857. await executor.execute([request], scope=scope)
  858. executor.release_scope(scope)
  859. scope_tokens = getattr(executor, "_scope_tokens", None)
  860. retained_generations = getattr(executor, "_scope_generations", {})
  861. assert scope_tokens == {}
  862. assert retained_generations == {}
  863. assert executor._replay_results == {}
  864. assert executor._inflight == {}
  865. @pytest.mark.asyncio
  866. async def test_terminal_uses_independent_grace_after_sibling_batch_timeout():
  867. terminal_calls: list[str] = []
  868. async def blocked(request: EventRequest) -> dict[str, Any]:
  869. await asyncio.Event().wait()
  870. return {"event_id": request.id}
  871. def terminate(request: EventRequest) -> dict[str, Any]:
  872. terminal_calls.append(request.id)
  873. return {"terminated": True}
  874. executor = _executor(
  875. [
  876. _definition("example.blocked", blocked),
  877. _definition(
  878. "example.terminate",
  879. terminate,
  880. terminal=True,
  881. timeout_seconds=0.05,
  882. ),
  883. ],
  884. batch_timeout_seconds=0.01,
  885. terminal_grace_seconds=0.1,
  886. )
  887. batch = await executor.execute(
  888. [
  889. _request("sibling", "example.blocked"),
  890. _request("terminal", "example.terminate"),
  891. ],
  892. scope="terminal-grace",
  893. )
  894. assert [result.status for result in batch.results] == [
  895. EventStatus.TIMEOUT,
  896. EventStatus.SUCCESS,
  897. ]
  898. assert batch.deadline_exceeded is True
  899. assert terminal_calls == ["terminal"]
  900. @pytest.mark.asyncio
  901. async def test_terminal_grace_is_bounded_by_terminal_event_timeout():
  902. async def blocked_terminal(request: EventRequest) -> dict[str, Any]:
  903. await asyncio.Event().wait()
  904. return {"event_id": request.id}
  905. executor = _executor(
  906. [
  907. _definition(
  908. "example.terminate",
  909. blocked_terminal,
  910. terminal=True,
  911. timeout_seconds=0.01,
  912. )
  913. ],
  914. terminal_grace_seconds=0.2,
  915. )
  916. batch = await executor.execute(
  917. [_request("terminal", "example.terminate")],
  918. scope="terminal-timeout",
  919. )
  920. assert batch.results[0].status is EventStatus.TIMEOUT
  921. assert batch.results[0].error == "event timed out after 0.01 seconds"
  922. assert batch.deadline_exceeded is False
  923. @pytest.mark.asyncio
  924. async def test_replayed_terminal_grace_timeout_preserves_deadline_flag():
  925. async def blocked_terminal(request: EventRequest) -> dict[str, Any]:
  926. await asyncio.Event().wait()
  927. return {"event_id": request.id}
  928. executor = _executor(
  929. [_definition("example.terminate", blocked_terminal, terminal=True)],
  930. terminal_grace_seconds=0.01,
  931. )
  932. request = _request("terminal", "example.terminate")
  933. first = await executor.execute([request], scope="terminal-replay")
  934. replayed = await executor.execute([request], scope="terminal-replay")
  935. assert first.results[0].status is EventStatus.TIMEOUT
  936. assert first.results[0].error == "batch deadline exceeded"
  937. assert first.deadline_exceeded is False
  938. assert replayed.replayed_count == 1
  939. assert replayed.deadline_exceeded is False
  940. @pytest.mark.asyncio
  941. async def test_replayed_sibling_batch_timeout_preserves_deadline_flag():
  942. async def blocked(request: EventRequest) -> dict[str, Any]:
  943. await asyncio.Event().wait()
  944. return {"event_id": request.id}
  945. executor = _executor(
  946. [_definition("example.blocked", blocked)],
  947. batch_timeout_seconds=0.01,
  948. )
  949. request = _request("sibling", "example.blocked")
  950. first = await executor.execute([request], scope="sibling-replay")
  951. replayed = await executor.execute([request], scope="sibling-replay")
  952. assert first.results[0].status is EventStatus.TIMEOUT
  953. assert first.deadline_exceeded is True
  954. assert replayed.replayed_count == 1
  955. assert replayed.deadline_exceeded is True
  956. @pytest.mark.asyncio
  957. async def test_blocking_sync_handler_timeout_returns_before_thread_finishes():
  958. started = threading.Event()
  959. release = threading.Event()
  960. finished = threading.Event()
  961. def blocking_handler(request: EventRequest) -> dict[str, Any]:
  962. started.set()
  963. release.wait(timeout=1)
  964. finished.set()
  965. return {"event_id": request.id}
  966. executor = _executor(
  967. [
  968. _definition(
  969. "example.blocking",
  970. blocking_handler,
  971. timeout_seconds=0.02,
  972. )
  973. ],
  974. batch_timeout_seconds=0.2,
  975. )
  976. loop = asyncio.get_running_loop()
  977. started_at = loop.time()
  978. batch = await executor.execute(
  979. [_request("blocking", "example.blocking")],
  980. scope="blocking-handler",
  981. )
  982. elapsed = loop.time() - started_at
  983. release.set()
  984. await asyncio.to_thread(finished.wait, 0.2)
  985. assert started.is_set()
  986. assert batch.results[0].status is EventStatus.TIMEOUT
  987. assert elapsed < 0.1
  988. assert finished.is_set()
  989. @pytest.mark.asyncio
  990. async def test_timed_out_sync_handler_keeps_conflict_lock_until_thread_finishes():
  991. worker_started = threading.Event()
  992. worker_release = threading.Event()
  993. follower_started = asyncio.Event()
  994. def blocking_handler(request: EventRequest) -> dict[str, Any]:
  995. worker_started.set()
  996. worker_release.wait(timeout=1)
  997. return {"event_id": request.id}
  998. async def follower_handler(request: EventRequest) -> dict[str, Any]:
  999. follower_started.set()
  1000. return {"event_id": request.id}
  1001. executor = _executor(
  1002. [
  1003. _definition(
  1004. "example.blocking",
  1005. blocking_handler,
  1006. conflict_keys=("shared",),
  1007. timeout_seconds=0.02,
  1008. ),
  1009. _definition(
  1010. "example.follower",
  1011. follower_handler,
  1012. conflict_keys=("shared",),
  1013. timeout_seconds=0.2,
  1014. ),
  1015. ]
  1016. )
  1017. timed_out = await executor.execute(
  1018. [_request("blocking", "example.blocking")],
  1019. scope="sync-conflict-blocking",
  1020. )
  1021. follower = asyncio.create_task(
  1022. executor.execute(
  1023. [_request("follower", "example.follower")],
  1024. scope="sync-conflict-follower",
  1025. )
  1026. )
  1027. await asyncio.sleep(0.03)
  1028. entered_before_worker_finished = follower_started.is_set()
  1029. worker_release.set()
  1030. follower_batch = await asyncio.wait_for(follower, timeout=0.3)
  1031. assert worker_started.is_set()
  1032. assert timed_out.results[0].status is EventStatus.TIMEOUT
  1033. assert entered_before_worker_finished is False
  1034. assert follower_batch.results[0].status is EventStatus.SUCCESS
  1035. @pytest.mark.asyncio
  1036. async def test_timed_out_sync_handler_keeps_parallel_slot_until_thread_finishes():
  1037. worker_started = threading.Event()
  1038. worker_release = threading.Event()
  1039. follower_started = asyncio.Event()
  1040. def blocking_handler(request: EventRequest) -> dict[str, Any]:
  1041. worker_started.set()
  1042. worker_release.wait(timeout=1)
  1043. return {"event_id": request.id}
  1044. async def follower_handler(request: EventRequest) -> dict[str, Any]:
  1045. follower_started.set()
  1046. return {"event_id": request.id}
  1047. executor = _executor(
  1048. [
  1049. _definition(
  1050. "example.blocking",
  1051. blocking_handler,
  1052. timeout_seconds=0.02,
  1053. ),
  1054. _definition(
  1055. "example.follower",
  1056. follower_handler,
  1057. timeout_seconds=0.2,
  1058. ),
  1059. ],
  1060. max_parallel_events=1,
  1061. )
  1062. timed_out = await executor.execute(
  1063. [_request("blocking", "example.blocking")],
  1064. scope="sync-slot-blocking",
  1065. )
  1066. follower = asyncio.create_task(
  1067. executor.execute(
  1068. [_request("follower", "example.follower")],
  1069. scope="sync-slot-follower",
  1070. )
  1071. )
  1072. await asyncio.sleep(0.03)
  1073. entered_before_worker_finished = follower_started.is_set()
  1074. worker_release.set()
  1075. follower_batch = await asyncio.wait_for(follower, timeout=0.3)
  1076. assert worker_started.is_set()
  1077. assert timed_out.results[0].status is EventStatus.TIMEOUT
  1078. assert entered_before_worker_finished is False
  1079. assert follower_batch.results[0].status is EventStatus.SUCCESS
  1080. @pytest.mark.asyncio
  1081. async def test_timed_out_async_handler_releases_resources_after_cancellation():
  1082. cancelled = asyncio.Event()
  1083. follower_started = asyncio.Event()
  1084. async def blocked_handler(request: EventRequest) -> dict[str, Any]:
  1085. try:
  1086. await asyncio.Event().wait()
  1087. finally:
  1088. cancelled.set()
  1089. return {"event_id": request.id}
  1090. async def follower_handler(request: EventRequest) -> dict[str, Any]:
  1091. follower_started.set()
  1092. return {"event_id": request.id}
  1093. executor = _executor(
  1094. [
  1095. _definition(
  1096. "example.blocked",
  1097. blocked_handler,
  1098. conflict_keys=("shared",),
  1099. timeout_seconds=0.01,
  1100. ),
  1101. _definition(
  1102. "example.follower",
  1103. follower_handler,
  1104. conflict_keys=("shared",),
  1105. timeout_seconds=0.2,
  1106. ),
  1107. ],
  1108. max_parallel_events=1,
  1109. )
  1110. timed_out = await executor.execute(
  1111. [_request("blocked", "example.blocked")],
  1112. scope="async-release-blocked",
  1113. )
  1114. follower = await executor.execute(
  1115. [_request("follower", "example.follower")],
  1116. scope="async-release-follower",
  1117. )
  1118. assert timed_out.results[0].status is EventStatus.TIMEOUT
  1119. assert cancelled.is_set()
  1120. assert follower_started.is_set()
  1121. assert follower.results[0].status is EventStatus.SUCCESS