Skip to content

Write-ahead log ​

An append is acknowledged when its record is in the WAL. The WAL is the durable part of the write path. It lives in the object store or in a Postgres database. Caches, committed objects, and compaction are the same for both.

Contract ​

A WAL backend implements four operations.

OperationWhenContract
AppendEvery writeStore a framed batch, acknowledge in submission order
ReadRecoveryReturn records from an offset forward
TrimAfter a commit to data objectsRelease everything below the committed offset
ResetAfter recovery is uploadedStart a new session from an empty log

Records use the same frame in both backends: a header with the offset, length, and a CRC32 over the body, then the batch of records. The container around the frame differs.

Object storePostgres
Unit of writeOne object per bulkOne row per batch
Location{prefix}{epoch}/wal/{start}-{end}Slot table pico_wal_{cluster}_{node}_{slot}
FenceEpoch in the keyEpoch on the node row, checked per insert
TrimDelete covered objectsTruncate the slot table
Append latencyTens of ms on S3, single digits on S3 ExpressOne commit
framed batchheader, crc, recordsobject storePUT{prefix}{epoch}/wal/{start}-{end}epoch in the key, prefix deleted on trimpostgresINSERTslot table, node row FOR SHAREepoch checked per row, slot truncated on trim

Object store ​

The WAL is a sequence of small objects written by one node under its own key prefix.

  • Incoming records accumulate into a bulk. Each bulk is one PUT.
  • Uploads are pipelined. Acknowledgements are delivered in submission order, once a bulk and every bulk before it are stored.
  • One upload contains every record that arrived while the previous upload was in flight, so per-record cost drops as concurrency rises.
  • The object key carries the node's epoch. A node that lost its registration cannot extend its WAL past a takeover, see Streams.
  • Trim deletes the covered objects.

Postgres ​

The WAL is a set of tables in a Postgres database. --wal with a postgres:// URL selects it. The Postgres extension uses it by default. An append costs one Postgres commit.

Tables ​

TableRowsColumns
pico_wal_nodeOne per node, keyed by cluster and node idepoch, trim_offset, segments, segment_bytes, timeline
pico_wal_{cluster}_{node}_{slot}One per batchstart_offset, end_offset, epoch, body

The cluster part of a slot table name is a CRC32 of the cluster id. body is stored EXTERNAL, so a row read is one TOAST fetch with no decompression.

Ring ​

  • Offsets are divided into segments of segmentBytes. The segment number of an offset is its generation.
  • A generation maps to slot generation % segments. A slot holds one generation at a time.
  • When the trim offset passes the end of a generation its slot is truncated.
  • If the head reaches a slot whose generation has not been trimmed, appends wait. The ring bounds how far the WAL can run ahead of the upload to data objects.
slot 0gen 16slot 1gen 17slot 2gen 18slot 3gen 19slot 4gen 20slot 5gen 21slot 6emptyslot 7emptytrimheadtruncated nextgen 22 goes here
ParameterDefaultConstraint
segments8At least 2
segmentBytes64 MiBAt least maxBytesInBatch, at most 4 GiB
Capacity, (segments - 1) * segmentBytes448 MiBMust exceed maxUnflushedBytes

A change to segments or segmentBytes is applied at the first start where the WAL is fully drained.

Fencing ​

  • Every batch insert runs as INSERT ... SELECT ... FROM pico_wal_node WHERE epoch = $epoch FOR SHARE.
  • If a newer process has claimed the node row with a higher epoch, the select returns no row, the insert writes nothing, and the writer reports itself fenced.
  • Claiming the node row is an upsert that succeeds only when the new epoch is at least the stored one.
  • Trim is guarded by the same epoch predicate.

Commit path ​

ParameterDefaultEffect
batchInterval1 msHow long records accumulate before a batch is inserted
maxBytesInBatch1 MiBSize that closes a batch early
maxInflight4Batches inserted concurrently, each in its own transaction
maxUnflushedBytes128 MiBBytes acknowledged but not yet uploaded to data objects before appends wait
synchronousCommitonsynchronous_commit on the WAL connections: on, remote_write, remote_apply, or local

Acknowledgements are released in order as commits return. A failed insert is retried against the same table. If a row already exists at that offset it is compared with the batch: a match is success, a mismatch is corruption.

Recovery ​

When a stream is opened after a crash the engine reads the WAL past the last committed offset and replays it. Acknowledged records are recovered because acknowledgement required the write to complete. Records in flight but never acknowledged may be absent.

Object storePostgres
ReadList under the previous session's prefixScan from the trim offset forward across slot tables, stop at the first gap
IntegrityBody CRC per recordBody CRC per record, row epoch must match the epoch in its offset

The Postgres backend also compares the database timeline with the one recorded at the last start.

TimelineMeaningOutcome
EqualNormal restartRecovery proceeds
LowerDatabase restored from a backupRecords after the backup point are gone. Logged as a warning
Higher, synchronous_standby_names setStandby promotedRecovery proceeds. Logged
Higher, synchronous_standby_names emptyStandby promotedRecords acknowledged after the standby's last replayed commit are lost. Logged as an error

Durability ​

EventObject store WALPostgres WAL, no synchronous standbyPostgres WAL, synchronous standby
Node crash or restartNothing lostNothing lostNothing lost
Postgres crash, same diskNot applicableNothing lost with fsync = onNothing lost
Primary host lostNot applicableRecords after the last replicated commit lostNothing lost after promotion
Zone lostDepends on bucket classLost unless the standby is elsewhereNothing lost if the standby is elsewhere

Checks the Postgres WAL runs at start:

ConditionResult
pg_is_in_recovery() is trueRefuses to start
fsync = offRefuses to start unless the URI carries allowUnsafe=true, then warns
synchronous_standby_names emptyWarns