Table Design
Edit this pageEvery SQLStreams table is one of two kinds: a shared control-plane table,
created once by System().Register (the first stream Register runs it), or
a member of one stream’s family,
created by the stream’s Register and named by the stream’s id. The
architecture page explains why the split
exists; this page is the map of what’s actually there.
SHARED CONTROL PLANE ONE FAMILY PER STREAM (suffix = id)
created by System().Register created by Stream(name).Register
┌───────────────┐
│ system_config │
└───────┬───────┘
│ system_id
┌───────▼──────┐ id names the family ┌─────────────────────────────────────┐
│ stream_config │ ····························▶ │ message_log_1 (partitioned) │
└───────┬──────┘ │ idempotency_key_1 │
│ stream_id │ compaction_head_1 │
┌───────┴───────────────┐ │ exception_queue_1 · delivery_log_1 │
┌──▼───────────────┐ ┌─────▼─────────────────┐ │ consumer_group_cursor_1 │
│ stream_config_log │ │ consumer_group_config │◀───│ claim_lease_1 · message_key_lease_1 │
└──────────────────┘ └───────────────────────┘ FK │ binding_config_1 │
from │ binding_config_log_1 │
worker_config ─▶ worker_config_log cursor, └─────────────────────────────────────┘
└─▶ worker_instance binding
schedule_config ─▶ schedule_cursor tables
migration_log
The control plane
system_config -> stream_config -> consumer_group_config is the
ownership spine, each link a real foreign key with
ON DELETE CASCADE. The stream_config row is where a stream’s
identity and config live: name is unique, and its id is what
names the family’s physical tables. A payload schema bump is not a
new row here — the version sits on each message_log row
(schema versions). partition_size is immutable
after creation because the log’s partition boundaries depend on it
staying fixed. empty_compaction_head_ttl_ns bounds how long an idle,
headless compaction-key row remains before the stream janitor may sweep it.
The fleet and history tables hang off the spine with one shared
pattern: worker_config, schedule_config, and migration_log each
carry all three owner columns (system_id, stream_id,
consumer_group_id) and a CHECK that exactly one is set — a worker
belongs to the system, a stream, or a group, never two.
worker_instance is the live-copy row under a worker_config: a
token only its creator can match and a heartbeat-renewed
expires_at, the same lease shape the
lifecycle uses for claims. schedule_cursor
is the scheduler’s position in each job’s schedule
(next_scheduled_at, last_scheduled_at), a 1:1 runtime row beside
the near-static schedule_config, so per-fire churn never touches
the config row.
Two of these are append-only operator trails, never updated:
stream_config_log and worker_config_log snapshot every
declaration, while the stream_config and worker_config rows stay
the truth.
worker_instance_log snapshots each successful claim and renewal in the
same transaction as the live-row change. It copies the instance’s identity,
creation time, expiry, token, and failure count. Release and expiry cleanup
remove the live row without deleting these snapshots; deleting the worker
removes its history. The existing manager sweep retains snapshots for 24 hours
after recorded lease expiry by default. The log has no lifecycle-operation
field; proposed lease-coverage evaluation uses the copied
timestamps.
The family
Suffix 1 throughout — the stream’s id from the catalog.
message_log_1— the log.id BIGSERIAL PRIMARY KEYis the position, and the table is partitioned by id range (message_log_1_0,message_log_1_1, …),partition_sizerows each. Beside thepayload:routing_key,message_key,compaction_rank(NULL unless the message opted into compaction), and a sparseoptionsdocument.idempotency_key_1— produce dedup. The caller’s key, resolved to a uuid, is the primary key; rows expire on a TTL sweep.compaction_head_1— the durable row-lock identity for one compacted message key. Its head id, schema version, and rank are either all present, naming the winner, or all NULL while the key has a lock but no head.created_atrecords the identity’s creation;updated_atmoves when the head changes or an empty row is locked again. Compacted produce upserts the winner in its transaction, and the janitor uses the partial(updated_at, compaction_key) WHERE message_id IS NULLindex to find expired empty rows without scanning materialized heads.exception_queue_1— deliveries off the mainline path,PRIMARY KEY (consumer_group_id, message_id): status, the message’s key and the concurrency policy the group resolved for it, attempts,can_run_afterbackoff, last error, and a per-row lease. The statuses are the lifecycle state machine; the key column is what a same-key predecessor lookup reads.delivery_log_1— the event trail, append-only, keyed by its ownid;attemptis the run each event belongs to, and a run can have more than one (a claim handed back at a busy key gate, then the run).consumer_group_cursor_1— one row per group (consumer_group_id UNIQUE):claimedandcommitted, plus the snapshot-fence columns (settled_head,pending_head,pending_xid) that keep claims from reading past ids whose producing transactions haven’t committed.claim_lease_1— range claims:(low, high), an expiry, and areclaimscounter that quarantines a range reclaimed too often.message_key_lease_1—PRIMARY KEY (consumer_group_id, message_key): at most one in-flight delivery per message key per group, the ordering opt-in.binding_config_1/binding_config_log_1— the group’s routing patterns, and the append-only trail of every declaration attempt.
Waiting for earlier producers
A consumer must wait for unfinished producers before advancing past their
message ids. For orders and its processor group, producer A can take an
earlier transaction id, then producer B inserts message 1 and stays open.
A inserts message 2 and commits. Seeing message 2 does not make it safe to
claim through 2: message 1 is still invisible.
An active poll reads the visible head and allocates its own transaction
id in the same statement. That statement finishes before the claim starts.
The claim waits until every transaction older than that observation has
finished. PostgreSQL’s snapshot xmax does not provide this bound: an
already-running producer can have an id at or above it.
An empty claim saves its observation for the next poll. Caught-up polls remain read-only; active polls allocate a transaction id. Existing skipped deliveries need reconciliation; changing the claim rule does not replay them.
The deliberate absences
- No
stream_idcolumn anywhere in the family. The table name is the scope; a cross-stream read resolves ids from the catalog first and loops the families. - No foreign key into
message_log_1. Delivery rows carry baremessage_idvalues and cursor positions are plain ids, so retention canDROPa whole partition without a constraint check walking every referencing row. - FKs cross back to the control plane only from the long-lived
tables —
consumer_group_cursor_1,binding_config_1, andbinding_config_log_1declareconsumer_group_idforeign keys; the churn-heavyexception_queue_1,delivery_log_1, and the lease tables carry the same column unconstrained. - Destroying a stream drops the family’s tables outright — cleanup
is
DROP TABLE, never a cross-tableDELETEsweep.