# SPDX-License-Identifier: Apache-2.0 """Tests for EventBus, EventSubscriber, and singleton management.""" # Standard from unittest.mock import MagicMock, patch import time # Third Party import pytest # First Party from lmcache.v1.mp_observability.event import Event, EventType from lmcache.v1.mp_observability.event_bus import ( EventBus, EventBusConfig, EventSubscriber, get_event_bus, init_event_bus, ) import lmcache.v1.mp_observability.event_bus as _bus_module # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_event( event_type: EventType = EventType.L1_READ_FINISHED, session_id: str = "s1", **metadata, ) -> Event: return Event(event_type=event_type, session_id=session_id, metadata=metadata) class _RecordingSubscriber(EventSubscriber): """Test subscriber that records events.""" def __init__(self, event_types: list[EventType] | None = None): if event_types is None: event_types = [EventType.L1_READ_FINISHED] self._event_types = event_types self.events: list[Event] = [] self.shutdown_called = False def get_subscriptions(self): return {et: self._on_event for et in self._event_types} def _on_event(self, event: Event) -> None: self.events.append(event) def shutdown(self) -> None: self.shutdown_called = True # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def restore_global_bus(): """Save and restore the global singleton so tests don't leak state.""" saved = _bus_module._global_bus yield _bus_module._global_bus = saved @pytest.fixture def bus(): """Enabled EventBus with small queue for fast tests.""" return EventBus(EventBusConfig(enabled=True, max_queue_size=100)) # --------------------------------------------------------------------------- # Subscribe / publish basics # --------------------------------------------------------------------------- class TestSubscription: def test_subscribe_adds_callback(self, bus): cb = MagicMock() bus.subscribe(EventType.L1_READ_FINISHED, cb) assert cb in bus._subscribers[EventType.L1_READ_FINISHED] def test_multiple_callbacks_per_event_type(self, bus): cb1, cb2 = MagicMock(), MagicMock() bus.subscribe(EventType.L1_READ_FINISHED, cb1) bus.subscribe(EventType.L1_READ_FINISHED, cb2) assert len(bus._subscribers[EventType.L1_READ_FINISHED]) == 2 def test_register_subscriber(self, bus): sub = _RecordingSubscriber() bus.register_subscriber(sub) assert sub in bus._registered_subscribers assert len(bus._subscribers[EventType.L1_READ_FINISHED]) == 1 # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- class TestLifecycle: def test_start_launches_thread(self, bus): bus.start() assert bus._thread is not None assert bus._thread.is_alive() bus.stop() def test_disabled_noop(self): b = EventBus(EventBusConfig(enabled=False)) b.start() assert b._thread is None def test_thread_is_daemon(self, bus): bus.start() assert bus._thread.daemon is True bus.stop() def test_thread_name(self, bus): bus.start() assert bus._thread.name == "EventBus" bus.stop() def test_double_start_is_idempotent(self, bus): bus.start() thread = bus._thread bus.start() assert bus._thread is thread bus.stop() def test_double_stop(self, bus): bus.start() bus.stop() bus.stop() def test_stop_without_start(self, bus): bus.stop() # --------------------------------------------------------------------------- # Event dispatch # --------------------------------------------------------------------------- class TestEventDispatch: def test_event_reaches_subscriber(self, bus): sub = _RecordingSubscriber() bus.register_subscriber(sub) bus.start() bus.publish(_make_event()) time.sleep(0.15) bus.stop() assert len(sub.events) == 1 assert sub.events[0].event_type == EventType.L1_READ_FINISHED def test_publish_stamps_timestamp(self, bus): sub = _RecordingSubscriber() bus.register_subscriber(sub) bus.start() before = time.time() bus.publish(_make_event()) after = time.time() time.sleep(0.15) bus.stop() assert len(sub.events) == 1 assert before <= sub.events[0].timestamp <= after def test_events_dispatched_in_order(self, bus): sub = _RecordingSubscriber() bus.register_subscriber(sub) bus.start() for i in range(5): bus.publish(_make_event(session_id=str(i))) time.sleep(0.15) bus.stop() assert len(sub.events) == 5 for i, ev in enumerate(sub.events): assert ev.session_id == str(i) def test_event_only_dispatched_to_matching_subscribers(self, bus): l1_sub = _RecordingSubscriber([EventType.L1_READ_FINISHED]) sm_sub = _RecordingSubscriber([EventType.SM_WRITE_RESERVED]) bus.register_subscriber(l1_sub) bus.register_subscriber(sm_sub) bus.start() bus.publish(_make_event(event_type=EventType.L1_READ_FINISHED)) bus.publish(_make_event(event_type=EventType.SM_WRITE_RESERVED)) time.sleep(0.15) bus.stop() assert len(l1_sub.events) == 1 assert l1_sub.events[0].event_type == EventType.L1_READ_FINISHED assert len(sm_sub.events) == 1 assert sm_sub.events[0].event_type == EventType.SM_WRITE_RESERVED def test_multiple_subscribers_same_event(self, bus): sub1 = _RecordingSubscriber() sub2 = _RecordingSubscriber() bus.register_subscriber(sub1) bus.register_subscriber(sub2) bus.start() bus.publish(_make_event()) time.sleep(0.15) bus.stop() assert len(sub1.events) == 1 assert len(sub2.events) == 1 def test_metadata_preserved(self, bus): sub = _RecordingSubscriber() bus.register_subscriber(sub) bus.start() bus.publish(_make_event(count=42, name="test")) time.sleep(0.15) bus.stop() assert sub.events[0].metadata["count"] == 42 assert sub.events[0].metadata["name"] == "test" def test_disabled_bus_drops_events(self): b = EventBus(EventBusConfig(enabled=False)) sub = _RecordingSubscriber() b.register_subscriber(sub) b.publish(_make_event()) assert len(b._queue) == 0 def test_late_registered_subscriber(self, bus): bus.start() time.sleep(0.05) sub = _RecordingSubscriber() bus.register_subscriber(sub) bus.publish(_make_event()) time.sleep(0.15) bus.stop() assert len(sub.events) >= 1 def test_stop_drains_remaining_events(self, bus): sub = _RecordingSubscriber() bus.register_subscriber(sub) # Don't start the drain thread — events accumulate in the queue for _ in range(3): bus.publish(_make_event()) assert len(bus._queue) == 3 # stop() should do a final drain bus.stop() assert len(sub.events) == 3 # --------------------------------------------------------------------------- # Exception isolation # --------------------------------------------------------------------------- class TestExceptionIsolation: def test_bad_callback_doesnt_block_good(self, bus): bad_cb = MagicMock(side_effect=RuntimeError("boom")) bus.subscribe(EventType.L1_READ_FINISHED, bad_cb) good_sub = _RecordingSubscriber() bus.register_subscriber(good_sub) bus.start() bus.publish(_make_event()) time.sleep(0.15) bus.stop() assert len(good_sub.events) == 1 def test_shutdown_exception_isolated(self, bus): bad_sub = _RecordingSubscriber() bad_sub.shutdown = MagicMock(side_effect=RuntimeError("shutdown boom")) good_sub = _RecordingSubscriber() bus.register_subscriber(bad_sub) bus.register_subscriber(good_sub) bus.start() bus.stop() assert good_sub.shutdown_called # --------------------------------------------------------------------------- # Backpressure # --------------------------------------------------------------------------- class TestBackpressure: def test_events_discarded_when_queue_full(self): b = EventBus(EventBusConfig(enabled=True, max_queue_size=5)) # Don't start — nothing drains for _ in range(10): b.publish(_make_event()) assert len(b._queue) == 5 assert b._discard_count == 5 # --------------------------------------------------------------------------- # Self-monitoring accessors # --------------------------------------------------------------------------- class TestSelfMonitoring: def test_queue_depth_reports_current_size(self, bus): # Don't start the drain thread so events accumulate. assert bus.queue_depth() == 0 for _ in range(3): bus.publish(_make_event()) assert bus.queue_depth() == 3 def test_oldest_event_lag_zero_when_empty(self, bus): assert bus.oldest_event_lag_seconds() == 0.0 def test_oldest_event_lag_grows_with_age(self, bus): # Don't start drain — let events sit in the queue. bus.publish(_make_event()) time.sleep(0.05) lag = bus.oldest_event_lag_seconds() assert lag >= 0.05 # Drain via stop() so the bus shuts down cleanly. bus.stop() def test_oldest_event_lag_returns_zero_after_drain(self, bus): sub = _RecordingSubscriber() bus.register_subscriber(sub) bus.start() bus.publish(_make_event()) time.sleep(0.15) # Drained, queue should be empty. assert bus.oldest_event_lag_seconds() == 0.0 bus.stop() def test_dropped_events_count_starts_zero(self, bus): assert bus.dropped_events_count() == 0 def test_dropped_events_count_increments_on_drop(self): b = EventBus(EventBusConfig(enabled=True, max_queue_size=2)) # Don't start — nothing drains. for _ in range(5): b.publish(_make_event()) assert b.dropped_events_count() == 3 def test_subscriber_exception_counts_starts_empty(self, bus): assert bus.subscriber_exception_counts() == {} def test_subscriber_exception_counts_tracks_bound_methods(self, bus): class _BadSub(EventSubscriber): def get_subscriptions(self): return {EventType.L1_READ_FINISHED: self._on_event} def _on_event(self, event): raise RuntimeError("boom") bus.register_subscriber(_BadSub()) bus.start() bus.publish(_make_event()) bus.publish(_make_event()) time.sleep(0.15) bus.stop() counts = bus.subscriber_exception_counts() assert counts.get("_BadSub") == 2 def test_subscriber_exception_counts_returns_copy(self, bus): class _BadSub(EventSubscriber): def get_subscriptions(self): return {EventType.L1_READ_FINISHED: self._on_event} def _on_event(self, event): raise RuntimeError("boom") bus.register_subscriber(_BadSub()) bus.start() bus.publish(_make_event()) time.sleep(0.15) bus.stop() snapshot = bus.subscriber_exception_counts() snapshot["_BadSub"] = 99 assert bus.subscriber_exception_counts().get("_BadSub") == 1 def test_subscriber_exception_counts_uses_qualname_for_free_function(self, bus): def free_callback(event): raise RuntimeError("boom") bus.subscribe(EventType.L1_READ_FINISHED, free_callback) bus.start() bus.publish(_make_event()) time.sleep(0.15) bus.stop() counts = bus.subscriber_exception_counts() # Free functions are labeled by __qualname__, which here lives # inside the test method. assert any("free_callback" in name for name in counts) def test_good_subscriber_not_counted(self, bus): class _BadSub(EventSubscriber): def get_subscriptions(self): return {EventType.L1_READ_FINISHED: self._on_event} def _on_event(self, event): raise RuntimeError("boom") good = _RecordingSubscriber() bus.register_subscriber(_BadSub()) bus.register_subscriber(good) bus.start() bus.publish(_make_event()) time.sleep(0.15) bus.stop() counts = bus.subscriber_exception_counts() assert "_BadSub" in counts assert "_RecordingSubscriber" not in counts # --------------------------------------------------------------------------- # Global singleton # --------------------------------------------------------------------------- class TestGlobalSingleton: def test_get_returns_instance(self): bus = get_event_bus() assert isinstance(bus, EventBus) def test_init_replaces_global(self): old = get_event_bus() new = init_event_bus(EventBusConfig(enabled=False)) assert get_event_bus() is new assert get_event_bus() is not old def test_default_singleton_is_disabled(self): bus = get_event_bus() assert bus._config.enabled is False def test_init_with_none_uses_defaults(self): bus = init_event_bus() assert bus._config.enabled is True assert bus._config.max_queue_size == 10_000 # --------------------------------------------------------------------------- # Block allocation event # --------------------------------------------------------------------------- class TestBlockAllocationEvent: def test_publish_block_allocation_event(self, bus): """Verify MP_VLLM_BLOCK_ALLOCATION events are delivered to subscribers.""" sub = _RecordingSubscriber(event_types=[EventType.MP_VLLM_BLOCK_ALLOCATION]) bus.register_subscriber(sub) bus.start() # First Party from lmcache.v1.multiprocess.custom_types import BlockAllocationRecord records = [ BlockAllocationRecord( req_id="req-1", new_block_ids=[0, 1, 2], new_token_ids=[10, 20, 30], ), ] bus.publish( _make_event( event_type=EventType.MP_VLLM_BLOCK_ALLOCATION, session_id="", records=records, ) ) time.sleep(0.15) bus.stop() assert len(sub.events) == 1 evt = sub.events[0] assert evt.event_type == EventType.MP_VLLM_BLOCK_ALLOCATION assert len(evt.metadata["records"]) == 1 assert evt.metadata["records"][0].req_id == "req-1" assert evt.metadata["records"][0].new_block_ids == [0, 1, 2] def test_block_allocation_not_delivered_to_other_subscriber(self, bus): """Verify block allocation events are not delivered to unrelated subscribers.""" sub = _RecordingSubscriber(event_types=[EventType.L1_READ_FINISHED]) bus.register_subscriber(sub) bus.start() bus.publish( _make_event( event_type=EventType.MP_VLLM_BLOCK_ALLOCATION, session_id="", ) ) time.sleep(0.15) bus.stop() assert len(sub.events) == 0 _REGISTER_GAUGE = "lmcache.v1.mp_observability.event_bus.register_gauge" class TestSelfMonitoringGauges: def test_init_registers_both_gauges(self): with patch(_REGISTER_GAUGE) as mock_register: EventBus(EventBusConfig(enabled=True)) names = [c.args[1] for c in mock_register.call_args_list] assert set(names) == { "lmcache_mp.event_bus.queue_depth", "lmcache_mp.event_bus.drain_lag_seconds", } def test_queue_depth_callback_reflects_bus(self): captured: dict[str, object] = {} def _capture(_meter, name, _desc, func): captured[name] = func with patch(_REGISTER_GAUGE, side_effect=_capture): bus = EventBus(EventBusConfig(enabled=True)) bus.publish(_make_event(session_id="s1")) bus.publish(_make_event(session_id="s2")) assert captured["lmcache_mp.event_bus.queue_depth"]() == 2