Skip to content

Event Bus

The event bus is the only legitimate channel for inter-domain communication. It follows the Elastic Monolith pattern: in-process by default, distributed on demand.

Universal Event Contract

MicroCoreOS uses a standardized Event Envelope (Pydantic model) for all messages. This ensures compatibility with enterprise brokers like Kafka, RabbitMQ, and SQS without changing plugin code.

Envelope Fields

FieldTypeDescription
idstr (UUID)Unique event ID, auto-generated
eventstrEvent name (e.g. user.created)
payloaddictBusiness data
emitterstrName of the publishing plugin/tool
timestampdatetime (UTC)Time of creation
parent_idstr | NoneID of the triggering event (causality chain)
correlation_idstr | NoneUsed internally for RPC request/response
reply_tostr | NoneReply channel name, used internally by request()
keystr | NonePartition key for ordered delivery (Kafka/SQS)
priorityint | NonePriority level 1–10 (RabbitMQ)
delayint | NoneSeconds before delivery
ttlfloat | NoneTime-to-live in seconds (expired events are discarded)
headersdictArbitrary metadata attached to the envelope

Subscriber Signature

Every subscriber receives an EventEnvelope, not a raw dict.

python
async def on_user_created(self, event: EventEnvelope) -> None:
    user_id = event.payload.get("id")
    email   = event.payload.get("email")

For RPC (request()), return a non-None dict:

python
async def on_user_validate(self, event: EventEnvelope) -> dict:
    exists = await self.db.query_one("SELECT 1 FROM users WHERE email = $1", [event.payload["email"]])
    return {"exists": exists is not None}

Public API

publish(event_name, data, **kwargs) — fire and forget

python
await self.bus.publish("order.shipped", {"order_id": 123})

# With enterprise kwargs:
await self.bus.publish(
    "order.shipped",
    {"order_id": 123},
    key="customer_42",  # strict ordering per customer
    priority=8,
    delay=60            # deliver after 60 seconds
)

Non-blocking. Each subscriber runs as an independent asyncio.Task.

subscribe(event_name, callback, group=None, retries=0, backoff=0.5) — register a handler

python
# Broadcast — every subscriber receives the event
await self.bus.subscribe("order.placed", self.on_order_placed)

# With Retries — automatic exponential backoff on failure
await self.bus.subscribe("job.heavy", self.handle_job, retries=3, backoff=1.0)
# Wait times: 1.0s, 2.0s, 4.0s before final failure

# Consumer group — only one subscriber in the group receives each event (Round-Robin)
await self.bus.subscribe("job.heavy", self.handle_job, group="workers")

Register in on_boot(). Both async def and def handlers are supported. Sync handlers are offloaded to a thread pool.

unsubscribe(event_name, callback) — remove a handler

python
await self.bus.unsubscribe("order.placed", self.on_order_placed)

request(event_name, data, timeout=5) — async RPC

python
result = await self.bus.request("user.validate", {"email": "[email protected]"}, timeout=5)

Waits for the first non-wildcard subscriber to return a non-None dict. Raises asyncio.TimeoutError if no response arrives within the timeout.

WARNING

request() reintroduces coupling. Use only when a response is strictly required.

get_trace_history() — last 500 events

Returns List[TraceRecord]. Each TraceRecord has:

  • record.envelope — the full EventEnvelope
  • record.subscribers — list of handler names that received it
python
history = self.bus.get_trace_history()
for r in history:
    print(r.envelope.event, r.envelope.parent_id, r.subscribers)

get_subscribers() — current subscription map

python
subs = self.bus.get_subscribers()
# {"user.created": ["EmailPlugin.on_user_created", "SmsPlugin.on_user_created"]}

add_listener(callback) — real-time event sink

Called synchronously on every publish() with a flat dict record. Keep it fast.

python
def my_sink(record: dict) -> None:
    # record keys: id, event, emitter, payload, payload_keys, subscribers, timestamp, parent_id, ...
    asyncio.create_task(self._broadcast(record))

self.bus.add_listener(my_sink)

Used by: SystemEventsStreamPlugin, SystemTracesStreamPlugin.

add_failure_listener(callback) — subscriber failure sink

Called synchronously when a subscriber raises. Record shape:

python
{"event": "email.send", "event_id": "uuid", "subscriber": "SmtpPlugin.on_email_send", "error": "..."}

Used by: EventDeliveryMonitorPlugin to publish event.delivery.failed.

Failure Handling

Dead-Letter Queue (DLQ)

When a delivery exhausts all retries, the bus automatically publishes a failure event to _dlq.<original_event>.

  • Payload: Includes the original envelope, subscriber identity, error message, and attempt count.
  • Loop Protection: Wildcards and _dlq.* events are never dead-lettered.
  • Global Switch: Controlled by EVENT_BUS_DLQ_ENABLED=true env var.

Auto-unsubscribe after 5 consecutive FINAL failures

If the same subscriber reaches 5 consecutive final failures (after all retries are exhausted), the bus permanently removes it from all subscriptions. Each failure is logged as: [EventBus] 💥 Final failure in {subscriber}: {error} ({count}/5)

The counter resets on any successful execution. This prevents a broken subscriber from accumulating errors forever.

The drop is never silent: the bus publishes system.subscriber.dropped with payload {event, subscriber, error, consecutive_failures}. Its parent_id is the event that caused the final failure, so the drop appears chained in /system/traces/tree. Subscribe to it for alerting (a dropped subscriber of system.subscriber.dropped itself does not re-trigger it — loop-guarded).

To avoid it for external services — catch exceptions inside the subscriber and never let them escape to the bus:

python
async def on_order_placed(self, event: EventEnvelope) -> None:
    result = await self.stripe_tool.charge(event.payload["amount"])
    if not result["success"]:
        await self.bus.publish("payment.failed", event.payload)

Causality Tracking

The bus automatically propagates context vars into each subscriber's execution context:

  • current_event_id_var → ID of the triggering event (becomes parent_id of any event published inside a subscriber)
  • current_identity_var"PluginClass.method_name" (attributed automatically to logger calls)

No manual work required. Causal chains build themselves. See Observability for the endpoints that expose them.

Driver Pattern (The "Elastic" Part)

The EventBusTool decouples the brain (logic, tracing) from the transport via the EventBusDriver interface.

DriverStatusUse Case
InProcessDriverBuilt-inDefault. Fast, local memory. Simulates groups and delays.
RedisStreamsDriverBuilt-inDistributed transport across replicas. Activate with EVENT_BUS_DRIVER=redis_streams.
RabbitMQDriverNot includedWould add AMQP support. Implement EventBusDriver interface.
KafkaDriverNot includedWould add streaming and replayability. Implement EventBusDriver interface.

RedisStreamsDriver (distributed mode)

Set EVENT_BUS_DRIVER=redis_streams (plus the REDIS_* env vars if Redis is not on localhost) and start N replicas pointing at the same Redis — zero code changes:

  • Each event maps to a capped stream (bus:user.created), plus a firehose stream (bus:*) that powers wildcard subscribers.
  • subscribe(..., group="workers") becomes a real Redis consumer group: each message is delivered to exactly one consumer across the whole fleet.
  • Without group=, every subscriber in every replica receives every event (broadcast), matching in-process semantics.
  • Retries, backoff, DLQ, RPC and tracing keep working untouched — they live in the Bus, not the transport.

To use a custom driver, instantiate EventBusTool(driver=MyDriver()) and register it. Plugins remain 100% unaffected because they only interact with EventBusTool's public API. Every driver MUST pass the parity suite (tests/tools/test_event_bus_broker_parity.py), which runs parametrized over all built-in transports.

See the Elastic Deployment guide for the full operational path from one process to N replicas.

Anti-Patterns

Event loop:

python
# Plugin A publishes "inventory.check" → Plugin B publishes "order.created" → Plugin A → infinite loop

Using events as function calls:

python
# Wrong — use request() if you need a response, not publish()
await self.bus.publish("user.get_by_id", {"id": 42})
# You can't get the result back from publish()

Naming commands instead of facts:

python
# Wrong — commands imply a single owner
send.email  /  process.payment  /  user.create

# Right — facts can have multiple independent reactors
email.sent  /  payment.processed  /  user.created

event.delivery.failed loop protection: EventDeliveryMonitorPlugin explicitly suppresses re-publishing when the failing event was itself event.delivery.failed.

Released under the MIT License.