Producer and worker processes#
By default an emitter publishes and consumes in the same process, so every web instance runs every listener. To run listeners in a separate process, give each process a role.
Roles#
embeddedPublish and consume in this process. The default; existing apps keep working unchanged.
producerPublish only. No broker subscription is made and no consumer task starts.
workerConsume only. Listeners run here and
app.emit()raisesRuntimeError.
The role is set explicitly with EventsConfig.role (or a backend’s role=
argument) and defaults to embedded. The library reads no environment
variables; if you want to choose the role per deployment, read your own
setting and pass it in. litestar events run and drain always use
worker, so one image serves both processes: the web app is configured as
producer and the worker command overrides it.
Example#
Keep Litestar’s own @listener and app.emit(). Both processes load the
same app:
"""One Litestar app, two processes: a web producer and an event worker.
Both processes import this module. The app is configured as a producer, and
``litestar events run`` always starts the same app as a worker, so one image
serves both.
# RabbitMQ
docker run --rm -p 5672:5672 rabbitmq:3
# Web process: publishes only, consumes nothing
uv run litestar --app examples.producer_worker.app:app run
# Worker process: consumes only
uv run litestar --app examples.producer_worker.app:app events run
curl -X POST http://localhost:8000/orders
"""
from __future__ import annotations
from typing import Any
from litestar import Litestar, Request, post
from litestar.events import listener
from litestar_events.config import EventsConfig
from litestar_events.context import EventContext, event_headers
from litestar_events.contrib.rabbit import RabbitEventEmitter
from litestar_events.plugin import EventsPlugin
ORDER_CREATED = "order_created"
@listener(ORDER_CREATED)
async def send_receipt(order_id: int, ctx: EventContext) -> None:
print(
f"[worker] receipt for order {order_id} "
f"(correlation {ctx.correlation_id}, tenant {ctx.headers.get('tenant')})"
)
@listener(ORDER_CREATED)
async def log_in_web_process(order_id: int) -> None:
print(f"[web] order {order_id} created")
@post("/orders")
async def create_order(request: Request[Any, Any, Any]) -> dict[str, int]:
# Headers and the correlation id travel with the event to the worker.
# The correlation id is the request's ``x-request-id`` (see the config below).
with event_headers(tenant="acme"):
request.app.emit(ORDER_CREATED, order_id=42)
return {"order_id": 42}
app = Litestar(
route_handlers=[create_order],
listeners=[send_receipt, log_in_web_process],
plugins=[
EventsPlugin(
EventsConfig(
backend=RabbitEventEmitter,
role="producer",
# Events emitted while serving a request carry its id.
request_id_header="x-request-id",
# Runs in the web process; send_receipt runs on the worker.
local_listeners=[log_in_web_process],
# A named queue is durable: events survive a worker restart.
backend_options={"queue_name": "orders-worker"},
)
)
],
)
$ litestar --app app:app events run # worker
$ litestar --app app:app run # web (producer)
Running some listeners in the web process#
Listeners are placed per process by default, but you can keep chosen ones in
the web process with EventsConfig.local_listeners. Pass the listener
objects themselves (what @listener returns); each must also be in the
app’s listeners, otherwise startup fails.
EventsConfig(
backend=RabbitEventEmitter,
role="producer",
local_listeners=[update_cache], # the rest run on the worker
)
A
producercalls local listeners directly fromapp.emit(), in the emitting instance only, without the broker. They run as background tasks; a failure is logged and never raised into the request.A
workerskips them, and does not subscribe to events that only local listeners handle.An
embeddedemitter ignores the setting and runs every listener.Local listeners see the same values a worker would: the event is encoded and decoded first, so
typed_decodingapplies to them too.
The litestar events commands#
runConsume events until SIGINT or SIGTERM, then shut down cleanly. Forces the
workerrole.drain [--timeout SECONDS]Consume the backlog, then exit once no event has arrived for the timeout (default
EventsConfig.drain_timeout). Useful for batch or cron-style processing.statusConnect to the broker and report whether it is reachable. This connects as a producer, so for ZeroMQ it cannot detect a missing peer (connections are lazy) and may collide with a running app’s PUB port on the same host.
infoPrint the backend, role, durability and registered events.
run and drain refuse the in-memory emitter, which is process-local.
Wire format and typed listeners#
Every backend uses one codec, so the producer and the worker always agree on
the bytes. The default JSONCodec sends
{"v": 1, "args": [...], "kwargs": {...}}.
Encoding happens inside app.emit(): a payload that cannot be serialized
raises SerializationException at the call site instead of being dropped
later by a background task. datetime, UUID, Decimal, dataclasses
and msgspec/Pydantic models are supported. Messages without "v" (written
by earlier releases) are still accepted; an unknown version is rejected and
logged.
By default the worker converts each argument to the type annotated on the
listener, as FastStream does: a datetime annotation receives a
datetime, a dataclass, msgspec or Pydantic annotation receives the model.
Unannotated parameters, and listeners whose annotations cannot be resolved
(for example, names imported only under TYPE_CHECKING; a warning is
logged once), receive plain JSON values. Set typed_decoding=False to get
plain JSON values everywhere, as in earlier releases:
@listener("order_created")
async def send_receipt(order: Order, placed: datetime) -> None: ...
EventsConfig(backend=RabbitEventEmitter) # typed_decoding=True is the default
A value that does not match its annotation fails that listener only; the
error is logged like any other listener failure. Pass EventsConfig(codec=...)
with your own object implementing encode, decode, invoke and a
content_type string (see below) to change the format entirely (both processes must use
the same one). The codec’s content_type is also set in each broker’s native
metadata where one exists, so consumers outside this library can tell the
format:
Backend |
Where |
|---|---|
RabbitMQ |
the AMQP |
Kafka, Confluent |
record header |
NATS |
message header |
SQS, Google Pub/Sub |
message attribute |
Redis, ZeroMQ, PostgreSQL |
nowhere: these transports have no per-message metadata |
MQTT |
the MQTT 5 |
This library does not check the value when consuming: the worker always decodes with its configured codec.
Event headers and the correlation id are also copied into the same native
metadata, so brokers and tools outside this library can route or trace on them.
The envelope stays the source of truth: workers read both from it, never from
the native copy. A user header named content-type is dropped in favour of the
codec’s.
Backend |
Headers and correlation id |
|---|---|
RabbitMQ |
AMQP |
Kafka, Confluent, NATS |
record or message headers; correlation id as |
Google Pub/Sub |
message attributes; correlation id as |
SQS |
correlation id only, as attribute |
MQTT |
MQTT 5 user properties, on v5 only |
Redis, ZeroMQ, PostgreSQL |
nowhere |
Whole-body models#
A listener with one parameter annotated as a model (dataclass, msgspec
Struct, Pydantic model or TypedDict) can receive the whole keyword
payload:
@listener("order_created")
async def on_order(order: Order) -> None: ...
app.emit("order_created", id=1, total=9) # builds Order(id=1, total=9)
app.emit("order_created", Order(id=1, total=9)) # also works
This applies only when the emit has keyword arguments that do not name the
parameter, and the listener has no **kwargs and no other parameters.
Headers and correlation ids#
Every event carries a correlation id (generated if you don’t set one) and optional string headers. They travel in the message envelope, so they work on every backend; they are not native broker headers.
from litestar_events.context import EventContext, event_headers
with event_headers(tenant="acme", correlation_id=request_id):
app.emit("order_created", order_id=1)
@listener("order_created")
async def on_order(order_id: int, ctx: EventContext) -> None:
ctx.headers["tenant"], ctx.correlation_id # injected by annotation
Inside a listener, litestar_events.context.current_event() returns the same
context. Events emitted from a listener inherit its correlation id, so one
request can be followed across processes. Headers are not inherited.
Request ids#
To use the incoming request’s id as the correlation id of the events emitted while serving it, name the header in the config:
EventsConfig(backend=RabbitEventEmitter, request_id_header="x-request-id")
The middleware (RequestIdMiddleware) is
added outermost. A missing id, or one that is not 1-128 characters of
A-Z a-z 0-9 . _ -, is replaced by a generated one. The id is echoed on the
response unless echo_request_id=False. Without request_id_header no
middleware is added. Skip routes such as health checks with
request_id_exclude_path_pattern="^/health", or per route with
opt={"exclude_request_id": True}.
A custom codec implements encode(Message), decode(data) -> Message and
invoke(fn, event_id, message), where
Message holds args, kwargs, headers
and correlation_id.
Delivery guarantees by backend#
While no worker is connected, a non-durable backend loses events. run and
drain print a warning for these.
Backend |
Durable? |
|---|---|
Redis (Pub/Sub), ZeroMQ, MQTT (QoS 0), NATS (core), PostgreSQL (LISTEN/NOTIFY) |
No. At-most-once; events emitted while no worker is connected are lost. |
RabbitMQ |
Only with |
Google Pub/Sub |
Only with |
Kafka, Confluent |
Only with an explicit |
SQS |
Yes. |
Testing#
litestar_events.contrib.memory.MemoryEventEmitter dispatches events inside
the process. Use it in tests; it needs no broker.