AMQP Event Semantics¶
Databús uses RabbitMQ as its internal async message backbone for run lifecycle domain events. Every completed run lifecycle FSM transition is published as a fire-and-forget message on a durable topic exchange, so other services can react to run state changes without polling the REST API.
The publisher (backend/messages/publisher.py) is fully implemented — it is
not a stub. It is called from a single seam in the run lifecycle service and
never blocks or fails the caller's request path.
Exchange¶
| Attribute | Value |
|---|---|
| Name | databus.events |
| Type | topic |
| Durable | Yes |
| Protocol | AMQP 0-9-1 via Kombu |
| Broker | RabbitMQ (message-broker service), same connection Celery uses as its broker |
| Connection | Connection(settings.CELERY_BROKER_URL), built lazily on first publish — importing the module never opens a socket |
Source: backend/messages/publisher.py.
Routing keys — runs.lifecycle.* namespace¶
Every publish is keyed runs.lifecycle.<event>, where <event> is the
lowercased .value of the RunLifecycleEvents enum member for the transition
that just completed (routing_key_for, backend/messages/publisher.py).
Event (RunLifecycleEvents) |
Routing key |
|---|---|
run_requested |
(not published — set at record creation, not via process_event) |
validate_run |
runs.lifecycle.validate_run |
initialize_run |
runs.lifecycle.initialize_run |
run_rejected |
runs.lifecycle.run_rejected |
run_confirmed_by_operator |
runs.lifecycle.run_confirmed_by_operator |
cancel_run |
runs.lifecycle.cancel_run |
run_tracking_started |
runs.lifecycle.run_tracking_started |
run_started |
runs.lifecycle.run_started |
run_tracking_lost |
runs.lifecycle.run_tracking_lost |
run_interrupted |
runs.lifecycle.run_interrupted |
run_short_turned |
runs.lifecycle.run_short_turned |
run_completed |
runs.lifecycle.run_completed |
run_tracking_restored |
runs.lifecycle.run_tracking_restored |
run_tracking_expired |
runs.lifecycle.run_tracking_expired |
Every event in RunLifecycleEvents that has at least one entry in the FSM
transition table (backend/runs/domain/lifecycle/transitions.py) publishes a
message the moment its transition succeeds. Consumers should bind:
runs.lifecycle.#— all run lifecycle events, orruns.lifecycle.run_completed(etc.) — a single event, orruns.#— everything under therunsnamespace (forward-compatible with any future non-lifecycleruns.*routing keys).
Source: backend/runs/domain/lifecycle/transitions.py, backend/messages/publisher.py.
Message envelope¶
build_envelope (backend/messages/publisher.py) produces this JSON body:
{
"event": "run_confirmed_by_operator",
"version": 1,
"occurred_at": "2026-08-19T00:00:00+00:00",
"producer": "databus",
"run_id": "0b2b6b2e-...-uuid",
"from_state": "Initialized",
"to_state": "Confirmed",
"data": {
"vehicle_id": "...",
"trip_id": "...",
"route_id": "..."
}
}
| Field | Type | Notes |
|---|---|---|
event |
string | RunLifecycleEvents.<member>.value — lowercase snake_case |
version |
int | Envelope schema version, currently 1. Consumers should branch on this if the shape ever changes |
occurred_at |
string | ISO-8601 UTC timestamp, generated at publish time (datetime.now(UTC)) |
producer |
string | Always "databus" |
run_id |
string | The run's UUID, stringified |
from_state |
string | RunLifecycleStates.<member>.value (e.g. "Initialized") — the state the run transitioned from |
to_state |
string | RunLifecycleStates.<member>.value (e.g. "Confirmed") — the state the run transitioned to |
data |
object | Best-effort extras. The lifecycle service populates whichever of vehicle_id, trip_id, route_id are available on the Run at publish time; any missing field is simply omitted, never sent as null |
Note from_state/to_state carry the FSM's display-style state values
("Initialized", "Confirmed", "In Progress", …) as defined in
RunLifecycleStates, not the upper-snake enum member names.
Source: backend/messages/publisher.py, backend/runs/services/lifecycle.py.
Where events are emitted from¶
Every event is published from a single seam:
RunLifecycleService._publish_run_lifecycle_transition in
backend/runs/services/lifecycle.py, called by _apply_transition
immediately after the transition's actions run and the new
run_lifecycle_state is persisted to Postgres — but the publish itself is a
fire-and-forget side effect that does not block the request/task path.
sequenceDiagram
participant Caller as REST view / Celery task
participant Service as RunLifecycleService
participant DB as PostgreSQL (Run)
participant Publisher as messages.publisher
participant RMQ as RabbitMQ (databus.events)
Caller->>Service: process_event(event, payload)
Service->>Service: check guards, run actions
Service->>DB: run.run_lifecycle_state = to_state; run.save()
Service->>Publisher: publish_event(event, run_id, from_state, to_state, data)
Publisher-->>RMQ: producer.publish(envelope, routing_key="runs.lifecycle.<event>")
Note over Publisher,RMQ: On any broker error: logged and dropped.<br/>Never raised back to Service or Caller.
Service-->>Caller: (to_state, guards, actions)
This means every REST-triggered transition (POST /api/runs/<id>/update/,
POST /api/create-run/) and every telemetry-triggered transition (fired from
realtime_engine.tasks.run_lifecycle_event, driven by the detection layer)
produces the same event shape on the same exchange — there is no separate
"internal" vs "external" event path.
Error policy: fire-and-forget, log-and-drop¶
Publishing a domain event is a deliberate best-effort side effect, not a guaranteed delivery:
publish_eventwraps the entire publish in atry/except Exception. Any connection error, channel error, or broker rejection is caught, logged withlogger.warning(..., exc_info=True), and swallowed — it never propagates back into the FSM transition path.- A small bounded retry is attempted first (
max_retries=2,interval_start=0,interval_step=0.2,interval_max=0.5, passed to kombu'sProducer.publish(retry=True, retry_policy=...)), but there are no publisher confirms and no outbox/at-least-once guarantee beyond that. - This is intentional: telemetry and lifecycle processing must never block
or fail because RabbitMQ is unavailable. A run's FSM transition, its
Postgres persistence, and its audit-log entry (
RunLifecycleTransition, viaGET /api/runs/<id>/history/) all complete regardless of whether the AMQP publish succeeds. - Consequence for integrators: a consumer that needs strict at-least-once
semantics should treat these AMQP messages as a best-effort notification
stream and reconcile against
GET /api/runs/<id>/history/— the authoritative, durable audit log — rather than relying on AMQP delivery alone.
Source: backend/messages/publisher.py, backend/messages/README.md.
Which transitions produce events¶
Any transition executed through RunLifecycleService.process_event that
passes its guards publishes an event — this covers essentially the whole FSM:
registration (validate_run, initialize_run), rejection/cancellation
(run_rejected, cancel_run), operator confirmation
(run_confirmed_by_operator), tracking and progress
(run_tracking_started, run_started), deviations
(run_tracking_lost, run_interrupted, run_short_turned), completion
(run_completed), and recovery/expiry (run_tracking_restored,
run_tracking_expired). The one exception is run_requested: a run enters
Requested state at record creation (Run.objects.create(...) in
CreateRunViewSet), not through process_event, so no lifecycle event is
published for it.
See the full transition table in
backend/runs/domain/lifecycle/transitions.py and the REST-facing surface in
REST API › Run lifecycle endpoints.
Integration guidance¶
- RabbitMQ (
message-broker) is running and healthy in both dev and prod compose stacks. - The publisher declares the
databus.eventsexchange on every publish (declare=[events_exchange]), so a consumer does not strictly need to declare it first — but should still declare it (idempotent) plus its own durable queue, to avoid depending on publish timing. - Bind your queue with
runs.lifecycle.#(all run lifecycle events) or a narrower pattern for the specific events you care about. - Deserialize the body as JSON and branch on
versionfor forward compatibility. - Treat delivery as best-effort (see error policy above); use
GET /api/runs/<id>/history/to reconcile or backfill missed events.
Communication boundaries¶
- Internal messaging (AMQP): services within the compose network, currently limited to run lifecycle domain events described on this page.
- External telemetry (MQTT): vehicles and devices.
Spec:
backend/api/realtime.yml. See MQTT telemetry.
External telemetry is treated as untrusted signal and must be validated before entering the domain messaging layer (see MQTT telemetry › Untrusted edge signal stance).