Change streams
A stream is a standing filter over a table's writes. Every committed change that matches its predicate is captured, kept for a retention window you can replay, and published live to subscribers.
CREATE STREAM [IF NOT EXISTS] big_orders ON orders
WHEN (total > 1000 AND status != 'draft')
WITH (start = 'now', retention = '24h');
DROP STREAM [IF EXISTS] big_orders;
SHOW STREAMS; -- name, table, predicate, consumers, lag
WHEN is required and is an ordinary boolean expression, evaluated exactly
like a WHERE clause. A stream without one would be the table itself.
Options
| Option | Values | Meaning |
|---|---|---|
start |
'now' (default), 'earliest' |
'now' captures future writes only. 'earliest' first backfills every row that already matches, so a consumer can build a derived copy from scratch. |
pre_image |
false (default), true |
Also carry the row as it was before the change, in an old column (NULL for an insert). Opt-in: it makes each event two documents instead of one, and charging that to a stream that did not ask would be a silent regression. CREATE TRIGGER sets it on the stream it owns. |
retention |
a duration — '24h', '90m', 30d |
How long events stay visible and replayable. Omit to keep forever. See What retention actually bounds below — the space is freed by a background sweep, shortly after but not at the instant events expire. |
An option that cannot be honoured is refused, not ignored: a
start other than the two above fails the statement rather than silently
capturing the wrong thing.
The log
Events land in _stream_<name>, an ordinary table in the same database:
| Column | Meaning |
|---|---|
id |
Position. Sorts in event order; this is your resume cursor. |
op |
put (matches now), exit (matched before, no longer does), or delete |
k |
The row's primary-key value |
ts |
When the change was written |
doc |
The row. For a delete, the row as it last was. |
old |
The row before the change — only with WITH (pre_image = true), and NULL for an insert. |
Because it is a table, everything else follows for free: it replicates like
your data, retention is its TTL, and reading it is just SQL.
The log is read-only: INSERT, UPDATE and DELETE against it are
refused, because a hand-written row would be indistinguishable from a
captured event and consumers would act on it. DROP TABLE on it is refused
too — DROP STREAM is what removes a stream and its log together. For the
same reason, CREATE STREAM refuses to start if a table of the log's name
already exists, rather than adopting it.
Reading events
Replay (any client, no extra dependencies)
SELECT id, op, k, ts, doc FROM _stream_big_orders ORDER BY id LIMIT 500;
-- then, from the last id you saw:
SELECT id, op, k, ts, doc FROM _stream_big_orders
ORDER BY id LIMIT 500 AFTER ('<last id>');
Keep the last id you processed and resume from it. That is the whole
consumer protocol, and it is what makes retention a replayable window
rather than a queue: any consumer can rewind to any position still inside
it. The read streams server-side, so replaying a full retention window
does not materialise on the node.
Every driver wraps this loop — see HOWDOI.md.
Live push (MQTT)
With the broker enabled ([mqtt] enabled), each event is also published as
JSON to $stream/<db>/<name>:
mosquitto_sub -t '$stream/default/big_orders'
{"id":"00001786…-0000000003-…","op":"put","k":2,"ts":1786…,"doc":{…}}
The $ prefix keeps streams out of ordinary # subscriptions, as with
$SYS. Subscribing is governed by the usual topic ACLs
(GRANT SUBSCRIBE ON TOPIC …).
What is guaranteed
- At-least-once. You may see an event twice;
ididentifies it, so dedupe on that if it matters. - Per-key order. Changes to one row arrive in order. There is no global order across different keys — nothing in a leaderless cluster can offer one honestly.
- Every change to a matching row is reported. A row that enters the
predicate, changes while inside it, drops out of it, or is deleted all
produce an event (
put,put,exit,delete). A derived copy built from the stream therefore stays in step with the source. - The log is authoritative. The MQTT tail is a convenience: it is
best-effort (QoS 0), so a subscriber that was disconnected misses the live
message. Every event carries its
id, so the fix is always to replay the log from the last id you processed. The tail's position survives a restart, so a node that was down still delivers what it missed — unless it fell more than 10,000 events behind, in which case it skips to the end and logs that, rather than flooding every subscriber with history. - A write that lost last-writer-wins never appears. Only changes that actually became visible in the table are captured.
- Repair does not replay history. A stream ignores changes older than its creation, so anti-entropy or a catching-up replica cannot inject ancient events into a live stream.
- Every event in the log was captured. Nothing else can write there, so an event you read is a change that actually happened to the source table.
- A stream never fails your write. If an event cannot be appended, the
write to the source table still succeeds — it was already committed — and
the lost event is counted by
skaidb_stream_events_lost_total. A non-zero value means that stream's log is behind its table, so a replay of that window has a hole in it; the source table is unaffected.
Costs, and when not to use one
A stream costs a write. Every matching change writes a second row, and
that row replicates like any other. A predicate matching most writes
roughly doubles the table's write volume. Filter narrowly: WHEN is the
knob that decides the cost.
Retention costs disk for the same reason — a 24-hour window on a busy table is 24 hours of extra rows.
What retention actually bounds
retention is the log table's TTL, and a TTL is first of all a visibility
rule: past it, an event is invisible to every reader, immediately and on
every replica. The bytes are a separate question.
Expired rows are physically removed by compaction, and a background sweep
runs compaction for expiring tables that would not otherwise reach it — a
low-volume stream flushes rarely, so without the sweep its log would sit in
memory long after its events stopped being visible. The sweep is on by
default and runs every storage.ttl_reclaim_interval_secs (900 by default;
0 disables it), so in practice space comes back within about that window of
an event expiring, not at the instant it does.
Two consequences worth planning around:
- Retention is not a hard disk ceiling. Size a stream for its retention window plus roughly one sweep interval of events.
- A TTL table is not digest-eligible, so anti-entropy reconciles a stream
log by stamp-scanning it rather than through the O(1) digest gate. A large
log therefore costs something on every repair pass — another reason to
keep
WHENnarrow andretentionno longer than you will actually replay.
Cluster behaviour
Every replica captures events locally, and all of them derive the same
event id from the change, so the log converges to one row per event
instead of one per replica — no coordination, no extra replication traffic.
A replica catching up through repair produces those same ids, so it
converges rather than duplicating.
Limits
start = 'earliest'runs inline: theCREATE STREAMreturns when the backfill is done, so it takes as long as one pass over the table.SHOW STREAMS'consumersandlagreflect the node you ask, and are refreshed about once a second.