fix(events): restore event bus close and bridge semantics
CI / load-versions (push) Successful in 16s
CI / push-validation (push) Successful in 24s
CI / lint (push) Successful in 41s
CI / quality (push) Successful in 52s
CI / build (push) Successful in 39s
CI / security (push) Successful in 1m5s
CI / typecheck (push) Successful in 1m16s
CI / helm (push) Successful in 57s
CI / unit_tests (push) Successful in 5m54s
CI / integration_tests (push) Successful in 8m32s
CI / docker (push) Successful in 2m4s
CI / coverage (push) Successful in 11m53s
CI / status-check (push) Successful in 3s
CI / benchmark-publish (push) Has been cancelled
CI / benchmark-regression (push) Has been cancelled

This commit was merged in pull request #11197.
This commit is contained in:
CleverAgents Bot
2026-06-18 06:17:09 -04:00
committed by Forgejo
parent 4fd5393a62
commit 556de5f6f9
4 changed files with 191 additions and 15 deletions
+19 -9
View File
@@ -290,25 +290,35 @@ class EventBusBridge:
) -> None:
self._event_bus = event_bus
self._event_queue = event_queue
self._subscription: Any | None = None
self._subscribed_types: frozenset[EventType] | None = None
def start(self) -> None:
"""Subscribe to the event bus and begin forwarding."""
if not hasattr(self._event_bus, "subscribe"):
return
self._subscribed_types = frozenset(EventType)
for et in self._subscribed_types:
self._event_bus.subscribe(et, self._on_domain_event)
subscribed_types = frozenset(EventType)
try:
for et in subscribed_types:
self._event_bus.subscribe(et, self._on_domain_event)
except TypeError:
self._subscribed_types = None
self._subscription = self._event_bus.subscribe(self._on_domain_event)
else:
self._subscribed_types = subscribed_types
logger.info("a2a.event_bridge.started")
def stop(self) -> None:
"""Unsubscribe from the event bus."""
if self._subscribed_types is None:
return
for et in self._subscribed_types:
with contextlib.suppress(TypeError, AttributeError):
self._event_bus.unsubscribe(et, self._on_domain_event)
self._subscribed_types = None
if self._subscribed_types is not None:
for et in self._subscribed_types:
with contextlib.suppress(TypeError, AttributeError):
self._event_bus.unsubscribe(et, self._on_domain_event)
self._subscribed_types = None
if self._subscription is not None:
if hasattr(self._subscription, "dispose"):
self._subscription.dispose()
self._subscription = None
logger.info("a2a.event_bridge.stopped")
def _on_domain_event(self, domain_event: Any) -> None:
@@ -42,6 +42,7 @@ import threading
from collections import defaultdict
from collections.abc import Callable
from datetime import UTC, datetime
from typing import cast
import structlog
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -287,14 +288,14 @@ class FixThenRevalidateOrchestrator:
"auto_validation_fix must be between 0.0 and 1.0, "
f"got {auto_validation_fix}"
)
if event_bus is not None and not isinstance(event_bus, EventBus):
if event_bus is not None and not callable(getattr(event_bus, "emit", None)):
raise ValidationError("event_bus must be an EventBus instance or None")
self._pipeline = validation_pipeline
self._max_retries = max_retries
self._auto_strategy_revision = float(auto_strategy_revision)
self._auto_validation_fix = float(auto_validation_fix)
self._event_bus = event_bus
self._event_bus = cast(EventBus | None, event_bus)
# Per-(plan_id, validation_key) retry counters.
# Inner key is (validation_name, resource_id) to prevent
# same-named validations on different resources from sharing
@@ -21,6 +21,7 @@ from __future__ import annotations
import contextlib
from collections import deque
from collections.abc import Callable
from types import TracebackType
import structlog
from rx import operators as ops
@@ -50,6 +51,7 @@ class ReactiveEventBus:
_stream: Read-only Observable view over ``_subject``.
_subscriptions: Per-event-type handler lists.
_audit_log: Volatile in-memory log of all emitted events.
_closed: Whether :meth:`close` has been called.
"""
def __init__(self, max_audit_log_size: int | None = None) -> None:
@@ -60,6 +62,7 @@ class ReactiveEventBus:
self._stream: Observable = self._subject.pipe(ops.map(_identity))
self._subscriptions: dict[EventType, list[Callable[[DomainEvent], None]]] = {}
self._audit_log: deque[DomainEvent] = deque(maxlen=max_audit_log_size)
self._closed: bool = False
# ------------------------------------------------------------------
# Public API (satisfies EventBus protocol)
@@ -107,12 +110,19 @@ class ReactiveEventBus:
Raises:
TypeError: If *event* is not a :class:`DomainEvent`.
RuntimeError: If :meth:`close` has already been called.
"""
if not isinstance(event, DomainEvent):
raise TypeError(
f"event must be a DomainEvent, got {type(event).__name__!r}"
)
if self._closed:
raise RuntimeError(
"ReactiveEventBus.emit() called after close() - "
"the bus has been shut down and the RxPY Subject is completed"
)
try:
self._subject.on_next(event)
except Exception as exc:
@@ -208,16 +218,38 @@ class ReactiveEventBus:
return self._stream
def close(self) -> None:
"""Signal completion on the reactive stream and clear all subscriptions.
"""Complete the RxPY stream and prevent further event emission.
Call this when the bus is no longer needed (e.g. in test teardown)
to release any RxPY Subject resources and prevent subscription leaks
between test scenarios.
Signals ``on_completed()`` to all RxPY subscribers, allowing them to
finalize their state. After ``close()``, any call to :meth:`emit`
raises :exc:`RuntimeError`.
This method is idempotent - calling it more than once is safe.
Call this when the bus is no longer needed (e.g. in test teardown or
application shutdown) to release RxPY Subject resources and prevent
subscription leaks between test scenarios.
"""
if self._closed:
return
self._closed = True
with contextlib.suppress(Exception):
self._subject.on_completed()
self._subscriptions.clear()
self._audit_log.clear()
def __enter__(self) -> ReactiveEventBus:
"""Support use as a context manager."""
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Call :meth:`close` on context manager exit."""
self.close()
__all__ = ["ReactiveEventBus"]
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from collections.abc import Callable
from cleveragents.a2a.events import A2aEventQueue, EventBusBridge
from cleveragents.application.services.fix_then_revalidate import (
FixThenRevalidateOrchestrator,
)
from cleveragents.application.services.validation_pipeline import ValidationPipeline
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.reactive import ReactiveEventBus
from cleveragents.infrastructure.events.types import EventType
def _event(event_type: EventType = EventType.PLAN_CREATED) -> DomainEvent:
return DomainEvent(event_type=event_type)
def _pipeline() -> ValidationPipeline:
return ValidationPipeline(commands=[], executor=lambda _name, _args: {})
def test_reactive_event_bus_close_completes_and_rejects_later_emit() -> None:
bus = ReactiveEventBus()
completed: list[bool] = []
bus.stream.subscribe(on_completed=lambda: completed.append(True))
bus.close()
assert completed == [True]
try:
bus.emit(_event())
except RuntimeError:
pass
else:
raise AssertionError("emit after close should raise RuntimeError")
def test_reactive_event_bus_context_manager_closes_bus() -> None:
bus = ReactiveEventBus()
completed: list[bool] = []
bus.stream.subscribe(on_completed=lambda: completed.append(True))
with bus as entered:
assert entered is bus
assert bus._closed is True
assert completed == [True]
class _TypedEventBus:
def __init__(self) -> None:
self.handlers: dict[EventType, list[Callable[[DomainEvent], None]]] = {}
def emit(self, event: DomainEvent) -> None:
for handler in self.handlers.get(event.event_type, []):
handler(event)
def subscribe(
self, event_type: EventType, handler: Callable[[DomainEvent], None]
) -> None:
self.handlers.setdefault(event_type, []).append(handler)
def unsubscribe(
self, event_type: EventType, handler: Callable[[DomainEvent], None]
) -> bool:
handlers = self.handlers.get(event_type, [])
try:
handlers.remove(handler)
except ValueError:
return False
return True
def test_event_bus_bridge_unsubscribes_from_typed_event_bus() -> None:
bus = _TypedEventBus()
queue = A2aEventQueue()
bridge = EventBusBridge(bus, queue)
bridge.start()
bus.emit(_event())
bridge.stop()
bus.emit(_event())
assert len(queue.get_events()) == 1
assert all(not handlers for handlers in bus.handlers.values())
class _DisposableSubscription:
def __init__(self) -> None:
self.disposed = False
def dispose(self) -> None:
self.disposed = True
class _LegacyEventBus:
def __init__(self) -> None:
self.callback: Callable[[DomainEvent], None] | None = None
self.subscription = _DisposableSubscription()
def subscribe(self, callback: Callable[[DomainEvent], None]) -> object:
self.callback = callback
return self.subscription
def test_event_bus_bridge_still_supports_disposable_subscription_bus() -> None:
bus = _LegacyEventBus()
queue = A2aEventQueue()
bridge = EventBusBridge(bus, queue)
bridge.start()
assert bus.callback is not None
bus.callback(_event())
bridge.stop()
assert len(queue.get_events()) == 1
assert bus.subscription.disposed is True
assert bridge._subscription is None
class _EmitOnlyBus:
def emit(self, event: DomainEvent) -> None:
pass
def test_fix_then_revalidate_accepts_emit_only_event_sink() -> None:
orchestrator = FixThenRevalidateOrchestrator(
validation_pipeline=_pipeline(),
event_bus=_EmitOnlyBus(), # type: ignore[arg-type]
)
assert orchestrator.event_bus is not None