Skip to content

Concepts: WAL, slots, LSNs, and the wire ​

You can use walcast without reading this page. But when something looks wrong at 3am, this is the mental model that makes the logs legible.

The WAL ​

Postgres writes every change to the write-ahead log before applying it. Logical replication is a second reader of that log: a walsender process decodes WAL records through an output plugin (pgoutput for walcast) and streams them to a client. Walcast is that client — a hand-written one, speaking the protocol directly over a node-postgres connection opened with replication: 'database'.

Two server-side objects make this work:

  • A publication — a named set of tables whose changes are decoded. walcast setup creates one FOR ALL TABLES (or for the tables you list).
  • A replication slot — the server-side cursor. The slot remembers how far its consumer has confirmed (confirmed_flush_lsn) and forces the server to retain all WAL after restart_lsn. This is both the durability guarantee and the disk-growth hazard: a slot nobody consumes retains WAL forever.

LSNs ​

A Log Sequence Number is a byte position in the WAL — an unsigned 64-bit integer. Textually Postgres shows it as two hex halves: 16/B374D848 (high 32 bits, slash, low 32 bits).

Walcast uses bigint internally — JS number silently loses precision past 2^53, and exact ordering is the whole point — and the familiar X/Y text form at every public boundary (events, config, HTTP), because that is what pg_replication_slots shows and what you paste into support threads.

ts
parseLsn('16/B374D848') // (0x16n << 32n) | 0xB374D848n
formatLsn(0n) // '0/0'

pgoutput framing ​

Inside each replication data frame is one pgoutput message, identified by a single-byte tag:

TagMessageContents
BBegincommit LSN, commit time, xid — opens a transaction
CCommitcommit LSN, end LSN, commit time — closes it
OOriginreplication origin name (bookkeeping)
RRelationa table's shape: schema, name, replica identity, columns with type OIDs
YTypea custom type's name (bookkeeping)
IInsertrelation OID + new tuple
UUpdaterelation OID + optional old tuple (K key / O full row) + new tuple
DDeleterelation OID + old tuple (K or O)
TTruncaterelation OIDs + cascade / restart-identity flags

Relation messages arrive before the first change touching a table (and again when its shape changes); the decoder caches them by OID so tuples can be materialized into named-column objects. Change messages for a relation never seen on this connection are a protocol error, and walcast throws.

TupleData is a column count followed by, per column, one of:

  • n — NULL
  • u — unchanged TOAST column (the value wasn't in the WAL record; walcast surfaces the UNCHANGED_TOAST sentinel string — see the FAQ)
  • t — length-prefixed text bytes (walcast runs pgoutput in text format, not binary)

Text values are converted conservatively: bool, int2, int4, oid, float4, float8, json, jsonb become JS values; int8 and numeric stay strings (both can exceed Number's safe range); everything else (timestamps, uuids, arrays, enums, ...) is the Postgres text form.

The streaming replication protocol ​

After START_REPLICATION SLOT "walcast" LOGICAL 0/0 (proto_version '1', publication_names '"walcast"') the connection enters copy-both mode. Starting at 0/0 means "resume from the slot's confirmed_flush_lsn" — the restart-safe default. Three payloads flow inside CopyData frames:

w — XLogData (server → client): walStart (position of this payload), walEnd (current end of WAL on the server), send time, then the pgoutput message bytes.

k — Primary keepalive (server → client): walEnd, send time, and a reply-requested flag. When the flag is set, the server demands an immediate standby status update — fail to answer long enough and the server terminates the connection.

r — Standby status update (client → server): three LSNs — written (highest received), flushed (durably processed; the server may recycle WAL up to here), and applied — plus a timestamp and an optional reply-request. Walcast sends flushed = applied = its acked frontier, on a timer (default every 10s), immediately when a keepalive requests a reply, and once more on shutdown.

The flushed position is the load-bearing field: it must reflect acknowledged work and nothing more optimistic, because the server is free to discard WAL below it. This is why ack() exists.

Idle databases and keepalive positions ​

Keepalives advance walEnd even when your published tables are quiet (other tables, checkpoints, and vacuum still write WAL). Confirming a keepalive's position too eagerly can lose data — frames for an already-committed transaction may still sit in walcast's queue. Walcast advances on keepalives only when it is fully drained: no unacked events, no buffered events, not mid-transaction. Without this, a walcast watching a quiet table on a busy database would retain WAL forever.

Backpressure, not loss ​

Frames are decoded eagerly the moment they arrive (so a Commit record advances the slot even while your consumer is between pulls); decoded events buffer in a bounded queue (default high-water mark 10,000). Past the mark, walcast pauses the replication socket. Backpressure flows to Postgres: WAL accumulates on the server — visible as slot lag, monitor it — and nothing is ever dropped.

Next: Delivery guarantees — what all of this machinery does and does not promise.

Released under the MIT License.