MongoDB¶
The high-volume, append-only store for the delivery-event log.
MongoDB is the home of one thing in Beacon: the record of what happened to every message messaging-service sends. Each time a message goes out and the provider tells us it landed (or didn't), messaging-service writes a single delivery event here. That's it — no templates, no schedules, no accounts, no billing. Those live elsewhere (PostgreSQL for messaging-service's relational data, MySQL for accounts and billing).
The shape of the data drives the choice of store. Delivery events arrive in the millions per day, are written once and never updated, and the set of useful fields has grown over the years. A document store that appends cheaply and lets the schema drift without migrations fits that better than a high-churn append-only table in a relational database. The full reasoning is in ADR 0001 (and its follow-up, ADR 0002).
What lives here¶
| Holds | Owned by | Notes |
|---|---|---|
messaging.delivery_events |
messaging-service | schemaless; see Delivery event and ADR 0001 |
One database (messaging), one collection (delivery_events).
messaging-service is the sole writer and the
only service that connects to MongoDB at all — it declares the dependency in its
depends_on, which is what populates the auto-generated "Used by" list. No other
Beacon service reads or writes here; cross-domain readers that want delivery
outcomes consume the message.delivered / message.failed events off
RabbitMQ instead of querying this store directly.
Where it sits in the send flow¶
The write to MongoDB is the second-to-last step of a send: render, send, record, then announce the outcome on the broker.
sequenceDiagram
participant MQ as RabbitMQ
participant Messaging as messaging-service
participant Provider as Email provider
participant Mongo as MongoDB
MQ-)Messaging: subscription.upgraded
Messaging->>Messaging: render template (PostgreSQL)
Messaging->>Provider: send
Provider-->>Messaging: delivered / failed / bounced
Messaging->>Mongo: insert one delivery_event (write-once)
Messaging-)MQ: message.delivered / message.failed
Two properties of this flow matter for anyone operating the store:
- The insert is the system of record for "what happened." The broker events are
notifications; the durable truth lives in
delivery_events. If the Mongo write fails, the outcome is effectively unrecorded even if the event was emitted. - Writes are one-way. A document is inserted once and never touched again. There is no update path in normal operation, which is why the collection grows monotonically and why retention (below) is the main lever on its size.
What a document looks like¶
The collection is schemaless, so "the schema" is really the union of every shape that has existed over time. A recent document looks like this:
{
"_id": ObjectId("6630f1a2c3d4e5f6a7b8c9d0"),
"message_id": "msg_01HX...",
"org_id": "org_8f3a",
"status": "delivered",
"channel": "email",
"provider_id": "sg_7c1e...",
"region": "us-east-1",
"attempts": 1
}
Older documents are missing the newer fields entirely — that's expected, not corruption. The full field-by-field reference, including the dates each field appeared and how to read older rows, lives on the Delivery event model page. The short version every reader needs:
| Field | Present on |
|---|---|
message_id, org_id, status, channel |
all documents |
provider_id |
documents from 2024-03 onward |
region |
documents from 2024-07 onward |
attempts |
most documents — treat absent as 1 |
Code defensively — field presence drifts by record age
There is no schema validator on the historical data, so any query that scans
across a wide date range will hit documents that lack fields added later. Filter
on existence ({ region: { $exists: true } }) or default in code; never assume a
field is universally present. This is the single most important fact about the
store, and it's the cost ADR 0001 knowingly accepted.
A validator now guards new writes (ADR 0002)
ADR 0002 adds a MongoDB
JSON-schema validator on messaging.delivery_events that constrains new
inserts so they're uniform going forward. It is not retroactive — existing
documents are grandfathered — so the drift caveat above still holds for historical
data, and will keep holding until (or unless) a backfill runs. The validator
stops the bleeding; it doesn't heal the old wounds.
Indexing & query patterns¶
Reads fall into two buckets, and the indexing is shaped around them:
- Recent-window lookups — "what happened to this message / this org lately."
These filter by
message_idororg_idand a recent time bound (time is carried by theObjectId, which is monotonic by creation). They're the hot path. - Aggregate reporting — per-org delivery and failure rates over a period.
These scan more documents and lean on
org_id+status.
Range queries are date-shaped, and so is the drift
Because _id encodes creation time, a date range is naturally an _id range.
That's convenient — but it also means a "last 90 days" query stays inside uniform
recent documents, while a "since 2023" report crosses the drift boundaries above.
The wider the window, the more you must defend against missing fields.
Notes & operations¶
Operational notes
- Engine: MongoDB 7.
- Footprint: one database (
messaging), one collection (delivery_events), growing monotonically at millions of inserts/day. It is by far the largest data store in Beacon by row count, though each document is small. - Write pattern: insert-only, no updates, no deletes in normal operation. That makes it friendly to replication and backup (no in-place churn) but means size is governed entirely by insert rate and retention.
- Schema enforcement: historically none — hence the field-presence drift documented on the delivery-event page. A validator on new writes is being introduced per ADR 0002; it does not touch existing documents.
- Retention: the collection only grows, so a TTL or archival policy is the practical ceiling on its size. Confirm the current policy with the messaging team before assuming old events are still queryable — long-range reports depend on it.
- Backups: standard snapshot/restore is sufficient given the write-once pattern; there's no transactional coupling to the relational stores to coordinate, since every cross-store relationship here is logical only.
Suspected dead
debug_payload appears on only a handful of 2023 documents — it looks like a
temporary debugging field left in by accident and is not written by current code.
Flagged on the delivery-event model; recorded here
as the physical store that carries those stray rows.
Related pages¶
Depended on by: messaging-service
Confidence: 70/100 · Last reviewed: 2026-06-14 · Status: active