"""What the bus costs the event loop, and that it still delivers everything.""" import asyncio import threading from contextlib import AsyncExitStack import pytest from fluksio.flow.events import DISPATCH_BUDGET, QUEUE_SIZE, EventBus def _event(i: int) -> dict[str, object]: return {"type": "node_executed", "node": f"n{i}", "outputs": 1} def test_a_cascade_costs_one_loop_callback() -> None: """The whole point of coalescing. A three-node cascade publishes 13-16 events and each used to cross to the event loop on its own. They are one callback now — and still every event. """ async def run() -> tuple[int, int]: bus = EventBus() loop = asyncio.get_running_loop() bus.bind(loop) calls = 0 real = loop.call_soon_threadsafe def counting(*args: object, **kwargs: object) -> object: nonlocal calls calls += 1 return real(*args, **kwargs) # type: ignore[arg-type] loop.call_soon_threadsafe = counting # type: ignore[method-assign] try: async with bus.subscribe() as queue: # From a worker thread, which is where nodes run. thread = threading.Thread( target=lambda: [bus.publish(_event(i)) for i in range(16)] ) thread.start() thread.join() await asyncio.sleep(0.05) return calls, queue.qsize() finally: loop.call_soon_threadsafe = real # type: ignore[method-assign] calls, delivered = asyncio.run(run()) assert calls == 1 assert delivered == 16 def test_a_slow_subscriber_still_loses_its_oldest() -> None: """Coalescing must not turn the drop-oldest bound into unbounded growth.""" async def run() -> tuple[int, object]: bus = EventBus() bus.bind(asyncio.get_running_loop()) async with bus.subscribe() as queue: thread = threading.Thread( target=lambda: [bus.publish(_event(i)) for i in range(QUEUE_SIZE + 50)] ) thread.start() thread.join() await asyncio.sleep(0.05) return queue.qsize(), (await queue.get())["node"] size, oldest = asyncio.run(run()) assert size == QUEUE_SIZE # The first fifty were dropped, not the last. assert oldest == "n50" def test_a_subscriber_that_lost_events_is_told_once() -> None: """What a device's socket catches up by: it may have lost its demand.""" async def run() -> tuple[bool, bool, bool]: bus = EventBus() bus.bind(asyncio.get_running_loop()) async with bus.subscribe() as kept, bus.subscribe() as behind: for i in range(QUEUE_SIZE): bus.publish(_event(i)) await asyncio.sleep(0.01) while not kept.empty(): kept.get_nowait() bus.publish(_event(QUEUE_SIZE)) await asyncio.sleep(0.01) return bus.dropped(kept), bus.dropped(behind), bus.dropped(behind) assert asyncio.run(run()) == (False, True, False) def test_nothing_is_buffered_with_nobody_listening() -> None: """An engine with no browser open holds no events.""" async def run() -> int: bus = EventBus() bus.bind(asyncio.get_running_loop()) for i in range(10): bus.publish(_event(i)) await asyncio.sleep(0.01) return len(bus._pending) assert asyncio.run(run()) == 0 @pytest.mark.parametrize("flush", [False, True]) def test_overflow_keeps_errors_and_control_events_in_order(flush: bool) -> None: """The staging bound and a slow subscriber use the same eviction policy.""" async def run() -> None: bus = EventBus() bus.bind(asyncio.get_running_loop()) critical = [ {"type": "node_log", "level": "error"}, {"type": "node_error"}, {"type": "run_started"}, {"type": "audit"}, {"type": "dashboard_changed"}, {"type": "future_control_event"}, ] async with bus.subscribe() as first, bus.subscribe() as second: sent = [*critical, *(_event(i) for i in range(QUEUE_SIZE + 50))] sent.append({"type": "run_finished"}) for sequence, event in enumerate(sent): event["sequence"] = sequence bus.publish(event) assert len(bus._pending) <= QUEUE_SIZE if flush: # Queue overflow, with no staging overflow. await asyncio.sleep(0) await asyncio.sleep(0) for queue in (first, second): received = [queue.get_nowait() for _ in range(queue.qsize())] assert len(received) == QUEUE_SIZE assert received[: len(critical)] == critical assert received[-1]["type"] == "run_finished" assert received == sorted(received, key=lambda e: e["sequence"]) assert bus.dropped(queue) assert not bus.dropped(queue) asyncio.run(run()) def test_a_full_critical_backlog_rejects_telemetry_and_reports_loss() -> None: async def run() -> None: bus = EventBus() bus.bind(asyncio.get_running_loop()) async with bus.subscribe() as queue: for i in range(QUEUE_SIZE + 1): bus.publish({"type": "node_error", "sequence": i}) bus.publish(_event(0)) assert len(bus._pending) == QUEUE_SIZE await asyncio.sleep(0) assert bus.dropped(queue) assert (await queue.get())["sequence"] == 1 bus.publish({"type": "audit"}) await asyncio.sleep(0) bus.publish(_event(1)) await asyncio.sleep(0) assert bus.dropped(queue) assert queue.qsize() == QUEUE_SIZE assert all( queue.get_nowait()["type"] != "node_executed" for _ in range(QUEUE_SIZE) ) asyncio.run(run()) def test_fanout_yields_before_all_subscribers_are_served() -> None: async def run() -> None: bus = EventBus() loop = asyncio.get_running_loop() bus.bind(loop) async with AsyncExitStack() as stack: queues = [ await stack.enter_async_context(bus.subscribe()) for _ in range(DISPATCH_BUDGET // QUEUE_SIZE + 1) ] for i in range(QUEUE_SIZE): bus.publish(_event(i)) heartbeat: asyncio.Future[int] = loop.create_future() loop.call_soon( lambda: heartbeat.set_result(sum(queue.qsize() for queue in queues)) ) assert await heartbeat == DISPATCH_BUDGET await asyncio.sleep(0) assert all(queue.qsize() == QUEUE_SIZE for queue in queues) assert not any(bus.dropped(queue) for queue in queues) assert not bus._flush_scheduled asyncio.run(run())