📢 Fast multi-language Event Bus library for Python/TS/Golang/Rust with support for advanced concurrency control features, nested event tracking, type enforcement, bridges to other backends, and more...
12
stars
923
commits
Rust
primary language
Aug 28, 2026
updated
abxbus: 📢 Fast, in-memory, multi-language event busAbxBus is an in-memory event bus library for async Python, TypeScript (node/browser), Rust, and Go.
It's designed for quickly building resilient, predictable, complex event-driven apps.
It "just works" with an intuitive, but powerful event JSON format + emit API that's consistent across runtimes and scales consistently from one event up to millions (~0.2ms/event):
import asyncio
from abxbus import BaseEvent, EventBus
class SomeEvent(BaseEvent):
some_data: int
def handle_some_event(event: SomeEvent):
print('hi!')
async def main():
bus = EventBus()
bus.on(SomeEvent, handle_some_event)
await bus.emit(SomeEvent(some_data=132)).now()
asyncio.run(main())
# "hi!"
It's async native, has proper automatic nested event tracking, and powerful concurrency control options. The API is inspired by EventEmitter or emittery in JS, but it takes it a step further:
♾️ It's inspired by the simplicity of async and events in JS but with baked-in features that allow to eliminate most of the tedious repetitive complexity in event-driven codebases:
Install abxbus and get started with a simple event-driven application:
project_dir="$(mktemp -d)"
trap 'rm -rf "$project_dir"' EXIT
uv init --bare "$project_dir"
uv add --project "$project_dir" --editable "$PWD" # see ./abxbus-ts/README.md for JS instructions
uv run --project "$project_dir" python -c 'import abxbus'
import asyncio
from abxbus import EventBus, BaseEvent
class AuthRequestEvent(BaseEvent[str]):
username: str
class AuthResponseEvent(BaseEvent[dict[str, str]]):
token: str
class UserLoginEvent(BaseEvent[str]):
username: str
is_admin: bool
async def handle_auth_request(event: AuthRequestEvent):
await event.emit(AuthResponseEvent(token=f"token-for-{event.username}")).now()
async def handle_login(event: UserLoginEvent) -> str:
auth_request = await event.emit(AuthRequestEvent(username=event.username)).now() # nested events supported
auth_response = await event.event_bus.find(AuthResponseEvent, child_of=auth_request, future=30)
return f"User {event.username} logged in admin={event.is_admin} with API response: {await auth_response.event_result()}"
async def main():
bus = EventBus()
bus.on(UserLoginEvent, handle_login)
bus.on(AuthRequestEvent, handle_auth_request)
event = await bus.emit(UserLoginEvent(username="alice", is_admin=True)).now()
print(await event.event_result())
asyncio.run(main())
# User alice logged in admin=True with API response: {'token': 'token-for-alice'}
Subscribe to events using multiple patterns:
# By event model class (recommended for best type hinting)
bus.on(UserActionEvent, handler)
# By event type string
bus.on('UserActionEvent', handler)
# Wildcard - handle all events
bus.on('*', universal_handler)
Register both synchronous and asynchronous handlers for maximum flexibility:
# Async handler
async def async_handler(event: SomeEvent) -> str:
await asyncio.sleep(0.1) # Simulate async work
return "async result"
# Sync handler
def sync_handler(event: SomeEvent) -> str:
return "sync result"
bus.on(SomeEvent, async_handler)
bus.on(SomeEvent, sync_handler)
Handlers can also be defined under classes for easier organization:
class SomeService:
some_value = 'this works'
async def handlers_can_be_methods(self, event: SomeEvent) -> str:
return self.some_value
@classmethod
async def handler_can_be_classmethods(cls, event: SomeEvent) -> str:
return cls.some_value
@staticmethod
async def handlers_can_be_staticmethods(event: SomeEvent) -> str:
return 'this works too'
# All usage patterns behave the same:
bus.on(SomeEvent, SomeService().handlers_can_be_methods)
bus.on(SomeEvent, SomeService.handler_can_be_classmethods)
bus.on(SomeEvent, SomeService.handlers_can_be_staticmethods)
Define events as Pydantic models with full type checking and validation:
from typing import Any
from abxbus import BaseEvent
class OrderCreatedEvent(BaseEvent):
order_id: str
customer_id: str
total_amount: float
items: list[dict[str, Any]]
# Events are automatically validated
event = OrderCreatedEvent(
order_id="ORD-123",
customer_id="CUST-456",
total_amount=99.99,
items=[{"sku": "ITEM-1", "quantity": 2}]
)
[!TIP] You can also enforce the types of event handler return values.
You can define separate EventBus instances in different "microservices" to separate different areas of concern.
EventBuss can be set up to forward events between each other (with automatic loop prevention):
# Create a hierarchy of buses
main_bus = EventBus(name='MainBus')
auth_bus = EventBus(name='AuthBus')
data_bus = EventBus(name='DataBus')
# Share all or specific events between buses
main_bus.on('*', auth_bus.emit) # if main bus gets LoginEvent, will forward to AuthBus
auth_bus.on('*', data_bus.emit) # auth bus will forward everything to DataBus
data_bus.on('*', main_bus.emit) # don't worry! event will only be processed once by each, no infinite loop occurs
# Events flow through the hierarchy with tracking
async def main():
event = main_bus.emit(LoginEvent())
await event.now()
print(event.event_path) # ['MainBus#ab12', 'AuthBus#cd34', 'DataBus#ef56'] # list of bus labels that already processed the event
asyncio.run(main())
Collect results from multiple handlers:
async def load_user_config(event: GetConfigEvent) -> dict[str, Any]:
return {"debug": True, "port": 8080}
async def load_system_config(event: GetConfigEvent) -> dict[str, Any]:
return {"debug": False, "timeout": 30}
bus.on(GetConfigEvent, load_user_config)
bus.on(GetConfigEvent, load_system_config)
# Get all handler result values
async def main():
event = await bus.emit(GetConfigEvent()).now()
results = await event.event_results_list()
# Inspect per-handler metadata when needed
for handler_id, event_result in event.event_results.items():
print(handler_id, event_result.handler_name, event_result.result)
asyncio.run(main())
By default, events and their handlers are processed in strict serial FIFO order, maintaining consistency:
# Events are processed in the order they were emitted
async def main():
for i in range(10):
bus.emit(ProcessTaskEvent(task_id=i))
# Even with async handlers, order is preserved
await bus.wait_until_idle(timeout=30.0)
asyncio.run(main())
If a handler emits and awaits any child events during execution, those events will jump the FIFO queue and be processed immediately:
def child_handler(event: SomeOtherEvent) -> str:
return 'xyz123'
async def main_handler(event: MainEvent) -> str:
# emit a linked child event
child_event = event.emit(SomeOtherEvent())
# now() marks it as parent-completion-blocking and can queue-jump it
completed_child_event = await child_event.now()
return f'result from awaiting child event: {await completed_child_event.event_result()}' # 'xyz123'
bus.on(SomeOtherEvent, child_handler)
bus.on(MainEvent, main_handler)
async def main():
main_event = await bus.emit(MainEvent()).now()
print(await main_event.event_result())
asyncio.run(main())
# result from awaiting child event: xyz123
You can also set event_concurrency='parallel' and event_handler_concurrency='parallel' options per-bus, per-event, or per-handler enable parallel processing when needed.
Automatically track event relationships and causality tree:
async def parent_handler(event: BaseEvent):
# Most handler code should use this: linked child work that blocks parent completion.
blocking_child = await event.emit(ChildEvent()).now()
assert blocking_child.event_parent_id == event.event_id
assert blocking_child.event_blocks_parent_completion is True
# Linked background work keeps ancestry but does not hold the parent open.
linked_background_child = event.emit(ChildEvent())
assert linked_background_child.event_parent_id == event.event_id
assert linked_background_child.event_blocks_parent_completion is False
# Awaiting bus.emit(...) blocks this handler naturally, but creates a top-level event.
detached_blocking_event = await event.event_bus.emit(ChildEvent()).now()
assert detached_blocking_event.event_parent_id is None
assert detached_blocking_event.event_blocks_parent_completion is False
# Un-awaited bus.emit(...) is a true detached background event.
detached_background_event = event.event_bus.emit(ChildEvent())
assert detached_background_event.event_parent_id is None
assert detached_background_event.event_blocks_parent_completion is False
async def run_main():
bus.on(ChildEvent, child_handler)
bus.on(ParentEvent, parent_handler)
parent_event = bus.emit(ParentEvent())
print(parent_event.event_children) # show all the child events emitted during handling of an event
await parent_event.now()
print(bus.log_tree())
await bus.destroy()
if __name__ == '__main__':
asyncio.run(run_main())
find() is the single lookup API: search history, wait for future events, or combine both to check for an existing recent event before emitting a new one.
async def main():
# Default: non-blocking history lookup (past=True, future=False)
existing = await bus.find(ResponseEvent)
# Wait only for future matches
asyncio.create_task(emit_response_soon())
future = await bus.find(ResponseEvent, past=False, future=5)
# Combine event predicate + event metadata filters
match = await bus.find(
ResponseEvent,
where=lambda e: e.request_id == my_id,
event_status='completed',
future=5,
)
# Wildcard: match any event type, filtered by metadata/predicate
any_completed = await bus.find(
'*',
where=lambda e: e.event_type.endswith('ResultEvent'),
event_status='completed',
future=5,
)
asyncio.run(main())
When you emit an event that triggers child events, use child_of to find specific descendants:
async def main():
# Emit a parent event that triggers child events
nav_event = await bus.emit(NavigateToUrlEvent(url="https://example.com")).now()
# Find a child event (already fired while NavigateToUrlEvent was being handled)
new_tab = await bus.find(TabCreatedEvent, child_of=nav_event, past=5)
if new_tab:
print(f"New tab created: {new_tab.tab_id}")
return new_tab
new_tab = asyncio.run(main())
This solves race conditions where child events fire before you start waiting for them.
filter()filter() takes the same arguments as find() but returns the list of all matching events
(newest to oldest), plus an optional limit argument to cap the result count.
async def main():
recent = await bus.filter(ResponseEvent, past=10, future=False, limit=5)
return recent
recent = asyncio.run(main())
See the EventBus.find(...) API section below for full parameter details.
[!IMPORTANT]
find()resolves when the event is first emitted to theEventBus, not when it completes. Useawait event.now()for immediate-await semantics (queue-jumps when called inside a handler), orawait event.wait()to always wait in normal queue order. Python also supportsawait eventas a Python-only shortcut forawait event.now(). If no match is found (or future timeout elapses),find()returnsNone.
Avoid re-running expensive work by reusing recent events. The find() method makes debouncing simple:
async def main():
# Simple debouncing: reuse event from last 10 seconds, or emit new
event = await bus.find(ScreenshotEvent, past=10, future=False) or bus.emit(ScreenshotEvent())
event = await event.now()
# Advanced: check history, wait briefly for new event to appear, fallback to emit new event
event = (
await bus.find(SyncEvent, past=True, future=False) # Check all history (instant)
or await bus.find(SyncEvent, past=False, future=5) # Wait up to 5s for in-flight
or bus.emit(SyncEvent()) # Fallback: emit new
)
await event.now() # get completed event
asyncio.run(main())
There are two ways to get return values from event handlers:
1. Have handlers return their values directly, which puts them in event.event_results:
import asyncio
from abxbus import BaseEvent, EventBus
class DoSomeMathEvent(BaseEvent[int]): # BaseEvent[int] = handlers are validated as returning int
a: int
b: int
# int passed above gets saved to:
# event_result_type = int
def do_some_math(event: DoSomeMathEvent) -> int:
return event.a + event.b
event_bus = EventBus()
event_bus.on(DoSomeMathEvent, do_some_math)
async def main():
event = await event_bus.emit(DoSomeMathEvent(a=100, b=120)).now(first_result=True)
print(await event.event_result())
await event_bus.destroy()
asyncio.run(main())
# 220
You can use these helpers to interact with the results returned by handlers:
BaseEvent.event_result()BaseEvent.event_results_list()BaseEvent.event_results2. Have the handler do the work, then emit another event containing the result value, which other code can find:
import asyncio
from abxbus import BaseEvent, EventBus
class DoSomeMathEvent(BaseEvent):
a: int
b: int
class MathCompleteEvent(BaseEvent):
final_sum: int
event_bus = EventBus()
async def do_some_math(event: DoSomeMathEvent) -> None:
result = event.a + event.b
await event.emit(MathCompleteEvent(final_sum=result)).now()
event_bus.on(DoSomeMathEvent, do_some_math)
async def main():
await event_bus.emit(DoSomeMathEvent(a=100, b=120)).now()
result_event = await event_bus.find(MathCompleteEvent, past=True, future=False)
assert result_event is not None
print(result_event.final_sum)
await event_bus.destroy()
asyncio.run(main())
# 220
These events can also be emitted automatically for you if you enable the AutoReturnEventMiddleware.
AbxBus supports optional strict typing for Event handler return values using a generic parameter passed to BaseEvent[ReturnTypeHere].
For example if you use BaseEvent[str], abxbus would enforce that all handler functions must return str | None at compile-time via IDE/mypy/pyright/ty type hints, and at runtime when each handler finishes.
import asyncio
from abxbus import BaseEvent, EventBus
class ScreenshotEvent(BaseEvent[bytes]): # BaseEvent[bytes] will enforce that handlers can only return bytes
width: int
height: int
async def on_ScreenshotEvent(event: ScreenshotEvent) -> bytes:
return b'someimagebytes...' # ✅ IDE type-hints & runtime both enforce return type matches expected: bytes
# return 123 # ❌ will show mypy/pyright issue + raise TypeError if the wrong type is returned
event_bus = EventBus()
event_bus.on(ScreenshotEvent, on_ScreenshotEvent)
async def main():
# Handler return values are automatically validated against the bytes type
event = await event_bus.emit(ScreenshotEvent(width=100, height=100)).now(first_result=True)
returned_bytes = await event.event_result()
assert isinstance(returned_bytes, bytes)
await event_bus.destroy()
asyncio.run(main())
Important: The validation uses Pydantic's TypeAdapter, which validates but does not coerce types. Handlers must return the exact type specified or None:
from abxbus import BaseEvent
class StringEvent(BaseEvent[str]):
pass
# ✅ This works - returns the expected str type
def good_handler(event: StringEvent) -> str:
return "hello"
# ❌ This fails validation - returns int instead of str
def bad_handler(event: StringEvent) -> str:
return 42 # ValidationError: expected str, got int
This also works with complex types and Pydantic models:
import asyncio
from uuid import UUID
from pydantic import BaseModel
from abxbus import BaseEvent, EventBus
class EmailMessage(BaseModel):
subject: str
content_len: int
email_from: str
class FetchInboxEvent(BaseEvent[list[EmailMessage]]):
account_id: UUID
auth_key: str
class GmailAPI:
@staticmethod
def get_msgs(account_id: UUID) -> list[EmailMessage]:
return [EmailMessage(subject=f"inbox-{account_id}", content_len=42, email_from="sender@example.com")]
async def fetch_from_gmail(event: FetchInboxEvent) -> list[EmailMessage]:
return GmailAPI.get_msgs(event.account_id)
event_bus = EventBus()
event_bus.on(FetchInboxEvent, fetch_from_gmail)
async def main():
# Return values are automatically validated as list[EmailMessage]
event = await event_bus.emit(
FetchInboxEvent(account_id=UUID("00000000-0000-4000-8000-000000000124"), auth_key="secret")
).now(first_result=True)
email_list = await event.event_result()
assert email_list[0].email_from == "sender@example.com"
await event_bus.destroy()
asyncio.run(main())
For pure Python usage, event_result_type can be any Python/Pydantic type you want. For cross-language JSON roundtrips, object-like shapes (e.g. TypedDict, dataclass, model-like dict schemas) rehydrate on Python as Pydantic models, map keys are constrained to JSON object string keys, and fine-grained string constraints/custom field validator logic is not preserved.
ContextVars set before emit() are automatically propagated to event handlers. This is essential for request-scoped context like request IDs, user sessions, or tracing spans:
import asyncio
from contextvars import ContextVar
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
bus = EventBus()
# Define your context variables
request_id: ContextVar[str] = ContextVar('request_id', default='<unset>')
user_id: ContextVar[str] = ContextVar('user_id', default='<unset>')
async def handler(event: MyEvent) -> str:
# Handler sees the context values that were set before emit()
print(f"Request: {request_id.get()}, User: {user_id.get()}")
return "done"
bus.on(MyEvent, handler)
# Set context before emit (e.g., in FastAPI middleware)
request_id.set('req-12345')
user_id.set('user-abc')
# Handler will see request_id='req-12345' and user_id='user-abc'
async def main():
await bus.emit(MyEvent()).now()
await bus.destroy()
asyncio.run(main())
Context propagates through nested handlers:
import asyncio
from contextvars import ContextVar
from abxbus import BaseEvent, EventBus
request_id: ContextVar[str] = ContextVar('request_id', default='<unset>')
class ParentEvent(BaseEvent[str]):
pass
class ChildEvent(BaseEvent[str]):
pass
bus = EventBus()
async def parent_handler(event: ParentEvent) -> str:
# Context is captured at emit time
print(f"Parent sees: {request_id.get()}") # 'req-12345'
# Child events inherit the same context
await event.emit(ChildEvent()).now()
return "parent_done"
async def child_handler(event: ChildEvent) -> str:
# Child also sees the original emit context
print(f"Child sees: {request_id.get()}") # 'req-12345'
return "child_done"
bus.on(ParentEvent, parent_handler)
bus.on(ChildEvent, child_handler)
async def main():
request_id.set('req-12345')
await bus.emit(ParentEvent()).now()
await bus.destroy()
asyncio.run(main())
Context isolation between emits:
Each emit captures its own context snapshot. Concurrent emits with different context values are properly isolated:
import asyncio
from contextvars import ContextVar
from abxbus import BaseEvent, EventBus
request_id: ContextVar[str] = ContextVar('request_id', default='<unset>')
class MyEvent(BaseEvent[str]):
pass
async def handler(event: MyEvent) -> str:
return request_id.get()
bus = EventBus()
bus.on(MyEvent, handler)
async def main():
request_id.set('req-A')
event_a = bus.emit(MyEvent()) # Handler A sees 'req-A'
request_id.set('req-B')
event_b = bus.emit(MyEvent()) # Handler B sees 'req-B'
await event_a.now() # Still sees 'req-A'
await event_b.now() # Still sees 'req-B'
await bus.destroy()
asyncio.run(main())
[!NOTE] Context is captured at
emit()time, not when the handler executes. This ensures handlers see the context from the call site, even if the event is processed later from a queue.
EventBus includes automatic memory management to prevent unbounded growth in long-running applications:
import asyncio
from abxbus import EventBus
# Create a bus with memory limits (default: 100 events)
bus = EventBus(max_history_size=100) # Keep max 100 events in history
# Or disable memory limits for unlimited history
bus = EventBus(max_history_size=None)
# Or keep only in-flight events in history (drop each event as soon as it completes)
bus = EventBus(max_history_size=0)
# Or reject new emits when history is full (instead of dropping old history)
bus = EventBus(max_history_size=100, max_history_drop=False)
asyncio.run(bus.destroy())
Automatic Cleanup:
max_history_size is set and max_history_drop=True, EventBus removes old events when the limit is exceededmax_history_size=0, history keeps only pending/started events and drops each event immediately after completionmax_history_drop=True, the bus may drop oldest history entries even if they are uncompleted eventsManual Memory Management:
import asyncio
from abxbus import BaseEvent, EventBus
class ProcessRequestEvent(BaseEvent[str]):
request_id: str
class EventService:
bus: EventBus
async def on_ProcessRequestEvent(self, event: ProcessRequestEvent) -> str:
return f"ok:{event.request_id}"
def __init__(self):
self.bus = EventBus()
self.bus.on(ProcessRequestEvent, self.on_ProcessRequestEvent)
# For request-scoped buses (e.g. web servers), clear all memory after each request
async def main():
try:
event_service = EventService() # Creates internal EventBus
event = await event_service.bus.emit(ProcessRequestEvent(request_id="req-1")).now()
assert await event.event_result() == "ok:req-1"
finally:
# Clear all event history and remove from global tracking
await event_service.bus.destroy(clear=True)
asyncio.run(main())
Memory Monitoring:
bus.destroy(clear=True) to completely free memory for unused buses[!CAUTION] Not Recommended. Only for advanced users willing to implement their own concurrency control.
Enable parallel processing of handlers for better performance.
The harsh tradeoff is less deterministic ordering as handler execution order will not be guaranteed when run in parallel.
(It's very hard to write non-flaky/reliable applications when handler execution order is not guaranteed.)
import asyncio
import time
from abxbus import BaseEvent, EventBus
class DataEvent(BaseEvent):
pass
async def slow_handler_1(event: DataEvent) -> None:
await asyncio.sleep(0.01)
async def slow_handler_2(event: DataEvent) -> None:
await asyncio.sleep(0.01)
# Create bus with parallel handler execution
bus = EventBus(event_handler_concurrency='parallel')
# Multiple handlers run concurrently for each event
bus.on('DataEvent', slow_handler_1) # Takes 1 second
bus.on('DataEvent', slow_handler_2) # Takes 1 second
async def main():
start = time.time()
await bus.emit(DataEvent()).now()
assert time.time() - start < 0.1
await bus.destroy()
asyncio.run(main())
# Total time: ~1 second (not 2)
Middlewares can observe or mutate the EventResult at each step, emit additional events, or trigger other side effects (metrics, retries, auth checks, etc.).
import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from abxbus import BaseEvent, EventBus
from abxbus.middlewares import LoggerEventBusMiddleware, OtelTracingMiddleware, SQLiteHistoryMirrorMiddleware, WALEventBusMiddleware
class SecondEventAbc(BaseEvent):
some_key: str
async def handler(event: SecondEventAbc) -> str:
return event.some_key
with TemporaryDirectory() as temp_dir:
output_dir = Path(temp_dir)
sqlite_path = output_dir / 'events.sqlite3'
wal_path = output_dir / 'events.jsonl'
log_path = output_dir / 'events.log'
bus = EventBus(
name='MyBus',
middlewares=[
SQLiteHistoryMirrorMiddleware(sqlite_path),
WALEventBusMiddleware(wal_path),
LoggerEventBusMiddleware(log_path),
OtelTracingMiddleware(),
# ...
],
)
bus.on(SecondEventAbc, handler)
async def main():
await bus.emit(SecondEventAbc(some_key="banana")).now()
await bus.destroy()
asyncio.run(main())
assert sqlite_path.exists() and wal_path.exists() and log_path.exists()
Built-in middlewares you can import from abxbus.middlewares:
AutoErrorEventMiddleware: on handler error, fire-and-forget emits OriginalEventTypeErrorEvent with {error, error_type} (skips *ErrorEvent/*ResultEvent sources). Useful when downstream/remote consumers only see events and need explicit failure notifications.AutoReturnEventMiddleware: on non-None handler return, fire-and-forget emits OriginalEventTypeResultEvent with {data} (skips *ErrorEvent/*ResultEvent sources). Useful for bridges/remote systems since handler return values do not cross bridge boundaries, but events do.AutoHandlerChangeEventMiddleware: emits BusHandlerRegisteredEvent({handler}) / BusHandlerUnregisteredEvent({handler}) when handlers are added/removed via .on() / .off().OtelTracingMiddleware: emits OpenTelemetry spans for events and handlers with parent-child linking; can be exported to Sentry via Sentry's OpenTelemetry integration.WALEventBusMiddleware: persists completed events to JSONL for replay/debugging.LoggerEventBusMiddleware: writes event/handler transitions to stdout and optionally to file.SQLiteHistoryMirrorMiddleware: mirrors event and handler snapshots into append-only SQLite events_log and event_results_log tables for auditing/debugging.Handler middlewares subclass EventBusMiddleware and override whichever lifecycle hooks they need (on_event_change, on_event_result_change, on_bus_handlers_change):
from abxbus.middlewares import EventBusMiddleware
class AnalyticsMiddleware(EventBusMiddleware):
async def on_event_result_change(self, eventbus, event, event_result, status):
if status == 'started':
await analytics_bus.emit(HandlerStartedAnalyticsEvent(event_id=event_result.event_id)).now()
elif status == 'completed':
await analytics_bus.emit(
HandlerCompletedAnalyticsEvent(
event_id=event_result.event_id,
error=repr(event_result.error) if event_result.error else None,
)
).now()
async def on_bus_handlers_change(self, eventbus, handler, registered):
await analytics_bus.emit(
HandlerRegistryChangedEvent(handler_id=handler.id, registered=registered, bus=eventbus.name)
).now()
EventBusThe main event bus class that manages event processing and handler execution.
from inspect import signature
from abxbus import EventBus
parameters = signature(EventBus).parameters
assert parameters['event_concurrency'].default is None
assert parameters['event_handler_concurrency'].default.value == 'serial'
assert parameters['event_handler_completion'].default.value == 'all'
assert parameters['event_timeout'].default == 60.0
assert parameters['max_history_size'].default == 100
Parameters:
name: Optional unique name for the bus (auto-generated if not provided)event_concurrency: Default event scheduling mode: 'global-serial', 'bus-serial' (default), or 'parallel' (resolved at processing time when event.event_concurrency is unset)event_handler_concurrency: Default handler execution mode for events on this bus: 'serial' (default) or 'parallel' (resolved at processing time when event.event_handler_concurrency is unset)event_handler_completion: Handler completion mode for each event: 'all' (default, wait for all handlers) or 'first' (complete once first successful non-None result is available), resolved at processing time when event.event_handler_completion is unsetevent_timeout: Default per-event timeout in seconds resolved at processing time when event.event_timeout is Noneevent_slow_timeout: Default slow-event warning threshold in seconds resolved at processing time when event.event_slow_timeout is Noneevent_handler_slow_timeout: Default slow-handler warning threshold in seconds resolved at processing time when event.event_handler_slow_timeout is Noneevent_handler_detect_file_paths: Whether to auto-detect handler source file paths at registration time (slightly slower when enabled)max_history_size: Maximum number of events to keep in history (default: 100, None = unlimited, 0 = keep only in-flight events and drop completed events immediately)max_history_drop: If True, drop oldest history entries when full (even uncompleted events). If False (default), reject new emits once history reaches max_history_size (except when max_history_size=0, which never rejects on history size)middlewares: Optional list of EventBusMiddleware subclasses or instances that hook into handler execution for analytics, logging, retries, etc. (see Middlewares for more info)Timeout precedence matches TS:
min(resolved_handler_timeout, event_timeout) where resolved_handler_timeout resolves in order: handler.handler_timeout -> event.event_handler_timeout -> bus.event_timeout.handler.handler_slow_timeout -> event.event_handler_slow_timeout -> bus.event_handler_slow_timeout.EventBus Propertiesname: The bus identifierid: Unique UUID7 for this bus instanceevent_history: Dict of all events the bus has seen by event_id (limited by max_history_size)events_pending: List of events waiting to be processedevents_started: List of events currently being processedevents_completed: List of completed eventsall_instances: Class-level WeakSet tracking all active EventBus instances (for memory monitoring)EventBus Methodson(event_type: str | Type[BaseEvent], handler: Callable)Subscribe a handler to events matching a specific event type or '*' for all events.
import asyncio
from abxbus import BaseEvent, EventBus
class UserEvent(BaseEvent[str]):
pass
async def handler_func(event: UserEvent) -> str:
return event.event_type
bus = EventBus()
bus.on('UserEvent', handler_func) # By event type string
bus.on(UserEvent, handler_func) # By event class
bus.on('*', handler_func) # Wildcard - all events
asyncio.run(bus.destroy())
emit(event: BaseEvent) -> BaseEventEnqueue an event for processing and return the pending Event immediately (synchronous).
import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
data: str
async def handler(event: MyEvent) -> str:
return event.data
async def main():
bus = EventBus()
bus.on(MyEvent, handler)
event = bus.emit(MyEvent(data="test"))
result = await event.now()
result_in_queue_order = await event.wait()
assert result is event and result_in_queue_order is event
assert await event.event_result() == 'test'
await bus.destroy()
asyncio.run(main())
Note: Queueing is unbounded. History pressure is controlled by max_history_size + max_history_drop:
max_history_drop=True: absorb new events and trim old history entries (even uncompleted events).max_history_drop=False: raise RuntimeError when history is full.max_history_size=0: keep pending/in-flight events only; completed events are immediately removed from history.find(event_type: str | Literal['*'] | Type[BaseEvent], *, where: Callable[[BaseEvent], bool]=None, child_of: BaseEvent | None=None, past: bool | float | timedelta=True, future: bool | float=False, **event_fields) -> BaseEvent | NoneFind an event matching criteria in history and/or future. This is the recommended unified method for event lookup.
Parameters:
event_type: The event type string, '*' wildcard, or model class to findwhere: Predicate function for filtering (default: matches all)child_of: Only match events that are descendants of this parent eventpast: Controls history search behavior (default: True)
True: search all historyFalse: skip history searchfloat/timedelta: search events from last N seconds onlyfuture: Controls future wait behavior (default: False)
True: wait forever for matching eventFalse: don't wait for future eventsfloat: wait up to N seconds for matching event**event_fields: Optional equality filters for any event fields (for example event_status='completed', user_id='u-1')import asyncio
from abxbus import BaseEvent, EventBus
class ResponseEvent(BaseEvent[None]):
request_id: str
async def main():
bus = EventBus()
completed = await bus.emit(ResponseEvent(request_id='req-1')).now()
assert await bus.find(ResponseEvent) is completed
assert await bus.find(ResponseEvent, past=5, future=False) is completed
assert await bus.find(ResponseEvent, event_status='completed') is completed
assert await bus.find('*', event_status='completed', past=True, future=False) is completed
await bus.destroy()
asyncio.run(main())
filter(event_type, *, limit: int | None=None, ...) -> list[BaseEvent]Same as find()
but returns the list of all matching events (newest to oldest) instead of just the first match.
Accepts an additional limit argument to cap the result count.
import asyncio
from abxbus import BaseEvent, EventBus
class ResponseEvent(BaseEvent[None]):
request_id: str
async def main():
bus = EventBus()
await bus.emit(ResponseEvent(request_id='req-1')).now()
recent = await bus.filter(ResponseEvent, past=10, future=False, limit=5)
assert len(recent) == 1 and recent[0].request_id == 'req-1'
await bus.destroy()
asyncio.run(main())
event_is_child_of(event: BaseEvent, ancestor: BaseEvent) -> boolCheck if event is a descendant of ancestor (child, grandchild, etc.).
import asyncio
from abxbus import BaseEvent, EventBus
bus = EventBus()
parent_event = BaseEvent()
child_event = BaseEvent(event_parent_id=parent_event.event_id)
assert bus.event_is_child_of(child_event, parent_event)
asyncio.run(bus.destroy())
event_is_parent_of(event: BaseEvent, descendant: BaseEvent) -> boolCheck if event is an ancestor of descendant (parent, grandparent, etc.).
import asyncio
from abxbus import BaseEvent, EventBus
bus = EventBus()
parent_event = BaseEvent()
child_event = BaseEvent(event_parent_id=parent_event.event_id)
assert bus.event_is_parent_of(parent_event, child_event)
asyncio.run(bus.destroy())
wait_until_idle(timeout: float | None=None)Wait until all events are processed and the bus is idle.
import asyncio
from abxbus import EventBus
async def main():
bus = EventBus()
await bus.wait_until_idle()
await bus.wait_until_idle(timeout=5.0)
await bus.destroy()
asyncio.run(main())
destroy(clear: bool=True)Destroy the event bus immediately. In-flight work is cancelled best-effort, future waiters are resolved, and the bus cannot be used again.
import asyncio
from abxbus import EventBus
async def main():
await EventBus().destroy()
await EventBus().destroy(clear=False)
asyncio.run(main())
BaseEventBase class for all events. Subclass BaseEvent to define your own events.
Make sure none of your own event data fields start with event_ or model_ to avoid clashing with BaseEvent or pydantic builtin attrs.
BaseEvent Fieldsfrom abxbus import BaseEvent
documented_fields = {
'event_id', 'event_type', 'event_result_type', 'event_version',
'event_timeout', 'event_handler_timeout', 'event_slow_timeout',
'event_handler_slow_timeout', 'event_concurrency',
'event_handler_concurrency', 'event_handler_completion',
'event_status', 'event_created_at', 'event_started_at',
'event_completed_at', 'event_parent_id', 'event_path', 'event_results',
}
assert documented_fields <= BaseEvent.model_fields.keys()
assert isinstance(BaseEvent().event_children, list)
BaseEvent Methodsnow(first_result: bool=False, timeout: float | None=None) -> SelfImmediate path for the Event object.
first_result=True waits only until the first valid result is available; remaining handlers continue running.timeout limits this wait call only. Use event_timeout=0 / event_handler_timeout=0 to disable execution timeouts.await event is equivalent to await event.now().import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def main():
bus = EventBus()
bus.on(MyEvent, lambda event: 'done')
completed_event = await bus.emit(MyEvent()).now()
first_result_event = await bus.emit(MyEvent()).now(first_result=True, timeout=0.25)
assert await completed_event.event_results_list() == ['done']
assert await first_result_event.event_result() == 'done'
await bus.destroy()
asyncio.run(main())
wait(first_result: bool=False, timeout: float | None=None) -> Selffirst_result=True waits only until the first valid result is available; remaining handlers continue running.timeout limits this wait call only.import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def main():
bus = EventBus()
bus.on(MyEvent, lambda event: 'done')
completed_event = await bus.emit(MyEvent()).wait()
first_result_event = await bus.emit(MyEvent()).wait(first_result=True)
assert await completed_event.event_result() == 'done'
assert await first_result_event.event_result() == 'done'
await bus.destroy()
asyncio.run(main())
reset() -> SelfReturn a fresh event copy with runtime processing state reset back to pending.
event_id is generated for the returned copy (to allow it to process as a separate event it needs a new unique uuid)event_results, completion signal/flags, processed timestamp, emit context).event_result_update(handler, eventbus: EventBus | None=None, **kwargs) -> EventResultCreate or update a single EventResult entry for a handler.
status, result, error, and timeout updates through **kwargs.import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def handler(event):
return 'normal result'
async def main():
bus = EventBus()
handler_entry = bus.on(MyEvent, handler)
event = MyEvent()
seeded = event.event_result_update(handler=handler_entry, eventbus=bus, status='pending')
seeded.update(status='completed', result='seeded')
assert seeded.result == 'seeded'
await bus.destroy()
asyncio.run(main())
event_result(include: EventResultFilter=None, raise_if_any: bool=True, raise_if_none: bool=False) -> AnyUtility method helper to execute all the handlers and return the first handler's raw result value.
Parameters:
include: Filter function (result, event_result) -> bool to include only specific results (default: only non-None, non-exception results)raise_if_any: If True, raise exception if any handler raises any Exception (default: True)raise_if_none: If True, raise exception if results are empty / all results are None or Exception (default: False)raise_if_any=False plus raise_if_none=False suppresses the error and returns None; every other option combination raises.import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def main():
bus = EventBus()
bus.on(MyEvent, lambda event: 'a sufficiently long result')
event = await bus.emit(MyEvent()).now()
assert await event.event_result() == 'a sufficiently long result'
assert await event.event_result(include=lambda result, _: isinstance(result, str) and len(result) > 10)
assert await event.event_result(raise_if_any=False, raise_if_none=False)
await bus.destroy()
asyncio.run(main())
event_results_list(include: EventResultFilter=None, raise_if_any: bool=True, raise_if_none: bool=False) -> list[Any]Utility method helper to get all raw result values in a list.
Parameters:
include: Filter function (result, event_result) -> bool to include only specific results (default: only non-None, non-exception results)raise_if_any: If True, raise exception if any handler raises any Exception (default: True)raise_if_none: If True, raise exception if results are empty / all results are None or Exception (default: False)raise_if_any=False plus raise_if_none=False suppresses the error and returns []; every other option combination raises.import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def main():
bus = EventBus()
async def first_handler(event):
return 'first result'
async def second_handler(event):
return 'second result'
bus.on(MyEvent, first_handler)
bus.on(MyEvent, second_handler)
event = await bus.emit(MyEvent()).now()
assert await event.event_results_list() == ['first result', 'second result']
assert await event.event_results_list(include=lambda result, _: len(result) > 12) == ['second result']
assert await event.event_results_list(raise_if_any=False, raise_if_none=False) == ['first result', 'second result']
await bus.destroy()
asyncio.run(main())
event_results_list() is the canonical collection helper for multiple handler return values.
event_bus (property)Shortcut to get the EventBus that is currently processing this event. Can be used to avoid having to pass an EventBus instance to your handlers.
import asyncio
from abxbus import BaseEvent, EventBus
class ParentEvent(BaseEvent[str]):
pass
class ChildEvent(BaseEvent[str]):
pass
async def child_handler(event):
return 'child done'
async def parent_handler(event):
child_event = await event.emit(ChildEvent()).now()
assert child_event.event_parent_id == event.event_id
return await child_event.event_result()
async def main():
bus = EventBus()
bus.on(ChildEvent, child_handler)
bus.on(ParentEvent, parent_handler)
parent = await bus.emit(ParentEvent()).now()
assert await parent.event_result() == 'child done'
assert parent.event_children[0].event_parent_id == parent.event_id
await bus.destroy()
asyncio.run(main())
EventResultThe placeholder object that represents the pending result from a single handler executing an event.
Event.event_results contains a dict[PythonIdStr, EventResult] in the shape of {handler_id: EventResult()}.
You generally won't interact with this class directly—the bus instantiates and updates it for you—but its API is documented here for advanced integrations and custom emit loops.
EventResult Fieldsfrom abxbus import EventResult
documented_fields = {
'id', 'status', 'event_id', 'handler', 'result_type', 'timeout',
'started_at', 'result', 'error', 'completed_at', 'event_children',
}
assert documented_fields <= EventResult.model_fields.keys()
EventResult Methodsawait resultAwait the EventResult object directly to get the raw result value.
import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def main():
bus = EventBus()
bus.on(MyEvent, lambda event: 'done')
event = await bus.emit(MyEvent()).now()
handler_result = next(iter(event.event_results.values()))
assert await handler_result == 'done'
await bus.destroy()
asyncio.run(main())
status, result, error, and timing fields through event.event_results, or uses the higher-level event result helpers.EventHandlerSerializable metadata wrapper around a registered handler callable.
You usually get an EventHandler back from bus.on(...), can pass it to bus.off(...), and may see it in middleware hooks like on_bus_handlers_change(...).
EventHandler Fieldsfrom abxbus import EventHandler
documented_fields = {
'id', 'handler_name', 'handler_file_path', 'handler_timeout',
'handler_slow_timeout', 'handler_registered_at', 'event_pattern',
'eventbus_name', 'eventbus_id',
}
assert documented_fields <= EventHandler.model_fields.keys()
The raw callable is stored on handler, but is excluded from JSON serialization (model_dump(mode='json', exclude={'handler'})).
EventHandler Properties and Methodslabel (property): Short display label like my_handler#abcd.model_dump(mode='json', exclude={'handler'}) -> dict[str, Any]: JSON-compatible metadata dict (callable excluded).from_json_dict(data, handler=None) -> EventHandler: Rebuilds metadata; optional callable reattachment.from_callable(...) -> EventHandler: Build a new handler entry from a callable plus bus/pattern metadata.uv run tests/performance_runtime.py --no-json
pnpm --dir abxbus-ts run perf:node
cargo test --manifest-path abxbus-rust/Cargo.toml --release --test test_eventbus_performance -- --nocapture
(cd abxbus-go && go test ./tests -run TestPerformance -count=1 -timeout=180s -v)
| Runtime | 1 bus x 50k events x 1 handler | 500 buses x 100 events x 1 handler | 1 bus x 1 event x 50k parallel handlers | 1 bus x 50k events x 50k one-off handlers | Worst case (N buses x N events x N handlers) |
|---|---|---|---|---|---|
| Python | 0.366ms/event, 0.188kb/event | 0.408ms/event, 0.153kb/event | 0.093ms/handler, 10.197kb/handler | 0.633ms/event, 0.145kb/event | 0.504ms/event, 4.171kb/event |
| Rust | 0.067ms/event | 0.070ms/event | 0.062ms/handler | 0.077ms/event | 0.227ms/event |
| Go | 0.016ms/event | 0.011ms/event | 0.085ms/handler | 0.011ms/event | 0.041ms/event |
| TypeScript (Node) | 0.065ms/event, 4.145kb/event | 0.078ms/event, 1.562kb/event | 0.065ms/handler, 11.631kb/handler | 0.123ms/event, 2.182kb/event | 0.344ms/event, 12.619kb/event |
Set up the python development environment using uv:
# From an abxbus checkout, install all development dependencies.
uv sync --dev --all-extras --no-extra tachyon
pnpm --dir abxbus-ts install --frozen-lockfile
Recommended once per clone:
git config --local core.hooksPath "$(git rev-parse --git-dir)/hooks"
env -u GIT_CONFIG_COUNT -u GIT_CONFIG_KEY_0 -u GIT_CONFIG_VALUE_0 uv run prek install
env -u GIT_CONFIG_COUNT -u GIT_CONFIG_KEY_0 -u GIT_CONFIG_VALUE_0 uv run prek run --all-files
# Run linter & type checker
uv run ruff check
uv run ruff format --check
uv run pyright
# Run the portable Python suite in parallel
uv run pytest -vs -n auto --dist loadfile --full-trace \
tests/
# Run specific test file
uv run pytest tests/test_eventbus.py
Mandatory dedicated CI jobs run tests/test_cross_runtime_roundtrip.py with its
native tools and bridge services, and tests/test_eventbus_performance.py with
isolated performance thresholds.
The cross-runtime performance commands are listed in Performance.
Run ./test.sh for the entire lint, test, example, and optional performance suite;
it is intentionally not invoked from a tested code block because that script runs
the documentation tests that are evaluating this README.
For AbxBus-TS development see the
abxbus-ts/README.md# Developmentsection. For Rust crate development seeabxbus-rust/README.md. For AbxBus-Go development rungo test ./...fromabxbus-go/; cross-runtime Go parity is covered bytests/test_cross_runtime_roundtrip.pyandabxbus-ts/tests/cross_runtime_roundtrip.test.ts.
[!TIP] Don't like working with event-driven interfaces? Check out our
abxbus.events_suckwrapper utils that can help wrap events workflows in a simpler imperative API...
🍃 Main Documentation | 🧠 DeepWiki | Get AI Help | 🐍 PyPI Package | 📦 NPM Package | </> Github
This project is licensed under the MIT License.
This repo is a fork that adds many new features and performance enhancements over the original project named bubus, which has since gone stale.
Timeline:
v1.0.1: Original library releasedv1.7.1: bubus forked to pirate/bbus temporarily; ContextVar support, Middlewares, and bus.find() addedv2.3.2: bubus-ts Typescript implementation released, cross-compatible with Python version (now abxbus-ts)v2.4.1: Fork renamed from pirate/bbus -> ArchiveBox/abxbus; added dual CJS/ESM support, bugfixes and perf improvementsv2.4.9: Added update(), uninstall(), and support for uv, gem, cargo, go get, docker, and nix. Used in new abx-dl project and ArchiveBox.Rust
31.4%
Python
28.1%
TypeScript
23.9%
Go
15.0%
📢 Fast multi-language Event Bus library for Python/TS/Golang/Rust with support for advanced concurrency control features, nested event tracking, type enforcement, bridges to other backends, and more...
12
stars
923
commits
Rust
primary language
Aug 28, 2026
updated
abxbus: 📢 Fast, in-memory, multi-language event busAbxBus is an in-memory event bus library for async Python, TypeScript (node/browser), Rust, and Go.
It's designed for quickly building resilient, predictable, complex event-driven apps.
It "just works" with an intuitive, but powerful event JSON format + emit API that's consistent across runtimes and scales consistently from one event up to millions (~0.2ms/event):
import asyncio
from abxbus import BaseEvent, EventBus
class SomeEvent(BaseEvent):
some_data: int
def handle_some_event(event: SomeEvent):
print('hi!')
async def main():
bus = EventBus()
bus.on(SomeEvent, handle_some_event)
await bus.emit(SomeEvent(some_data=132)).now()
asyncio.run(main())
# "hi!"
It's async native, has proper automatic nested event tracking, and powerful concurrency control options. The API is inspired by EventEmitter or emittery in JS, but it takes it a step further:
♾️ It's inspired by the simplicity of async and events in JS but with baked-in features that allow to eliminate most of the tedious repetitive complexity in event-driven codebases:
Install abxbus and get started with a simple event-driven application:
project_dir="$(mktemp -d)"
trap 'rm -rf "$project_dir"' EXIT
uv init --bare "$project_dir"
uv add --project "$project_dir" --editable "$PWD" # see ./abxbus-ts/README.md for JS instructions
uv run --project "$project_dir" python -c 'import abxbus'
import asyncio
from abxbus import EventBus, BaseEvent
class AuthRequestEvent(BaseEvent[str]):
username: str
class AuthResponseEvent(BaseEvent[dict[str, str]]):
token: str
class UserLoginEvent(BaseEvent[str]):
username: str
is_admin: bool
async def handle_auth_request(event: AuthRequestEvent):
await event.emit(AuthResponseEvent(token=f"token-for-{event.username}")).now()
async def handle_login(event: UserLoginEvent) -> str:
auth_request = await event.emit(AuthRequestEvent(username=event.username)).now() # nested events supported
auth_response = await event.event_bus.find(AuthResponseEvent, child_of=auth_request, future=30)
return f"User {event.username} logged in admin={event.is_admin} with API response: {await auth_response.event_result()}"
async def main():
bus = EventBus()
bus.on(UserLoginEvent, handle_login)
bus.on(AuthRequestEvent, handle_auth_request)
event = await bus.emit(UserLoginEvent(username="alice", is_admin=True)).now()
print(await event.event_result())
asyncio.run(main())
# User alice logged in admin=True with API response: {'token': 'token-for-alice'}
Subscribe to events using multiple patterns:
# By event model class (recommended for best type hinting)
bus.on(UserActionEvent, handler)
# By event type string
bus.on('UserActionEvent', handler)
# Wildcard - handle all events
bus.on('*', universal_handler)
Register both synchronous and asynchronous handlers for maximum flexibility:
# Async handler
async def async_handler(event: SomeEvent) -> str:
await asyncio.sleep(0.1) # Simulate async work
return "async result"
# Sync handler
def sync_handler(event: SomeEvent) -> str:
return "sync result"
bus.on(SomeEvent, async_handler)
bus.on(SomeEvent, sync_handler)
Handlers can also be defined under classes for easier organization:
class SomeService:
some_value = 'this works'
async def handlers_can_be_methods(self, event: SomeEvent) -> str:
return self.some_value
@classmethod
async def handler_can_be_classmethods(cls, event: SomeEvent) -> str:
return cls.some_value
@staticmethod
async def handlers_can_be_staticmethods(event: SomeEvent) -> str:
return 'this works too'
# All usage patterns behave the same:
bus.on(SomeEvent, SomeService().handlers_can_be_methods)
bus.on(SomeEvent, SomeService.handler_can_be_classmethods)
bus.on(SomeEvent, SomeService.handlers_can_be_staticmethods)
Define events as Pydantic models with full type checking and validation:
from typing import Any
from abxbus import BaseEvent
class OrderCreatedEvent(BaseEvent):
order_id: str
customer_id: str
total_amount: float
items: list[dict[str, Any]]
# Events are automatically validated
event = OrderCreatedEvent(
order_id="ORD-123",
customer_id="CUST-456",
total_amount=99.99,
items=[{"sku": "ITEM-1", "quantity": 2}]
)
[!TIP] You can also enforce the types of event handler return values.
You can define separate EventBus instances in different "microservices" to separate different areas of concern.
EventBuss can be set up to forward events between each other (with automatic loop prevention):
# Create a hierarchy of buses
main_bus = EventBus(name='MainBus')
auth_bus = EventBus(name='AuthBus')
data_bus = EventBus(name='DataBus')
# Share all or specific events between buses
main_bus.on('*', auth_bus.emit) # if main bus gets LoginEvent, will forward to AuthBus
auth_bus.on('*', data_bus.emit) # auth bus will forward everything to DataBus
data_bus.on('*', main_bus.emit) # don't worry! event will only be processed once by each, no infinite loop occurs
# Events flow through the hierarchy with tracking
async def main():
event = main_bus.emit(LoginEvent())
await event.now()
print(event.event_path) # ['MainBus#ab12', 'AuthBus#cd34', 'DataBus#ef56'] # list of bus labels that already processed the event
asyncio.run(main())
Collect results from multiple handlers:
async def load_user_config(event: GetConfigEvent) -> dict[str, Any]:
return {"debug": True, "port": 8080}
async def load_system_config(event: GetConfigEvent) -> dict[str, Any]:
return {"debug": False, "timeout": 30}
bus.on(GetConfigEvent, load_user_config)
bus.on(GetConfigEvent, load_system_config)
# Get all handler result values
async def main():
event = await bus.emit(GetConfigEvent()).now()
results = await event.event_results_list()
# Inspect per-handler metadata when needed
for handler_id, event_result in event.event_results.items():
print(handler_id, event_result.handler_name, event_result.result)
asyncio.run(main())
By default, events and their handlers are processed in strict serial FIFO order, maintaining consistency:
# Events are processed in the order they were emitted
async def main():
for i in range(10):
bus.emit(ProcessTaskEvent(task_id=i))
# Even with async handlers, order is preserved
await bus.wait_until_idle(timeout=30.0)
asyncio.run(main())
If a handler emits and awaits any child events during execution, those events will jump the FIFO queue and be processed immediately:
def child_handler(event: SomeOtherEvent) -> str:
return 'xyz123'
async def main_handler(event: MainEvent) -> str:
# emit a linked child event
child_event = event.emit(SomeOtherEvent())
# now() marks it as parent-completion-blocking and can queue-jump it
completed_child_event = await child_event.now()
return f'result from awaiting child event: {await completed_child_event.event_result()}' # 'xyz123'
bus.on(SomeOtherEvent, child_handler)
bus.on(MainEvent, main_handler)
async def main():
main_event = await bus.emit(MainEvent()).now()
print(await main_event.event_result())
asyncio.run(main())
# result from awaiting child event: xyz123
You can also set event_concurrency='parallel' and event_handler_concurrency='parallel' options per-bus, per-event, or per-handler enable parallel processing when needed.
Automatically track event relationships and causality tree:
async def parent_handler(event: BaseEvent):
# Most handler code should use this: linked child work that blocks parent completion.
blocking_child = await event.emit(ChildEvent()).now()
assert blocking_child.event_parent_id == event.event_id
assert blocking_child.event_blocks_parent_completion is True
# Linked background work keeps ancestry but does not hold the parent open.
linked_background_child = event.emit(ChildEvent())
assert linked_background_child.event_parent_id == event.event_id
assert linked_background_child.event_blocks_parent_completion is False
# Awaiting bus.emit(...) blocks this handler naturally, but creates a top-level event.
detached_blocking_event = await event.event_bus.emit(ChildEvent()).now()
assert detached_blocking_event.event_parent_id is None
assert detached_blocking_event.event_blocks_parent_completion is False
# Un-awaited bus.emit(...) is a true detached background event.
detached_background_event = event.event_bus.emit(ChildEvent())
assert detached_background_event.event_parent_id is None
assert detached_background_event.event_blocks_parent_completion is False
async def run_main():
bus.on(ChildEvent, child_handler)
bus.on(ParentEvent, parent_handler)
parent_event = bus.emit(ParentEvent())
print(parent_event.event_children) # show all the child events emitted during handling of an event
await parent_event.now()
print(bus.log_tree())
await bus.destroy()
if __name__ == '__main__':
asyncio.run(run_main())
find() is the single lookup API: search history, wait for future events, or combine both to check for an existing recent event before emitting a new one.
async def main():
# Default: non-blocking history lookup (past=True, future=False)
existing = await bus.find(ResponseEvent)
# Wait only for future matches
asyncio.create_task(emit_response_soon())
future = await bus.find(ResponseEvent, past=False, future=5)
# Combine event predicate + event metadata filters
match = await bus.find(
ResponseEvent,
where=lambda e: e.request_id == my_id,
event_status='completed',
future=5,
)
# Wildcard: match any event type, filtered by metadata/predicate
any_completed = await bus.find(
'*',
where=lambda e: e.event_type.endswith('ResultEvent'),
event_status='completed',
future=5,
)
asyncio.run(main())
When you emit an event that triggers child events, use child_of to find specific descendants:
async def main():
# Emit a parent event that triggers child events
nav_event = await bus.emit(NavigateToUrlEvent(url="https://example.com")).now()
# Find a child event (already fired while NavigateToUrlEvent was being handled)
new_tab = await bus.find(TabCreatedEvent, child_of=nav_event, past=5)
if new_tab:
print(f"New tab created: {new_tab.tab_id}")
return new_tab
new_tab = asyncio.run(main())
This solves race conditions where child events fire before you start waiting for them.
filter()filter() takes the same arguments as find() but returns the list of all matching events
(newest to oldest), plus an optional limit argument to cap the result count.
async def main():
recent = await bus.filter(ResponseEvent, past=10, future=False, limit=5)
return recent
recent = asyncio.run(main())
See the EventBus.find(...) API section below for full parameter details.
[!IMPORTANT]
find()resolves when the event is first emitted to theEventBus, not when it completes. Useawait event.now()for immediate-await semantics (queue-jumps when called inside a handler), orawait event.wait()to always wait in normal queue order. Python also supportsawait eventas a Python-only shortcut forawait event.now(). If no match is found (or future timeout elapses),find()returnsNone.
Avoid re-running expensive work by reusing recent events. The find() method makes debouncing simple:
async def main():
# Simple debouncing: reuse event from last 10 seconds, or emit new
event = await bus.find(ScreenshotEvent, past=10, future=False) or bus.emit(ScreenshotEvent())
event = await event.now()
# Advanced: check history, wait briefly for new event to appear, fallback to emit new event
event = (
await bus.find(SyncEvent, past=True, future=False) # Check all history (instant)
or await bus.find(SyncEvent, past=False, future=5) # Wait up to 5s for in-flight
or bus.emit(SyncEvent()) # Fallback: emit new
)
await event.now() # get completed event
asyncio.run(main())
There are two ways to get return values from event handlers:
1. Have handlers return their values directly, which puts them in event.event_results:
import asyncio
from abxbus import BaseEvent, EventBus
class DoSomeMathEvent(BaseEvent[int]): # BaseEvent[int] = handlers are validated as returning int
a: int
b: int
# int passed above gets saved to:
# event_result_type = int
def do_some_math(event: DoSomeMathEvent) -> int:
return event.a + event.b
event_bus = EventBus()
event_bus.on(DoSomeMathEvent, do_some_math)
async def main():
event = await event_bus.emit(DoSomeMathEvent(a=100, b=120)).now(first_result=True)
print(await event.event_result())
await event_bus.destroy()
asyncio.run(main())
# 220
You can use these helpers to interact with the results returned by handlers:
BaseEvent.event_result()BaseEvent.event_results_list()BaseEvent.event_results2. Have the handler do the work, then emit another event containing the result value, which other code can find:
import asyncio
from abxbus import BaseEvent, EventBus
class DoSomeMathEvent(BaseEvent):
a: int
b: int
class MathCompleteEvent(BaseEvent):
final_sum: int
event_bus = EventBus()
async def do_some_math(event: DoSomeMathEvent) -> None:
result = event.a + event.b
await event.emit(MathCompleteEvent(final_sum=result)).now()
event_bus.on(DoSomeMathEvent, do_some_math)
async def main():
await event_bus.emit(DoSomeMathEvent(a=100, b=120)).now()
result_event = await event_bus.find(MathCompleteEvent, past=True, future=False)
assert result_event is not None
print(result_event.final_sum)
await event_bus.destroy()
asyncio.run(main())
# 220
These events can also be emitted automatically for you if you enable the AutoReturnEventMiddleware.
AbxBus supports optional strict typing for Event handler return values using a generic parameter passed to BaseEvent[ReturnTypeHere].
For example if you use BaseEvent[str], abxbus would enforce that all handler functions must return str | None at compile-time via IDE/mypy/pyright/ty type hints, and at runtime when each handler finishes.
import asyncio
from abxbus import BaseEvent, EventBus
class ScreenshotEvent(BaseEvent[bytes]): # BaseEvent[bytes] will enforce that handlers can only return bytes
width: int
height: int
async def on_ScreenshotEvent(event: ScreenshotEvent) -> bytes:
return b'someimagebytes...' # ✅ IDE type-hints & runtime both enforce return type matches expected: bytes
# return 123 # ❌ will show mypy/pyright issue + raise TypeError if the wrong type is returned
event_bus = EventBus()
event_bus.on(ScreenshotEvent, on_ScreenshotEvent)
async def main():
# Handler return values are automatically validated against the bytes type
event = await event_bus.emit(ScreenshotEvent(width=100, height=100)).now(first_result=True)
returned_bytes = await event.event_result()
assert isinstance(returned_bytes, bytes)
await event_bus.destroy()
asyncio.run(main())
Important: The validation uses Pydantic's TypeAdapter, which validates but does not coerce types. Handlers must return the exact type specified or None:
from abxbus import BaseEvent
class StringEvent(BaseEvent[str]):
pass
# ✅ This works - returns the expected str type
def good_handler(event: StringEvent) -> str:
return "hello"
# ❌ This fails validation - returns int instead of str
def bad_handler(event: StringEvent) -> str:
return 42 # ValidationError: expected str, got int
This also works with complex types and Pydantic models:
import asyncio
from uuid import UUID
from pydantic import BaseModel
from abxbus import BaseEvent, EventBus
class EmailMessage(BaseModel):
subject: str
content_len: int
email_from: str
class FetchInboxEvent(BaseEvent[list[EmailMessage]]):
account_id: UUID
auth_key: str
class GmailAPI:
@staticmethod
def get_msgs(account_id: UUID) -> list[EmailMessage]:
return [EmailMessage(subject=f"inbox-{account_id}", content_len=42, email_from="sender@example.com")]
async def fetch_from_gmail(event: FetchInboxEvent) -> list[EmailMessage]:
return GmailAPI.get_msgs(event.account_id)
event_bus = EventBus()
event_bus.on(FetchInboxEvent, fetch_from_gmail)
async def main():
# Return values are automatically validated as list[EmailMessage]
event = await event_bus.emit(
FetchInboxEvent(account_id=UUID("00000000-0000-4000-8000-000000000124"), auth_key="secret")
).now(first_result=True)
email_list = await event.event_result()
assert email_list[0].email_from == "sender@example.com"
await event_bus.destroy()
asyncio.run(main())
For pure Python usage, event_result_type can be any Python/Pydantic type you want. For cross-language JSON roundtrips, object-like shapes (e.g. TypedDict, dataclass, model-like dict schemas) rehydrate on Python as Pydantic models, map keys are constrained to JSON object string keys, and fine-grained string constraints/custom field validator logic is not preserved.
ContextVars set before emit() are automatically propagated to event handlers. This is essential for request-scoped context like request IDs, user sessions, or tracing spans:
import asyncio
from contextvars import ContextVar
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
bus = EventBus()
# Define your context variables
request_id: ContextVar[str] = ContextVar('request_id', default='<unset>')
user_id: ContextVar[str] = ContextVar('user_id', default='<unset>')
async def handler(event: MyEvent) -> str:
# Handler sees the context values that were set before emit()
print(f"Request: {request_id.get()}, User: {user_id.get()}")
return "done"
bus.on(MyEvent, handler)
# Set context before emit (e.g., in FastAPI middleware)
request_id.set('req-12345')
user_id.set('user-abc')
# Handler will see request_id='req-12345' and user_id='user-abc'
async def main():
await bus.emit(MyEvent()).now()
await bus.destroy()
asyncio.run(main())
Context propagates through nested handlers:
import asyncio
from contextvars import ContextVar
from abxbus import BaseEvent, EventBus
request_id: ContextVar[str] = ContextVar('request_id', default='<unset>')
class ParentEvent(BaseEvent[str]):
pass
class ChildEvent(BaseEvent[str]):
pass
bus = EventBus()
async def parent_handler(event: ParentEvent) -> str:
# Context is captured at emit time
print(f"Parent sees: {request_id.get()}") # 'req-12345'
# Child events inherit the same context
await event.emit(ChildEvent()).now()
return "parent_done"
async def child_handler(event: ChildEvent) -> str:
# Child also sees the original emit context
print(f"Child sees: {request_id.get()}") # 'req-12345'
return "child_done"
bus.on(ParentEvent, parent_handler)
bus.on(ChildEvent, child_handler)
async def main():
request_id.set('req-12345')
await bus.emit(ParentEvent()).now()
await bus.destroy()
asyncio.run(main())
Context isolation between emits:
Each emit captures its own context snapshot. Concurrent emits with different context values are properly isolated:
import asyncio
from contextvars import ContextVar
from abxbus import BaseEvent, EventBus
request_id: ContextVar[str] = ContextVar('request_id', default='<unset>')
class MyEvent(BaseEvent[str]):
pass
async def handler(event: MyEvent) -> str:
return request_id.get()
bus = EventBus()
bus.on(MyEvent, handler)
async def main():
request_id.set('req-A')
event_a = bus.emit(MyEvent()) # Handler A sees 'req-A'
request_id.set('req-B')
event_b = bus.emit(MyEvent()) # Handler B sees 'req-B'
await event_a.now() # Still sees 'req-A'
await event_b.now() # Still sees 'req-B'
await bus.destroy()
asyncio.run(main())
[!NOTE] Context is captured at
emit()time, not when the handler executes. This ensures handlers see the context from the call site, even if the event is processed later from a queue.
EventBus includes automatic memory management to prevent unbounded growth in long-running applications:
import asyncio
from abxbus import EventBus
# Create a bus with memory limits (default: 100 events)
bus = EventBus(max_history_size=100) # Keep max 100 events in history
# Or disable memory limits for unlimited history
bus = EventBus(max_history_size=None)
# Or keep only in-flight events in history (drop each event as soon as it completes)
bus = EventBus(max_history_size=0)
# Or reject new emits when history is full (instead of dropping old history)
bus = EventBus(max_history_size=100, max_history_drop=False)
asyncio.run(bus.destroy())
Automatic Cleanup:
max_history_size is set and max_history_drop=True, EventBus removes old events when the limit is exceededmax_history_size=0, history keeps only pending/started events and drops each event immediately after completionmax_history_drop=True, the bus may drop oldest history entries even if they are uncompleted eventsManual Memory Management:
import asyncio
from abxbus import BaseEvent, EventBus
class ProcessRequestEvent(BaseEvent[str]):
request_id: str
class EventService:
bus: EventBus
async def on_ProcessRequestEvent(self, event: ProcessRequestEvent) -> str:
return f"ok:{event.request_id}"
def __init__(self):
self.bus = EventBus()
self.bus.on(ProcessRequestEvent, self.on_ProcessRequestEvent)
# For request-scoped buses (e.g. web servers), clear all memory after each request
async def main():
try:
event_service = EventService() # Creates internal EventBus
event = await event_service.bus.emit(ProcessRequestEvent(request_id="req-1")).now()
assert await event.event_result() == "ok:req-1"
finally:
# Clear all event history and remove from global tracking
await event_service.bus.destroy(clear=True)
asyncio.run(main())
Memory Monitoring:
bus.destroy(clear=True) to completely free memory for unused buses[!CAUTION] Not Recommended. Only for advanced users willing to implement their own concurrency control.
Enable parallel processing of handlers for better performance.
The harsh tradeoff is less deterministic ordering as handler execution order will not be guaranteed when run in parallel.
(It's very hard to write non-flaky/reliable applications when handler execution order is not guaranteed.)
import asyncio
import time
from abxbus import BaseEvent, EventBus
class DataEvent(BaseEvent):
pass
async def slow_handler_1(event: DataEvent) -> None:
await asyncio.sleep(0.01)
async def slow_handler_2(event: DataEvent) -> None:
await asyncio.sleep(0.01)
# Create bus with parallel handler execution
bus = EventBus(event_handler_concurrency='parallel')
# Multiple handlers run concurrently for each event
bus.on('DataEvent', slow_handler_1) # Takes 1 second
bus.on('DataEvent', slow_handler_2) # Takes 1 second
async def main():
start = time.time()
await bus.emit(DataEvent()).now()
assert time.time() - start < 0.1
await bus.destroy()
asyncio.run(main())
# Total time: ~1 second (not 2)
Middlewares can observe or mutate the EventResult at each step, emit additional events, or trigger other side effects (metrics, retries, auth checks, etc.).
import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from abxbus import BaseEvent, EventBus
from abxbus.middlewares import LoggerEventBusMiddleware, OtelTracingMiddleware, SQLiteHistoryMirrorMiddleware, WALEventBusMiddleware
class SecondEventAbc(BaseEvent):
some_key: str
async def handler(event: SecondEventAbc) -> str:
return event.some_key
with TemporaryDirectory() as temp_dir:
output_dir = Path(temp_dir)
sqlite_path = output_dir / 'events.sqlite3'
wal_path = output_dir / 'events.jsonl'
log_path = output_dir / 'events.log'
bus = EventBus(
name='MyBus',
middlewares=[
SQLiteHistoryMirrorMiddleware(sqlite_path),
WALEventBusMiddleware(wal_path),
LoggerEventBusMiddleware(log_path),
OtelTracingMiddleware(),
# ...
],
)
bus.on(SecondEventAbc, handler)
async def main():
await bus.emit(SecondEventAbc(some_key="banana")).now()
await bus.destroy()
asyncio.run(main())
assert sqlite_path.exists() and wal_path.exists() and log_path.exists()
Built-in middlewares you can import from abxbus.middlewares:
AutoErrorEventMiddleware: on handler error, fire-and-forget emits OriginalEventTypeErrorEvent with {error, error_type} (skips *ErrorEvent/*ResultEvent sources). Useful when downstream/remote consumers only see events and need explicit failure notifications.AutoReturnEventMiddleware: on non-None handler return, fire-and-forget emits OriginalEventTypeResultEvent with {data} (skips *ErrorEvent/*ResultEvent sources). Useful for bridges/remote systems since handler return values do not cross bridge boundaries, but events do.AutoHandlerChangeEventMiddleware: emits BusHandlerRegisteredEvent({handler}) / BusHandlerUnregisteredEvent({handler}) when handlers are added/removed via .on() / .off().OtelTracingMiddleware: emits OpenTelemetry spans for events and handlers with parent-child linking; can be exported to Sentry via Sentry's OpenTelemetry integration.WALEventBusMiddleware: persists completed events to JSONL for replay/debugging.LoggerEventBusMiddleware: writes event/handler transitions to stdout and optionally to file.SQLiteHistoryMirrorMiddleware: mirrors event and handler snapshots into append-only SQLite events_log and event_results_log tables for auditing/debugging.Handler middlewares subclass EventBusMiddleware and override whichever lifecycle hooks they need (on_event_change, on_event_result_change, on_bus_handlers_change):
from abxbus.middlewares import EventBusMiddleware
class AnalyticsMiddleware(EventBusMiddleware):
async def on_event_result_change(self, eventbus, event, event_result, status):
if status == 'started':
await analytics_bus.emit(HandlerStartedAnalyticsEvent(event_id=event_result.event_id)).now()
elif status == 'completed':
await analytics_bus.emit(
HandlerCompletedAnalyticsEvent(
event_id=event_result.event_id,
error=repr(event_result.error) if event_result.error else None,
)
).now()
async def on_bus_handlers_change(self, eventbus, handler, registered):
await analytics_bus.emit(
HandlerRegistryChangedEvent(handler_id=handler.id, registered=registered, bus=eventbus.name)
).now()
EventBusThe main event bus class that manages event processing and handler execution.
from inspect import signature
from abxbus import EventBus
parameters = signature(EventBus).parameters
assert parameters['event_concurrency'].default is None
assert parameters['event_handler_concurrency'].default.value == 'serial'
assert parameters['event_handler_completion'].default.value == 'all'
assert parameters['event_timeout'].default == 60.0
assert parameters['max_history_size'].default == 100
Parameters:
name: Optional unique name for the bus (auto-generated if not provided)event_concurrency: Default event scheduling mode: 'global-serial', 'bus-serial' (default), or 'parallel' (resolved at processing time when event.event_concurrency is unset)event_handler_concurrency: Default handler execution mode for events on this bus: 'serial' (default) or 'parallel' (resolved at processing time when event.event_handler_concurrency is unset)event_handler_completion: Handler completion mode for each event: 'all' (default, wait for all handlers) or 'first' (complete once first successful non-None result is available), resolved at processing time when event.event_handler_completion is unsetevent_timeout: Default per-event timeout in seconds resolved at processing time when event.event_timeout is Noneevent_slow_timeout: Default slow-event warning threshold in seconds resolved at processing time when event.event_slow_timeout is Noneevent_handler_slow_timeout: Default slow-handler warning threshold in seconds resolved at processing time when event.event_handler_slow_timeout is Noneevent_handler_detect_file_paths: Whether to auto-detect handler source file paths at registration time (slightly slower when enabled)max_history_size: Maximum number of events to keep in history (default: 100, None = unlimited, 0 = keep only in-flight events and drop completed events immediately)max_history_drop: If True, drop oldest history entries when full (even uncompleted events). If False (default), reject new emits once history reaches max_history_size (except when max_history_size=0, which never rejects on history size)middlewares: Optional list of EventBusMiddleware subclasses or instances that hook into handler execution for analytics, logging, retries, etc. (see Middlewares for more info)Timeout precedence matches TS:
min(resolved_handler_timeout, event_timeout) where resolved_handler_timeout resolves in order: handler.handler_timeout -> event.event_handler_timeout -> bus.event_timeout.handler.handler_slow_timeout -> event.event_handler_slow_timeout -> bus.event_handler_slow_timeout.EventBus Propertiesname: The bus identifierid: Unique UUID7 for this bus instanceevent_history: Dict of all events the bus has seen by event_id (limited by max_history_size)events_pending: List of events waiting to be processedevents_started: List of events currently being processedevents_completed: List of completed eventsall_instances: Class-level WeakSet tracking all active EventBus instances (for memory monitoring)EventBus Methodson(event_type: str | Type[BaseEvent], handler: Callable)Subscribe a handler to events matching a specific event type or '*' for all events.
import asyncio
from abxbus import BaseEvent, EventBus
class UserEvent(BaseEvent[str]):
pass
async def handler_func(event: UserEvent) -> str:
return event.event_type
bus = EventBus()
bus.on('UserEvent', handler_func) # By event type string
bus.on(UserEvent, handler_func) # By event class
bus.on('*', handler_func) # Wildcard - all events
asyncio.run(bus.destroy())
emit(event: BaseEvent) -> BaseEventEnqueue an event for processing and return the pending Event immediately (synchronous).
import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
data: str
async def handler(event: MyEvent) -> str:
return event.data
async def main():
bus = EventBus()
bus.on(MyEvent, handler)
event = bus.emit(MyEvent(data="test"))
result = await event.now()
result_in_queue_order = await event.wait()
assert result is event and result_in_queue_order is event
assert await event.event_result() == 'test'
await bus.destroy()
asyncio.run(main())
Note: Queueing is unbounded. History pressure is controlled by max_history_size + max_history_drop:
max_history_drop=True: absorb new events and trim old history entries (even uncompleted events).max_history_drop=False: raise RuntimeError when history is full.max_history_size=0: keep pending/in-flight events only; completed events are immediately removed from history.find(event_type: str | Literal['*'] | Type[BaseEvent], *, where: Callable[[BaseEvent], bool]=None, child_of: BaseEvent | None=None, past: bool | float | timedelta=True, future: bool | float=False, **event_fields) -> BaseEvent | NoneFind an event matching criteria in history and/or future. This is the recommended unified method for event lookup.
Parameters:
event_type: The event type string, '*' wildcard, or model class to findwhere: Predicate function for filtering (default: matches all)child_of: Only match events that are descendants of this parent eventpast: Controls history search behavior (default: True)
True: search all historyFalse: skip history searchfloat/timedelta: search events from last N seconds onlyfuture: Controls future wait behavior (default: False)
True: wait forever for matching eventFalse: don't wait for future eventsfloat: wait up to N seconds for matching event**event_fields: Optional equality filters for any event fields (for example event_status='completed', user_id='u-1')import asyncio
from abxbus import BaseEvent, EventBus
class ResponseEvent(BaseEvent[None]):
request_id: str
async def main():
bus = EventBus()
completed = await bus.emit(ResponseEvent(request_id='req-1')).now()
assert await bus.find(ResponseEvent) is completed
assert await bus.find(ResponseEvent, past=5, future=False) is completed
assert await bus.find(ResponseEvent, event_status='completed') is completed
assert await bus.find('*', event_status='completed', past=True, future=False) is completed
await bus.destroy()
asyncio.run(main())
filter(event_type, *, limit: int | None=None, ...) -> list[BaseEvent]Same as find()
but returns the list of all matching events (newest to oldest) instead of just the first match.
Accepts an additional limit argument to cap the result count.
import asyncio
from abxbus import BaseEvent, EventBus
class ResponseEvent(BaseEvent[None]):
request_id: str
async def main():
bus = EventBus()
await bus.emit(ResponseEvent(request_id='req-1')).now()
recent = await bus.filter(ResponseEvent, past=10, future=False, limit=5)
assert len(recent) == 1 and recent[0].request_id == 'req-1'
await bus.destroy()
asyncio.run(main())
event_is_child_of(event: BaseEvent, ancestor: BaseEvent) -> boolCheck if event is a descendant of ancestor (child, grandchild, etc.).
import asyncio
from abxbus import BaseEvent, EventBus
bus = EventBus()
parent_event = BaseEvent()
child_event = BaseEvent(event_parent_id=parent_event.event_id)
assert bus.event_is_child_of(child_event, parent_event)
asyncio.run(bus.destroy())
event_is_parent_of(event: BaseEvent, descendant: BaseEvent) -> boolCheck if event is an ancestor of descendant (parent, grandparent, etc.).
import asyncio
from abxbus import BaseEvent, EventBus
bus = EventBus()
parent_event = BaseEvent()
child_event = BaseEvent(event_parent_id=parent_event.event_id)
assert bus.event_is_parent_of(parent_event, child_event)
asyncio.run(bus.destroy())
wait_until_idle(timeout: float | None=None)Wait until all events are processed and the bus is idle.
import asyncio
from abxbus import EventBus
async def main():
bus = EventBus()
await bus.wait_until_idle()
await bus.wait_until_idle(timeout=5.0)
await bus.destroy()
asyncio.run(main())
destroy(clear: bool=True)Destroy the event bus immediately. In-flight work is cancelled best-effort, future waiters are resolved, and the bus cannot be used again.
import asyncio
from abxbus import EventBus
async def main():
await EventBus().destroy()
await EventBus().destroy(clear=False)
asyncio.run(main())
BaseEventBase class for all events. Subclass BaseEvent to define your own events.
Make sure none of your own event data fields start with event_ or model_ to avoid clashing with BaseEvent or pydantic builtin attrs.
BaseEvent Fieldsfrom abxbus import BaseEvent
documented_fields = {
'event_id', 'event_type', 'event_result_type', 'event_version',
'event_timeout', 'event_handler_timeout', 'event_slow_timeout',
'event_handler_slow_timeout', 'event_concurrency',
'event_handler_concurrency', 'event_handler_completion',
'event_status', 'event_created_at', 'event_started_at',
'event_completed_at', 'event_parent_id', 'event_path', 'event_results',
}
assert documented_fields <= BaseEvent.model_fields.keys()
assert isinstance(BaseEvent().event_children, list)
BaseEvent Methodsnow(first_result: bool=False, timeout: float | None=None) -> SelfImmediate path for the Event object.
first_result=True waits only until the first valid result is available; remaining handlers continue running.timeout limits this wait call only. Use event_timeout=0 / event_handler_timeout=0 to disable execution timeouts.await event is equivalent to await event.now().import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def main():
bus = EventBus()
bus.on(MyEvent, lambda event: 'done')
completed_event = await bus.emit(MyEvent()).now()
first_result_event = await bus.emit(MyEvent()).now(first_result=True, timeout=0.25)
assert await completed_event.event_results_list() == ['done']
assert await first_result_event.event_result() == 'done'
await bus.destroy()
asyncio.run(main())
wait(first_result: bool=False, timeout: float | None=None) -> Selffirst_result=True waits only until the first valid result is available; remaining handlers continue running.timeout limits this wait call only.import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def main():
bus = EventBus()
bus.on(MyEvent, lambda event: 'done')
completed_event = await bus.emit(MyEvent()).wait()
first_result_event = await bus.emit(MyEvent()).wait(first_result=True)
assert await completed_event.event_result() == 'done'
assert await first_result_event.event_result() == 'done'
await bus.destroy()
asyncio.run(main())
reset() -> SelfReturn a fresh event copy with runtime processing state reset back to pending.
event_id is generated for the returned copy (to allow it to process as a separate event it needs a new unique uuid)event_results, completion signal/flags, processed timestamp, emit context).event_result_update(handler, eventbus: EventBus | None=None, **kwargs) -> EventResultCreate or update a single EventResult entry for a handler.
status, result, error, and timeout updates through **kwargs.import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def handler(event):
return 'normal result'
async def main():
bus = EventBus()
handler_entry = bus.on(MyEvent, handler)
event = MyEvent()
seeded = event.event_result_update(handler=handler_entry, eventbus=bus, status='pending')
seeded.update(status='completed', result='seeded')
assert seeded.result == 'seeded'
await bus.destroy()
asyncio.run(main())
event_result(include: EventResultFilter=None, raise_if_any: bool=True, raise_if_none: bool=False) -> AnyUtility method helper to execute all the handlers and return the first handler's raw result value.
Parameters:
include: Filter function (result, event_result) -> bool to include only specific results (default: only non-None, non-exception results)raise_if_any: If True, raise exception if any handler raises any Exception (default: True)raise_if_none: If True, raise exception if results are empty / all results are None or Exception (default: False)raise_if_any=False plus raise_if_none=False suppresses the error and returns None; every other option combination raises.import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def main():
bus = EventBus()
bus.on(MyEvent, lambda event: 'a sufficiently long result')
event = await bus.emit(MyEvent()).now()
assert await event.event_result() == 'a sufficiently long result'
assert await event.event_result(include=lambda result, _: isinstance(result, str) and len(result) > 10)
assert await event.event_result(raise_if_any=False, raise_if_none=False)
await bus.destroy()
asyncio.run(main())
event_results_list(include: EventResultFilter=None, raise_if_any: bool=True, raise_if_none: bool=False) -> list[Any]Utility method helper to get all raw result values in a list.
Parameters:
include: Filter function (result, event_result) -> bool to include only specific results (default: only non-None, non-exception results)raise_if_any: If True, raise exception if any handler raises any Exception (default: True)raise_if_none: If True, raise exception if results are empty / all results are None or Exception (default: False)raise_if_any=False plus raise_if_none=False suppresses the error and returns []; every other option combination raises.import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def main():
bus = EventBus()
async def first_handler(event):
return 'first result'
async def second_handler(event):
return 'second result'
bus.on(MyEvent, first_handler)
bus.on(MyEvent, second_handler)
event = await bus.emit(MyEvent()).now()
assert await event.event_results_list() == ['first result', 'second result']
assert await event.event_results_list(include=lambda result, _: len(result) > 12) == ['second result']
assert await event.event_results_list(raise_if_any=False, raise_if_none=False) == ['first result', 'second result']
await bus.destroy()
asyncio.run(main())
event_results_list() is the canonical collection helper for multiple handler return values.
event_bus (property)Shortcut to get the EventBus that is currently processing this event. Can be used to avoid having to pass an EventBus instance to your handlers.
import asyncio
from abxbus import BaseEvent, EventBus
class ParentEvent(BaseEvent[str]):
pass
class ChildEvent(BaseEvent[str]):
pass
async def child_handler(event):
return 'child done'
async def parent_handler(event):
child_event = await event.emit(ChildEvent()).now()
assert child_event.event_parent_id == event.event_id
return await child_event.event_result()
async def main():
bus = EventBus()
bus.on(ChildEvent, child_handler)
bus.on(ParentEvent, parent_handler)
parent = await bus.emit(ParentEvent()).now()
assert await parent.event_result() == 'child done'
assert parent.event_children[0].event_parent_id == parent.event_id
await bus.destroy()
asyncio.run(main())
EventResultThe placeholder object that represents the pending result from a single handler executing an event.
Event.event_results contains a dict[PythonIdStr, EventResult] in the shape of {handler_id: EventResult()}.
You generally won't interact with this class directly—the bus instantiates and updates it for you—but its API is documented here for advanced integrations and custom emit loops.
EventResult Fieldsfrom abxbus import EventResult
documented_fields = {
'id', 'status', 'event_id', 'handler', 'result_type', 'timeout',
'started_at', 'result', 'error', 'completed_at', 'event_children',
}
assert documented_fields <= EventResult.model_fields.keys()
EventResult Methodsawait resultAwait the EventResult object directly to get the raw result value.
import asyncio
from abxbus import BaseEvent, EventBus
class MyEvent(BaseEvent[str]):
pass
async def main():
bus = EventBus()
bus.on(MyEvent, lambda event: 'done')
event = await bus.emit(MyEvent()).now()
handler_result = next(iter(event.event_results.values()))
assert await handler_result == 'done'
await bus.destroy()
asyncio.run(main())
status, result, error, and timing fields through event.event_results, or uses the higher-level event result helpers.EventHandlerSerializable metadata wrapper around a registered handler callable.
You usually get an EventHandler back from bus.on(...), can pass it to bus.off(...), and may see it in middleware hooks like on_bus_handlers_change(...).
EventHandler Fieldsfrom abxbus import EventHandler
documented_fields = {
'id', 'handler_name', 'handler_file_path', 'handler_timeout',
'handler_slow_timeout', 'handler_registered_at', 'event_pattern',
'eventbus_name', 'eventbus_id',
}
assert documented_fields <= EventHandler.model_fields.keys()
The raw callable is stored on handler, but is excluded from JSON serialization (model_dump(mode='json', exclude={'handler'})).
EventHandler Properties and Methodslabel (property): Short display label like my_handler#abcd.model_dump(mode='json', exclude={'handler'}) -> dict[str, Any]: JSON-compatible metadata dict (callable excluded).from_json_dict(data, handler=None) -> EventHandler: Rebuilds metadata; optional callable reattachment.from_callable(...) -> EventHandler: Build a new handler entry from a callable plus bus/pattern metadata.uv run tests/performance_runtime.py --no-json
pnpm --dir abxbus-ts run perf:node
cargo test --manifest-path abxbus-rust/Cargo.toml --release --test test_eventbus_performance -- --nocapture
(cd abxbus-go && go test ./tests -run TestPerformance -count=1 -timeout=180s -v)
| Runtime | 1 bus x 50k events x 1 handler | 500 buses x 100 events x 1 handler | 1 bus x 1 event x 50k parallel handlers | 1 bus x 50k events x 50k one-off handlers | Worst case (N buses x N events x N handlers) |
|---|---|---|---|---|---|
| Python | 0.366ms/event, 0.188kb/event | 0.408ms/event, 0.153kb/event | 0.093ms/handler, 10.197kb/handler | 0.633ms/event, 0.145kb/event | 0.504ms/event, 4.171kb/event |
| Rust | 0.067ms/event | 0.070ms/event | 0.062ms/handler | 0.077ms/event | 0.227ms/event |
| Go | 0.016ms/event | 0.011ms/event | 0.085ms/handler | 0.011ms/event | 0.041ms/event |
| TypeScript (Node) | 0.065ms/event, 4.145kb/event | 0.078ms/event, 1.562kb/event | 0.065ms/handler, 11.631kb/handler | 0.123ms/event, 2.182kb/event | 0.344ms/event, 12.619kb/event |
Set up the python development environment using uv:
# From an abxbus checkout, install all development dependencies.
uv sync --dev --all-extras --no-extra tachyon
pnpm --dir abxbus-ts install --frozen-lockfile
Recommended once per clone:
git config --local core.hooksPath "$(git rev-parse --git-dir)/hooks"
env -u GIT_CONFIG_COUNT -u GIT_CONFIG_KEY_0 -u GIT_CONFIG_VALUE_0 uv run prek install
env -u GIT_CONFIG_COUNT -u GIT_CONFIG_KEY_0 -u GIT_CONFIG_VALUE_0 uv run prek run --all-files
# Run linter & type checker
uv run ruff check
uv run ruff format --check
uv run pyright
# Run the portable Python suite in parallel
uv run pytest -vs -n auto --dist loadfile --full-trace \
tests/
# Run specific test file
uv run pytest tests/test_eventbus.py
Mandatory dedicated CI jobs run tests/test_cross_runtime_roundtrip.py with its
native tools and bridge services, and tests/test_eventbus_performance.py with
isolated performance thresholds.
The cross-runtime performance commands are listed in Performance.
Run ./test.sh for the entire lint, test, example, and optional performance suite;
it is intentionally not invoked from a tested code block because that script runs
the documentation tests that are evaluating this README.
For AbxBus-TS development see the
abxbus-ts/README.md# Developmentsection. For Rust crate development seeabxbus-rust/README.md. For AbxBus-Go development rungo test ./...fromabxbus-go/; cross-runtime Go parity is covered bytests/test_cross_runtime_roundtrip.pyandabxbus-ts/tests/cross_runtime_roundtrip.test.ts.
[!TIP] Don't like working with event-driven interfaces? Check out our
abxbus.events_suckwrapper utils that can help wrap events workflows in a simpler imperative API...
🍃 Main Documentation | 🧠 DeepWiki | Get AI Help | 🐍 PyPI Package | 📦 NPM Package | </> Github
This project is licensed under the MIT License.
This repo is a fork that adds many new features and performance enhancements over the original project named bubus, which has since gone stale.
Timeline:
v1.0.1: Original library releasedv1.7.1: bubus forked to pirate/bbus temporarily; ContextVar support, Middlewares, and bus.find() addedv2.3.2: bubus-ts Typescript implementation released, cross-compatible with Python version (now abxbus-ts)v2.4.1: Fork renamed from pirate/bbus -> ArchiveBox/abxbus; added dual CJS/ESM support, bugfixes and perf improvementsv2.4.9: Added update(), uninstall(), and support for uv, gem, cargo, go get, docker, and nix. Used in new abx-dl project and ArchiveBox.Rust
31.4%
Python
28.1%
TypeScript
23.9%
Go
15.0%