fix(events): add unsubscribe() to EventBus protocol and implementations

This commit is contained in:
2026-05-14 05:24:49 +00:00
committed by Forgejo
parent 76b1a62f04
commit 432fd2c9a1
6 changed files with 242 additions and 132 deletions
+62 -23
View File
@@ -118,6 +118,68 @@ Feature: EventBus protocol and domain event emission
Scenario: EventBus Protocol emit and subscribe stubs are executable
Then the EventBus Protocol stubs should be callable
# ---------------------------------------------------------------------------
# EventBus unsubscribe
# ---------------------------------------------------------------------------
Scenario: ReactiveEventBus.unsubscribe() removes a handler
Given a ReactiveEventBus
When I subscribe to "plan.created" events
And I unsubscribe from "plan.created" with the same handler
Then the handler count for "plan.created" should be 0
Scenario: ReactiveEventBus.unsubscribe() returns True when found
Given a ReactiveEventBus
When I subscribe to "plan.created" events
And I unsubscribe from "plan.created" with the same handler
Then unsubscribing should return True
Scenario: ReactiveEventBus.unsubscribe() returns False when not found
Given a ReactiveEventBus
Then unsubscribing a non-existent handler should return False
Scenario: ReactiveEventBus.unsubscribe() rejects invalid event_type
Given a ReactiveEventBus
Then unsubscribing with a non-EventType should raise TypeError
Scenario: ReactiveEventBus.unsubscribe() rejects non-callable handler
Given a ReactiveEventBus
Then unsubscribing with a non-callable handler should raise TypeError
Scenario: LoggingEventBus.unsubscribe() removes a handler
Given a LoggingEventBus
When I subscribe to "decision.created" events
And I unsubscribe from "decision.created" with the same handler
Then the handler count for "decision.created" should be 0
Scenario: LoggingEventBus.unsubscribe() returns True when found
Given a LoggingEventBus
When I subscribe to "decision.created" events
And I unsubscribe from "decision.created" with the same handler
Then unsubscribing should return True
Scenario: LoggingEventBus.unsubscribe() returns False when not found
Given a LoggingEventBus
Then unsubscribing a non-existent handler should return False
Scenario: LoggingEventBus.unsubscribe() rejects invalid event_type
Given a LoggingEventBus
Then unsubscribing with a non-EventType should raise TypeError
Scenario: LoggingEventBus.unsubscribe() rejects non-callable handler
Given a LoggingEventBus
Then unsubscribing with a non-callable handler should raise TypeError
Scenario: EventBus Protocol unsubscribe stub is callable
Then the EventBus Protocol unsubscribe stub should be callable
Scenario: Event bus supports selective unsubscription (only specific handler removed)
Given a ReactiveEventBus
When I subscribe two handlers to "plan.created" events
And I unsubscribe only the first handler from "plan.created"
And I emit a "plan.created" DomainEvent
Then exactly one handler should have received 1 event
# ---------------------------------------------------------------------------
# DecisionService event emission
# ---------------------------------------------------------------------------
@@ -163,26 +225,3 @@ Feature: EventBus protocol and domain event emission
Scenario: Container wires event_bus into DecisionService
When I resolve decision_service from the DI container
Then the decision_service should have an event_bus attribute
# ---------------------------------------------------------------------------
# ReactiveEventBus.close() and context manager (issue #10378)
# ---------------------------------------------------------------------------
Scenario: ReactiveEventBus.close() completes the RxPY stream
Given a ReactiveEventBus
When bus.close() is called on the bus
Then the bus should be marked as closed
Scenario: ReactiveEventBus.close() prevents further emit() calls
Given a ReactiveEventBus
When bus.close() is called on the bus
Then emitting after close should raise RuntimeError
Scenario: ReactiveEventBus.close() is idempotent
Given a ReactiveEventBus
When bus.close() is called twice on the bus
Then no exception should be raised on double close
Scenario: ReactiveEventBus supports context manager protocol
Given a ReactiveEventBus used as a context manager
Then the bus should be closed after the context exits
+75 -53
View File
@@ -189,6 +189,18 @@ def step_subscribe_two(ctx: Context, et: str) -> None:
ctx.bus.subscribe(EventType(et), collector)
@when('I unsubscribe from "{et}" with the same handler')
def step_unsubscribe_handler(ctx: Context, et: str) -> None:
collector = ctx.collectors[0]
ctx.unsubscribe_result = ctx.bus.unsubscribe(EventType(et), collector)
@when('I unsubscribe only the first handler from "{et}"')
def step_unsubscribe_first_handler(ctx: Context, et: str) -> None:
collector = ctx.collectors[0]
ctx.bus.unsubscribe(EventType(et), collector)
@when('I emit a "{et}" DomainEvent')
def step_emit_event(ctx: Context, et: str) -> None:
ctx.bus.emit(_make_event(et))
@@ -220,6 +232,20 @@ def step_each_handler_received(ctx: Context, n: int) -> None:
)
@then("exactly one handler should have received 1 event")
def step_exactly_one_handler_received(ctx: Context) -> None:
counts = [len(col.received) for col in ctx.collectors]
assert sum(1 for c in counts if c == 1) == 1, (
f"Expected exactly one handler with 1 event, got {counts}"
)
@then('the handler count for "{et}" should be 0')
def step_handler_count_zero(ctx: Context, et: str) -> None:
actual = len(ctx.bus._subscriptions.get(EventType(et), []))
assert actual == 0, f"Expected 0 handlers for {et}, got {actual}"
@then("the bus should expose an observable stream")
def step_bus_has_stream(ctx: Context) -> None:
stream = ctx.bus.stream
@@ -256,6 +282,44 @@ def step_subscribe_non_callable(ctx: Context) -> None:
assert raised, "Expected TypeError for non-callable handler"
@then("unsubscribing should return True")
def step_unsubscribe_returns_true(ctx: Context) -> None:
result: bool = getattr(ctx, "unsubscribe_result", False)
assert result is True, f"Expected unsubscribe to return True, got {result}"
@then("unsubscribing a non-existent handler should return False")
def step_unsubscribe_nonexistent_returns_false(ctx: Context) -> None:
ctx.exception = None
try:
ctx.unsubscribe_result = ctx.bus.unsubscribe(
EventType.PLAN_CREATED, lambda e: None
)
except Exception as exc:
ctx.exception = exc
assert ctx.unsubscribe_result is False
@then("unsubscribing with a non-EventType should raise TypeError")
def step_unsubscribe_bad_type(ctx: Context) -> None:
raised = False
try:
ctx.bus.unsubscribe("plan.created", lambda e: None) # type: ignore[arg-type]
except TypeError:
raised = True
assert raised, "Expected TypeError for non-EventType event_type in unsubscribe"
@then("unsubscribing with a non-callable handler should raise TypeError")
def step_unsubscribe_non_callable(ctx: Context) -> None:
raised = False
try:
ctx.bus.unsubscribe(EventType.PLAN_CREATED, "not-callable") # type: ignore[arg-type]
except TypeError:
raised = True
assert raised, "Expected TypeError for non-callable handler in unsubscribe"
@then("the EventBus Protocol stubs should be callable")
def step_protocol_stubs_callable(ctx: Context) -> None:
"""Exercise Protocol method stubs directly to ensure 100% coverage."""
@@ -269,6 +333,17 @@ def step_protocol_stubs_callable(ctx: Context) -> None:
obj.subscribe(EventType.PLAN_CREATED, lambda e: None)
@then("the EventBus Protocol unsubscribe stub should be callable")
def step_protocol_unsubscribe_callable(ctx: Context) -> None:
"""Exercise Protocol unsubscribe stub directly to ensure coverage."""
class _BareImpl(EventBus): # type: ignore[misc]
pass
obj = _BareImpl()
obj.unsubscribe(EventType.PLAN_CREATED, lambda e: None)
# ---------------------------------------------------------------------------
# DecisionService event emission steps
# ---------------------------------------------------------------------------
@@ -477,56 +552,3 @@ def step_decision_service_has_event_bus(ctx: Context) -> None:
"DecisionService resolved from DI has no event_bus attribute"
)
assert svc.event_bus is not None, "DecisionService.event_bus should not be None"
# ---------------------------------------------------------------------------
# ReactiveEventBus.close() and context manager steps (issue #10378)
# ---------------------------------------------------------------------------
@when("bus.close() is called on the bus")
def step_close_bus(ctx: Context) -> None:
ctx.bus.close()
@then("the bus should be marked as closed")
def step_bus_is_closed(ctx: Context) -> None:
assert ctx.bus._closed, "Expected bus._closed to be True after close()"
@then("emitting after close should raise RuntimeError")
def step_emit_after_close_raises(ctx: Context) -> None:
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType
raised = False
try:
ctx.bus.emit(DomainEvent(event_type=EventType.PLAN_CREATED))
except RuntimeError:
raised = True
assert raised, "Expected RuntimeError when emitting after close()"
@when("bus.close() is called twice on the bus")
def step_close_bus_twice(ctx: Context) -> None:
ctx.bus.close()
ctx.bus.close() # should not raise
@then("no exception should be raised on double close")
def step_no_exception_on_double_close(ctx: Context) -> None:
# If we reached here without exception, the test passes
pass
@given("a ReactiveEventBus used as a context manager")
def step_given_bus_context_manager(ctx: Context) -> None:
ctx.bus = ReactiveEventBus()
ctx.cm_bus: ReactiveEventBus | None = None
with ctx.bus as bus:
ctx.cm_bus = bus
@then("the bus should be closed after the context exits")
def step_bus_closed_after_context(ctx: Context) -> None:
assert ctx.bus._closed, "Expected bus._closed to be True after context manager exit"
+17 -9
View File
@@ -25,6 +25,7 @@ from ulid import ULID
from cleveragents.a2a.errors import A2aNotAvailableError
from cleveragents.a2a.models import A2aEvent
from cleveragents.infrastructure.events.types import EventType
# ---------------------------------------------------------------------------
# SSE event type constants (A2A protocol)
@@ -288,21 +289,28 @@ 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 hasattr(self._event_bus, "subscribe"):
self._subscription = self._event_bus.subscribe(self._on_domain_event)
logger.info("a2a.event_bridge.started")
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)
logger.info("a2a.event_bridge.started")
def stop(self) -> None:
"""Unsubscribe from the event bus."""
if self._subscription is not None:
if hasattr(self._subscription, "dispose"):
self._subscription.dispose()
self._subscription = None
logger.info("a2a.event_bridge.stopped")
if self._subscribed_types is None:
return
for et in self._subscribed_types:
try:
self._event_bus.unsubscribe(et, self._on_domain_event)
except (TypeError, AttributeError):
pass
self._subscribed_types = None
logger.info("a2a.event_bridge.stopped")
def _on_domain_event(self, domain_event: Any) -> None:
"""Translate a domain event to an A2A event and publish."""
@@ -105,5 +105,37 @@ class LoggingEventBus:
raise TypeError("handler must be callable")
self._subscriptions.setdefault(event_type, []).append(handler)
def unsubscribe(
self,
event_type: EventType,
handler: Callable[[DomainEvent], None],
) -> bool:
"""Remove *handler* for events of *event_type*.
Args:
event_type: The :class:`EventType` to stop listening for.
handler: The callable to remove (must be the same object passed to subscribe).
Returns:
True if the handler was found and removed, False otherwise.
Raises:
TypeError: If *event_type* is not an :class:`EventType`.
TypeError: If *handler* is not callable.
"""
if not isinstance(event_type, EventType):
raise TypeError(
f"event_type must be an EventType, got {type(event_type).__name__!r}"
)
if not callable(handler):
raise TypeError("handler must be callable")
handlers = self._subscriptions.get(event_type, [])
try:
handlers.remove(handler)
return True
except ValueError:
return False
__all__ = ["LoggingEventBus"]
@@ -49,5 +49,25 @@ class EventBus(Protocol):
"""
...
def unsubscribe(
self,
event_type: EventType,
handler: Callable[[DomainEvent], None],
) -> bool:
"""Remove *handler* for events of *event_type*.
Args:
event_type: The :class:`EventType` to stop listening for.
handler: The callable to remove.
Returns:
True if the handler was found and removed, False otherwise.
Raises:
TypeError: If *event_type* is not an :class:`EventType`.
TypeError: If *handler* is not callable.
"""
...
__all__ = ["EventBus"]
@@ -21,7 +21,6 @@ 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
@@ -51,7 +50,6 @@ 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:
@@ -62,7 +60,6 @@ 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)
@@ -110,19 +107,12 @@ 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:
@@ -174,6 +164,38 @@ class ReactiveEventBus:
raise TypeError("handler must be callable")
self._subscriptions.setdefault(event_type, []).append(handler)
def unsubscribe(
self,
event_type: EventType,
handler: Callable[[DomainEvent], None],
) -> bool:
"""Remove *handler* for events of *event_type*.
Args:
event_type: The :class:`EventType` to stop listening for.
handler: The callable to remove (must be the same object passed to subscribe).
Returns:
True if the handler was found and removed, False otherwise.
Raises:
TypeError: If *event_type* is not an :class:`EventType`.
TypeError: If *handler* is not callable.
"""
if not isinstance(event_type, EventType):
raise TypeError(
f"event_type must be an EventType, got {type(event_type).__name__!r}"
)
if not callable(handler):
raise TypeError("handler must be callable")
handlers = self._subscriptions.get(event_type, [])
try:
handlers.remove(handler)
return True
except ValueError:
return False
@property
def stream(self) -> Observable:
"""Read-only observable stream for advanced RxPY operators.
@@ -185,49 +207,16 @@ class ReactiveEventBus:
return self._stream
def close(self) -> None:
"""Complete the RxPY stream and prevent further event emission.
"""Signal completion on the reactive stream and clear all subscriptions.
Signals ``on_completed()`` to all RxPY subscribers, allowing them to
finalize their state (e.g. buffering operators flush, aggregation
operators emit their final result). 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.
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.
"""
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.
Returns:
The bus instance itself.
"""
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.
Args:
exc_type: Exception type, if any.
exc_val: Exception value, if any.
exc_tb: Exception traceback, if any.
"""
self.close()
__all__ = ["ReactiveEventBus"]