Generated from mqlite main · docs/internals.md — source v0.3.3; latest release: v0.3.3. These docs track main; check release notes for published artifacts. Edit the canonical Markdown and re-run tools/build.mjs.

How mqlite works — building a reliable queue on one SQLite file

This is the teaching document: how mqlite is actually built, table by table and decision by decision — and why this construction yields a queue you can trust. Nothing here is required to use mqlite; all of it helps you predict what it will do. Companion reading: concepts.md (the model from the outside), conformance.md (every promise below, pinned as a test).


1 · The bet: a queue is rows plus discipline

Strip any message queue to its skeleton and you find three ideas:

  1. a durable list of messages,
  2. a claim operation that hands a message to exactly one consumer at a time,
  3. rules for failure — what happens when that consumer never comes back.

Kafka builds this on a replicated log. RabbitMQ builds it on a custom broker with its own storage engine. mqlite's bet is that for a huge class of workloads — background jobs, outboxes, fan-out, pipelines — all three ideas fit inside something you already trust: a SQLite database with ACID transactions.

A message is a row. A state is a column. A transition is one UPDATE guarded by a WHERE clause — which under a transaction is an atomic compare-and-swap. Get the schema and the transitions right, and the reliability story stops being clever code and becomes database invariants.

The rest of this document is those invariants, in the order they earn their keep.

2 · The schema: eight tables, one of which matters most

Everything lives in eight STRICT tables (engine/schema.go). Seven carry the machinery; one carries your data:

Table Job
messages the queue itself — every message, in every state
queues per-queue config: lock duration, retry bound, TTL, dedup window, ordering mode, DLQ retention
subscriptions the topic fan-out roster (topic → subscription + optional filter)
dedup duplicate-detection window
settlement_receipts makes acknowledgements idempotent (§7)
receive_attempts makes receives idempotent (§7)
meta one row: the schema guard token (§9)
access_keys SHA-256 token digests, permission sets, expiry and revocation metadata (v0.3.1)

The heart, abridged:

CREATE TABLE messages (
    id             INTEGER PRIMARY KEY AUTOINCREMENT,  -- SQLite rowid = the sequence number
    queue          TEXT NOT NULL REFERENCES queues(name),
    state          TEXT NOT NULL DEFAULT 'active'
                       CHECK (state IN ('active','locked','deferred',
                                        'scheduled','completed','dead_lettered')),
    visible_at     INTEGER NOT NULL DEFAULT 0,   -- delivery time (epoch ms)
    locked_until   INTEGER NOT NULL DEFAULT 0,   -- lock lease expiry
    lock_token     TEXT,                         -- the fencing token (§4)
    delivery_count INTEGER NOT NULL DEFAULT 0,
    expires_at     INTEGER NOT NULL DEFAULT 0,   -- TTL deadline; 0 = none
    body           BLOB NOT NULL,
    -- ... message_id, group_id, subject, properties, dead_letter_reason ...
) STRICT;

Three deliberate choices hide in this table:

3 · The single-writer model: concurrency by subtraction

Here is the design decision everything else leans on. SQLite allows one writer at a time — most systems fight that; mqlite builds on it:

The punchline: mqlite doesn't implement mutual exclusion between consumers — it arranges to never need it. Concurrency control by subtraction.

4 · Claiming: Peek-Lock is one atomic UPDATE

The hot path — a consumer asking for work — is a single statement (engine/claim.go, abridged):

UPDATE messages
   SET state = 'locked',
       locked_until   = :now + :lock_duration,
       lock_token     = :random_128_bit_token,
       delivery_count = delivery_count + 1
 WHERE id = (
       SELECT m.id FROM messages m
        WHERE m.queue = :queue AND m.state = 'active'
          AND m.visible_at <= :now
          AND (m.expires_at = 0 OR m.expires_at > :now)
          -- head-of-line probes for grouped messages (§5) go here
        ORDER BY m.id ASC
        LIMIT 1)
RETURNING id, body, delivery_count, ...;

One statement, three jobs:

  1. Select the eligible head (oldest active, already visible, not expired).
  2. Claim it — flip to locked, stamp a lease and a token, count the attempt.
  3. Return the full message to the caller.

Because the single writer executes this atomically, "select then claim" can never race: whoever's UPDATE runs first gets the row; the next one gets the next row. A batch Receive runs this in a loop inside one transaction — one commit, one fsync, for the whole batch (that change alone took a 1-vCPU broker from 591 to 1,507 msg/s of sustained drain).

The lock is a lease plus a fencing token. locked_until is a timestamp — not a connection, not a heartbeat — so a dead consumer's lock simply expires. lock_token is 128 random bits handed only to the claimer, and every acknowledgement must present it:

DELETE FROM messages
WHERE id = :seq AND queue = :q AND lock_token = :token
  AND state = 'locked' AND locked_until > :now;

The current token and an unexpired lease are both required. Equality at the deadline is already expired, including before the background reaper runs. The broker samples time after local writer admission and afresh for every settlement statement and remote retry. A stale consumer's new settlement affects zero rows and returns lock_lost. After redelivery, the changed token also fences the previous owner. Renew and RenewBatch require the same live lease and never revive an expired one. A live exact-request receipt can replay an already committed settlement (§7).

Receive-and-delete removes the claimed row in the same transaction. It grants no lease: both its token and deadline are cleared before the response or attempt record is created. The SDK therefore returns a zero LockedUntil time in that mode.

5 · Staying fast: partial indexes and an O(n²) war story

A claim must find "the oldest active message" without caring how many locked, scheduled or dead-lettered rows sit around it. The tool is SQLite's partial indexes:

CREATE INDEX idx_msg_active ON messages(queue, id) WHERE state = 'active';

Only active rows live in this index, pre-sorted by (queue, id) — so the claim's inner SELECT is one index seek, O(log n), even with a 200k-row backlog behind a lock. Each background sweep gets its own partial index the same way (locked by expiry, scheduled by due time, DLQ by queue, TTL by deadline).

The war story that shaped this (MQLITE-22): grouped messages — on the FIFO modes, and on standard queues whenever a message carries a group_id — must also check that no earlier message of the same group is still in flight: a head-of-line probe. Written the obvious way — one big NOT EXISTS (... WHERE ((b.state='locked' AND b.locked_until > :now) OR b.state IN ('deferred','scheduled')) AND b.id < m.id) — SQLite's planner can't match the OR/IN mixture to any partial index and falls back to a backward rowid scan: O(n) per candidate, O(n²) to drain a blocked backlog. On a 49k-message queue that was 1.29 seconds per claim. The fix is mechanical once you see it — one EXISTS per state, each a single state = '...' equality the planner can match to a partial index:

   ... AND NOT (
       EXISTS (SELECT 1 FROM messages b WHERE b.queue=m.queue
                 AND b.group_id=m.group_id AND b.state='deferred'  AND b.id<m.id)
    OR EXISTS (... b.state='scheduled' ...)
    OR EXISTS (... b.state='locked'    ...))

Same logic, three orders of magnitude faster (at a 20k blocked backlog: 29.7 s → 24 ms per claim) — and, since v0.2.0, the locked probe deliberately ignores lease expiry, so a crashed consumer's group stays in order (the reaper redelivers the head first) instead of letting successors overtake it.

The deeper lesson mqlite took from this: query plans are behavior, so they are tested. TestClaimPlanPinning runs EXPLAIN QUERY PLAN in CI and fails if the claim ever stops seeking idx_msg_active, or if any probe plans as a scan. The O(n²) bug cannot quietly return.

6 · Failure is the normal path

Everything above assumes things go wrong constantly. The machinery that absorbs it is small and boring on purpose:

Background loops (all writing through the same single connection):

Loop Cadence Job
reaper 1 s expired locks → back to active — or to the DLQ once delivery_count ≥ max
scheduler 1 s scheduled rows whose time arrived → active
TTL sweep 10 s expired messages → DLQ (or discard), from any non-terminal state
janitors 60 s prune old receipts, dedup entries; return freed pages to the OS

Crash recovery is one statement. On Open, every locked row is an orphan of a previous process (locks are rows, remember) and is reclaimed — back to active, or straight to the dead-letter state if it died on its final allowed attempt. Same rule as the reaper; a crash never buys an extra delivery.

Retries are bounded by data. delivery_count is incremented at claim time inside the claim UPDATE itself, so no failure mode can lose count. When it reaches the queue's max_delivery_count, the reaper/recovery route the message to state='dead_lettered' — the DLQ is not a separate queue, just a state you can Peek, Redrive (back to active, count reset) or Purge, with retention bounds (age/count/bytes) so an unattended broker doesn't grow forever.

7 · Honest at-least-once, and the two receipt tables

Exactly-once delivery across crashes is not a thing anyone can sell you: after a crash, "did my acknowledgement land before the power died?" is unanswerable, so someone must retry, so somebody may see a message twice. mqlite says this out loud — default Peek-Lock delivery is at-least-once; make handlers idempotent — and then works to make the window small and the retries safe:

Notice the pattern: each idempotency mechanism is a small table written in the same transaction as the operation it protects. No timeouts-and-hope, no distributed coordination — the ACID transaction is the coordination.

8 · The payoff: the queue that lives inside your transaction

Because the queue is a SQLite database, the embedded mode can do something no external broker can offer:

err := mq.Tx(ctx, func(tx *engine.EngineTx) error {
    // tx.Context(), not the outer ctx: on a local store an interrupted statement
    // inside the transaction leaks the connection and wedges the database.
    if _, err := tx.SQL().ExecContext(tx.Context(),
        `INSERT INTO orders(id, total) VALUES(?, ?)`, 42, 1999); err != nil {
        return err            // rollback: no order, no event
    }
    _, err := tx.SendOne("order-events",
        engine.OutMessage{Body: []byte(`{"order":42}`)})
    return err                // commit: order AND event, atomically
})

Your business tables and the messages table share one file, so one *sql.Tx covers both. The dual-write problem — "the row committed but the event didn't" — is not mitigated here; it is unrepresentable. This is the transactional outbox pattern with no outbox table, no poller, and no CDC pipeline.

On a local store, tx.Context() protects SQL from mid-statement interruption. Raw tx.SQL() calls bypass mqlite's per-statement cancellation guards, so check the original ctx.Err() between business statements and return promptly when it is cancelled. Cancellation observed before commit rolls back both the business writes and the enqueue, even if the callback returns nil; it cannot stop a callback that keeps running. See the cancellation contract.

9 · What keeps it true

Every claim in this document is enforced by something that fails loudly:

10 · What this design refuses to do

The same choices that buy the reliability set the ceiling, and it's better you hear it from us:

If your problem fits inside those lines — one node, thousands-per-second, must never lie to you — then the whole stack under your queue is a ~30 MB process, one file on disk (copyable with cp when stopped; use VACUUM INTO for a hot backup — see operations runbook), and a set of promises you can re-verify by running go test.

11 · Appendix: swapping the engine — the contract a new store must sign

Does any of this have to be SQLite? No. The engine needs a set of guarantees, not a brand — SQLite is simply the implementation that delivers all of them in one pure-Go file. If you ever wanted to port mqlite onto another database, an embedded KV store, or something self-built, this is the contract your store signs. Each item maps back to the section that depends on it.

The eight core guarantees:

# Your store must provide Because of
G1 Durable, ordered enqueue with a strictly-increasing, never-recycled sequence number §2 — seq is the ordering anchor and the settlement handle
G2 Atomic claim: select the eligible head and lock it as one indivisible operation — two consumers can never win the same message §4 — the claim UPDATE
G3 Per-state ordered lookup in O(log n) on a deep backlog: "oldest active in queue Q", "expired locks", "due scheduled", per group §5 — the partial indexes
G4 Secondary lookups: dedup by message id, DLQ scans, expiry by deadline §§6–7
G5 Multi-row ACID transactions — and one that the application can join with its own tables §7 receipts, §8 the outbox
G6 Crash recovery from data alone: after kill -9, the committed state is complete and consistent — locks must be rows, not memory §2, §6
G7 Range deletes with space reclamation — retention and purge must return disk, not just tombstone §6
G8 Embeddability: in-process, no CGO, no sidecar, ideally one copyable file the whole point

Four more that this document's fine print implies — easy to miss, fatal to skip:

The good news: your migration test suite already exists. The conformance TCK is written against behavior, not against SQLite — a store that passes it (with the race detector on) has proven G1–G7. Two things do not port and must be re-invented per engine: the plan-pinning tests (§5's EXPLAIN QUERY PLAN assertions are SQLite's dialect — you owe your new engine an equivalent performance pin, or the O(n²) class returns silently) and the schema golden test (pin whatever your layout artifact is).

How the usual candidates grade against this bar:

That grading is why mqlite runs on SQLite today: in a weighted evaluation of six candidates it was the only one delivering all eight guarantees — and uniquely the two differentiators, the shared-transaction outbox and the Turso remote. The engine keeps the question open behind a narrow store boundary (enqueue / claim / settle / recover / in-tx), but the price of admission is this appendix, with the conformance suite as the judge.


Want the view from outside? concepts.md. The wire-level details? api-reference.md. The receipts for the performance claims? benchmark.md.