Design
How Straum works is the model in the guide’s
words. This page is the same model for contributors: who takes part,
what each use case does, how the crates depend on each other, what
the code holds true, and which decision shaped what. A name in code
font, such as Writer::start, is the type or function that holds a
rule, under crates/; the glossary gives each name
its type in the code.
Four participants:
- PostgreSQL: the source of truth. Passive: it publishes the stream and serves the snapshot loads. Nothing of Straum runs in it.
- Straum (
straum run): active toward PostgreSQL, reactive toward clients. One writer thread opens the slot, loads the tables and applies every commit; tokio threads answer asks meanwhile. - Clients: the apps that
POST /ask. They never see a table, a join or a row, only the names a view gives its keys and measures. - People: a table owner, who runs the setup SQL
straum planprints once, and an operator, who runs the binary and reads/readyz,/status,/viewsand the log. In Kubernetes the chart is the operator.
Architecture
Six crates, dependencies inward only, enforced by
scripts/check-layers.py.
The binary depends on the three adapters. straum-pg and
straum-http depend on the use cases in straum-app;
straum-notation needs only the domain.
straum-domainhas no dependencies and no I/O. Everything an ask or a change needs is a plain struct, enum or function.straum-appholds the use cases and the two ports.Sourceis the one trait PostgreSQL sits behind;Spillis where sealed buckets go. No adapter forSpillships; the binary passesNone.- The adapters never meet:
straum-httpis handed a query reader by the binary (ReadAskinstraum-http), so HTTP never knows Straum SQL.
Data moves the other way at run time: PostgreSQL feeds straum-pg,
which hands commits to the writer, which applies them to the store;
apps post asks to straum-http, which reads the store through the app.
Use cases
| Use case | Actor | Input | Output | Detail |
|---|---|---|---|---|
| Set up PostgreSQL | Table owner | Spec, publication, role | Setup SQL, run once | Setting up PostgreSQL |
| Run Straum | Operator | Spec file, DSN, flags | A serving Straum | Running Straum |
| Start | Writer | Spec, Source | A store at L | Start |
| Follow | Writer | The stream | One commit applied | Applying a commit |
| Answer an ask | Client | Query and parameters | Answer with as_of | Answering an ask |
| Start over | Writer | A failure | A new start | States |
| Stop | Operator | SIGTERM or SIGINT | Exit, slots dropped | straum run |
Run Straum is run in the binary: read the spec and the DSN, bind the
listener, make an App with an empty Store and the Limits,
make a Writer over a Postgres source, spawn the writer thread,
serve HTTP until a signal. A panic aborts the process (the hook run
sets), since the store may be half written. Start over is
Writer::step and run_writer: the failure is recorded and
Following removed, so asks answer 503; the thread sleeps the
backoff; the next step is a start, whose open drops the old stream
first, and PostgreSQL drops its slot as the connection closes.
Start
Writer::start:
- Drop the old store and empty the spill, so two stores never take memory at once.
Source::open: connect, begin a read-only repeatable-read transaction, create a temporary logical slot withpgoutputandSNAPSHOT 'use', read L. Read the catalog and check it against the spec.Source::loadeach dim, then each fact: set the column types from the catalog, run one binaryCOPYof the columns the spec names, add rows by the batch (Store::add_rows). The snapshot transaction commits with the last load.- Read the server’s WAL once; start the Lag from when the slot was opened.
- Take the write lock, swap the store in, set Following at L, count the start. Straum is ready.
- Run a seal pass. The stream thread starts at the first
next_commit: it reads the stream, answers the server’s keepalives and confirms what the writer has applied, so the server can recycle WAL.
Any error is a Failure, and the next step is a start over. The load fills a new store outside the lock, so asks never wait on PostgreSQL; they answer 503 until step 5.
With --wal physical, step 2 differs (ADR-0049):
- Read the catalog on a plain connection and check it.
- Create a temporary physical slot; it reserves WAL from the last
checkpoint’s redo point. A checkpoint in progress may set that past
the WAL flushed, so wait for
pg_current_wal_flush_lsn()to reach it: the server streams only flushed WAL. Read each spec table’s Layout (physical::read_layouts): its file, its attributes, dropped ones too, and its enums’ labels by OID. After the slot, so a rewrite between the two is in the WAL read. Start the stream thread at once, with a Pipeline (wal/pipeline.rs):Recordscut the bytes into records,Heapkeeps the pages,Transactionsholds changes by xid,Publisherturns commits into pgoutput messages for the sameDecoder. - Try
pg_log_standby_snapshot(), then wait until the stream is at its Consistent point: past the firstRUNNING_XACTSand every transaction it names. - Begin the repeatable-read transaction, read
pg_current_snapshot()and the WAL position, check the catalog again, and hand the snapshot to the stream thread (Stream::see). Commits it saw are dropped. L is the LSN before the first commit it didn’t see. PostgreSQL logs a commit before snapshots see it, and waits for synchronous standbys in between, so a snapshot may miss a commit the stream dropped as before its start. The thread then answersSight::Blind, and Straum rolls back and takes another.
The thread reads on through the load, so the walsender never waits on
a reply. The pure parts, wal/ and the pipeline, read no socket: a
table’s layout read again after DDL, with its enums’ labels, comes in
through a closure.
The store
What the store holds, mutable, one per process, owned by App.
One keyed rollup per fact, a bound rollup for a fact whose joins all read fact columns, one projection per dim. A rollup is one level per width; a hot bucket is columnar cells with one state per input.
- A Rollup is every cell of one fact: one Level per width,
the
date_binwidth first, and one Codes per rollup-key column. The bucket is not coded; it keys the level’s map. Rows without a time go underNone(Level::bucket). - A Bucket is Hot, holding Cells, or Sealed, holding
only its cell count. Only
date_binbuckets are sealed (Rollup::sealable). - Cells are columnar: keys, row counts and one State per
input column, side by side (
Cells). A column of counts or sums is as wide as its widest entry needs, and none at all while every entry is zero (Counts,Sums, ADR-0044). An Index finds a cell by its key codes: a table of positions, the keys themselves staying in their column. A cell goes with its last row (Rollup::remove).Aggregate::state_kindpicks which State a measure reads, so measures over one column share it. - A Projection maps key values to the attribute codes of every
row under them.
Nullis code 0 in every attribute, so no row and a null read alike. - Bound cells are a second rollup of a fact whose joins all read
fact columns, one of them a column no key reads: keyed by the keys
and the dim columns’ values, so they are fewer than the fact’s
cells and an ask reads no projection. A fact row goes into both
rollups; into the bound one once per way its joins resolve now
(
Bound::rows). A change to a dim the fact reads moves the ones whose join columns name the changed key (Store::retarget); a dim truncate or load makes them again from the keyed cells (Store::rebuild_dim). They hold the hot buckets only: a seal takes its bucket out of them and an unseal binds it back.
Rollup::add puts a row in its cells, here the row of
Cells:
The key values become codes, the time picks a bucket per level, and the bucket’s index finds the cell, which adds the row.
- Each key value gets the code of its column’s first add of it
(
Codes::code), so a cell key is a few integers. - Each level floors the time to its width (
Level::bucket) and finds the bucket in itsBTreeMap, making a hot one if there is none. - The bucket’s Index hashes the codes to a home slot and walks on
until the cell with those keys, or appends a new cell
(
Cells::entry). - The cell adds the row: one more row, and each State its input.
Steps 2 to 4 repeat for every level.
Applying a commit
Writer::apply_next, one step:
- If the WAL was read a second or more ago: read it on the plain
connection, fail past
--max-retained-wal, feed the Lag, run a seal pass. Source::next_commit, waiting at most until the next WAL reading is due.- Unseal every sealed bucket a fact row of the commit falls in.
- Under the write lock, apply the commit: each change to its rollup or projection, tables the spec doesn’t name skipped, then move Following to the commit’s LSN and time.
- Forget the sealed buckets of a truncated fact in the spill.
A change to a dim a view reads moves its bound cells: each keyed cell whose join columns name the changed key leaves the bound cells it was in and goes into the ones the dim gives it now.
The stream thread reads ahead of the writer, up to 64 commits, and
answers keepalives while the writer is busy (StreamThread).
Answering an ask
The HTTP handler in straum-http (routes.rs) and App::ask:
- Read at most 1 MiB of body; parse the query with the parameters
bound to
$n; refuse a range jiff can’t print. - On a blocking thread, take the read lock.
- Check the ask against the spec and refuse any ask it can’t answer
exactly; name the sealed buckets it reads (
Store::sealed_read). An invalid ask is 400 even while loading. Once a load has read the column types, a filter value of another type than its column is refused too. - Without a Following, 503 not ready.
- Get each sealed bucket from the spill and thaw it against the store; a bucket that can’t be read is 500.
Store::ask_with: read the cells of the buckets in the range, the widest the view keeps that fit and narrower ones at the ends (Rollup::cover). A key the ask names is in the cell key or is a dim’s column: while the view’s bound cells hold, a dim column is a part of their key, else it is read from the dim’s projection through the join id in the cell key, as the dim is now, and a cell whose join keeps no row is passed over. Add each cell to its group, and to each measure whose filter holds; filters name only parts of the cell key, so a cell counts whole or not at all. Keep the groups whose measures meet everyHAVINGcomparison, exactly, a null meeting none. Sort rows by key.- Answer with
as_offrom the Lag and the applied LSN in theStraum-LSNheader,Cache-Control: no-store: JSON, or a table in plain text for an ask sent withAccept: text/plain.
GET /views takes the read lock once and reports what the store holds
(App::held): per view its widths, cells, range of buckets, keys with
the types the load set, and measures; per dim its rows. straum views
and straum ask print what the server wrote, as straum status does.
GET /views/NAME answers an ask the app builds (App::rows_ask):
every measure by the bucket and every key over the buckets the view
holds, through the same steps as any ask, written as a table of at
most 1000 rows. straum shell is a client of these endpoints: it
reads the views as JSON to complete names and knows no Straum SQL.
In the binary, main.rs reads the command line and wires the crates
together. The commands that talk to a running Straum go through
client.rs, one request on a plain socket. complete.rs is what Tab
offers, plain functions over a line and the views, and shell.rs is
the prompt around both, the only code that touches the terminal.
Model
Arrows denote dependency, not communication flow.
The spec: what Straum keeps
Immutable once Spec::new has checked it. Every node is a value
object compared by value.
A spec is dims and facts. A fact is one view: its table, one time, by keys, refs in join order, measures and the view’s WHERE. A ref joins through one via per key column and reads attributes.
An Aggregate is count, count of a column, sum, min, max, avg,
count distinct, bool_and, bool_or or percentile_disc; all but count
name one input column (Aggregate::input).
- A Spec holds dims and facts in one namespace (
Spec::new).Spec::tableslists each table once, dims first. - One Dim per table, whatever the number of views joining it;
Dim::attributesis the union of what they read. - A Fact holds its refs in join order: a ref joined through
another dim comes after it (
Via::Attribute::throughis an index intoFact::refsbefore it). - A Ref points at a dim by name, with as many Via columns as
the dim’s key has (
JoinError::KeyLength). Left and inner refs read at least one Attribute; semi and anti read none (JoinError::ReadsNothing,JoinError::ReadsColumns). - Keys, attributes and measures share one namespace per view, and
nothing may be named
bucket(SpecError::NamedBucket). An ask reaches any of them by that name alone (Fact::resolve). - Width divides a day; each wider width is a whole multiple of
the one before (
SpecError::BadWidth,SpecError::BadBucket).
Derived from a fact, not stored:
| Derived | What | Where |
|---|---|---|
| Rollup key | by parts, each fact-side via once, bucket | Fact::rollup_key |
| Row layout | time, by columns, vias, inputs, each once | Fact::columns |
| Dim row layout | key columns, then attributes | Dim::columns |
| Direct | every via is a fact column, no chain | Fact::is_direct |
The ask and the answer
An Ask names one fact and, by name, its measures, keys and
conditions; its range is two bucket edges with from < to. An
Answer is one column per key and measure and one row per group.
Spec::check_askresolves names, refuses two time keys, a name asked twice, an empty range and a bound off a bucket edge. The store adds the type check once a load has set the types (AskError::WrongType).- Answered wraps an answer with the applied LSN and
as_of. The domain never knows time or LSNs beyond the bucket.
The running system
A writer owns the source port and an app. The app holds the limits, a spilling with the spill port when one is configured, and a state behind a read-write lock: the store, a following while every table is loaded, and the last failure.
- App is an
Arc<RwLock<State>>beside the limits and the spilling, which never change; clones share it. Asks take the read lock, the writer the write lock only to apply a commit it has read or to swap a store in (State::apply,Writer::start). - Following exists only while every table is loaded; a failure
removes it (
Writer::step). Its absence is “not ready”. - Lag keeps the WAL readings the slot hasn’t confirmed, at most
an hour of them, and the instant it last had;
as_ofand readiness derive from it. - A Commit is one transaction, whole, with the end of its commit record as LSN. The stream delivers nothing before the commit, and the store sees it as one.
Invariants
What the code holds true, and where. Planting a bug in any of these makes a property test fail.
- A commit applies whole. The decoder holds a transaction until
its Commit message (
Decoder), and the store takes it under one write lock (State::apply). An answer never sees half of one. - No commit is missed or applied twice. The snapshot is at the
slot’s consistent point L and the stream starts at L
(
Postgres::openand the firstnext_commit);the_snapshot_and_the_stream_split_exactlyraces 2,000 commits against it. - Only the writer writes. Every
Spillmethod takes&self; the store is reached throughApp::readby asks andApp::writeby the writer alone. - A row removed was added.
RowError::Missingfrom a cell, a value count or a projection entry ends the step (Rollup::remove,Projection::remove), and Straum starts over. - Types come from the catalog only. The load sets them
(
Store::set_types); a filter value must fit its column (ColumnType::fits); a Relation message that changes them isUnfit(Decoder). - Null joins nothing. A dim row with a null key is never held and
a lookup with a null finds nothing (
Projection::add,Projection::matches). - A row with no time is kept, never answered (
Level::bucket). - Every width divides a day and nests in the next
(
SpecError::BadWidth); every ask bound is on adate_binedge (AskError::RangeUnaligned); an ask never reads part of a bucket. - Levels agree. Every row goes into one cell per width in the
same add (
Rollup::add);Rollup::coverreads each instant of a range from exactly one level. - A sealed bucket takes no change. A keyed rollup refuses a row
into one (
RowError::Sealed); the writer unseals before it applies (Writer::unseal). The bound cells hold nothing of a sealed bucket: a seal takes it out of them at every width and an unseal binds it again, as the dims are then (Store::seal,Store::unseal). - The spill holds what the store has sealed and no more. Put
before seal (
Writer::seal), removed on unseal and truncate (Writer::forget), cleared at every start (Writer::start). - Bound cells are the hot keyed cells, bound as the dims are
now. They exist only for a fact whose joins all read fact
columns, one of them no key: any other fact’s would be as many as
its keyed cells (
Bound::new). A dim change moves the ones that name its key (Store::retarget) and a dim truncate or load makes them again (Store::rebuild_dim). An ask whose range holds a sealeddate_binbucket reads the keyed cells (Store::reading). - Lag never understates. Readings merge only toward older
(
Lag::read);matches_every_reading_keptchecks it against a reference that keeps every reading. - Two stores never take memory at once. The old store goes
before the new load (
Writer::start). - One name, one thing, per view. Keys, attributes and measures
share a namespace,
bucketis reserved, and an answer’s columns are its names (Spec::new,Spec::check_ask).
State machines
The running states, Loading, Ready, Lagging, Waiting and Stopping, are
drawn under States. There is no enum: Following
is None while loading or waiting, Failure says which, and lag
against --max-lag tells Ready from Lagging (App::status,
why_not in straum-http). Three smaller machines:
The source connection, a date_bin bucket, and a bucket’s bound
cells.
- The source:
PostgresmovesClosedtoLoadingonopen,LoadingtoStreamingon the firstnext_commit, and back toClosedon the nextopen(Postgresinstraum-pg). AloadoutsideLoadingand awalwhileClosedareError::Stopped. - A bucket: only
date_binbuckets seal. A truncate drops every bucket of the fact, sealed ones from the spill too. - The bound cells of a bucket: a dim change moves them and never
drops them
(
a_dim_change_under_a_sealed_bucket_reaches_it_once_unsealed); a dim update that reads the same changes nothing (Store::update_row).
Which decision shaped what
The reasoning and the tradeoffs are in the ADRs; this maps each part of the model to the records that settled it.
| Part of the model | ADRs |
|---|---|
| Cells only, no rows, projections beside them | 0002, 0030 |
Crates inward, Source and Spill as ports | 0003, 0033 |
Spec as a CREATE VIEW subset, one spelling | 0004, 0005, 0028, 0029 |
| Ask as parameterised Straum SQL | 0006 |
Answered: as_of in the body, LSN in a header | 0007, 0020 |
One Store behind one RwLock, App clones | 0009 |
| Own replication client, temporary slot, one port for load and stream | 0010, 0012, 0022 |
| Old row on every update and delete, dims too | 0014, 0031 |
Failure and the start-over loop | 0015 |
Rollup key, cell states, levels per width, None bucket | 0016, 0017, 0019, 0028, 0041 |
| Refs bound at ask time, bound cells beside | 0030, 0038, 0042, 0056 |
Sealed buckets, date_bin width only | 0033, 0034, 0035 |
timestamp as wall clock, enums as labels | 0018, 0037 |
tokio only in straum-http | 0008 |
/status, /views and text answers for people | 0023, 0039 |
straum shell, a view’s rows, views as JSON | 0040 |
The PostgreSQL mechanics behind the load, the apply and the slot, with their citations, are in research/POSTGRESQL.md; what joins replaced and the sources behind them in research/JOINS.md.
Where the code and the records differ
ADR-0034’s adapter is not built: main.rs passes None. The open
names are in the glossary’s Clashes table.