pgColumnar architecture¶
This document is a module-by-module map of the pgColumnar source so a new
developer can navigate the code. It is derived from the source in src/ and
from design/NATIVE_FORMAT_AND_INTERFACE_SPEC.md. Read the specification first
for the on-disk layout and catalog schema; this document describes how the code
is organized against it.
Data is stored in one format, the native format PGCN v1. (An earlier 1.0-dev
format line has been removed; the v1.0-dev git tag preserves it.)
On-disk model in one paragraph¶
A columnar relation stores its data in its own main fork using standard
PostgreSQL pages, so the buffer manager, WAL, and page checksums apply. Block 0
is a metapage, block 1 is reserved, and block 2 onward is a logical byte area.
Rows are grouped into row groups (a run of up to stripe_row_limit rows, the
write unit). Within a row group one column's data is a chunk, holding a validity
bitmap and a value stream encoded in fixed-size vectors. The metadata catalog is a separate set of ordinary heap tables in the
pgcolumnar schema:
storage,row_groupandcolumn_chunkrecord the layout.zone_maprecords the minimum and maximum of each chunk and each vector, which drive skipping.bloomrecords the per-chunk equality filters.delete_vectorrecords the delete marks and the update marks.projectionrecords secondary column projections.free_spacetracks the reclaimable byte ranges that deletes and compaction free.optionsholds the per-table settings. A storage id links a relation's physical file to its catalog rows.
Crash safety¶
The data of a columnar relation lives in its main fork, through the buffer manager and WAL. Its metadata lives in WAL-logged heap catalogs. A columnar table is therefore as crash-safe as any other PostgreSQL relation, and the guarantee rests on core PostgreSQL rather than on this extension.
No write path goes around the buffer manager, and none writes the file directly.
log_newpage_buffer dirties and WAL-logs the data pages. The metapage and the
reserved page are WAL-logged before anything extends them. A truncation is an
XLOG_SMGR_TRUNCATE record.
Two crash shapes follow from this.
- The whole cluster crashes. On restart, WAL replay restores each committed write
and discards each uncommitted one. A committed columnar table then matches a
heap table that a load wrote the same way. A transaction that was in progress
leaves nothing visible.
test/recovery.shasserts both against a heap mirror. - One backend dies while the postmaster lives, which is the shape a fault in a
single session produces. The postmaster reinitializes shared memory, drops
every other session, and runs the same crash recovery. Committed columnar data
survives without damage. An uncommitted write in the dead backend leaves no
visible rows and no partial row group. The reason is the same as for the
cluster case: the state that survives is what WAL records as committed. The
postmaster drops the other connected sessions as well, not only the session
that died.
test/native_backend_crash.shasserts this against a heap mirror. It kills one backend withSIGSEGVat more than one point in the write path, and one time under a concurrent reader.
What does not survive is process-local: an open transaction's uncommitted work, and the connections of every session live at the moment of the crash. Every committed row survives. No columnar-specific shared state is left broken for the next backend, because the data and its catalog are both ordinary WAL-logged storage.
Module map¶
columnar_tableam.c¶
The table access method handler and extension glue. It fills a TableAmRoutine
with the callbacks PostgreSQL calls for a USING pgcolumnar table: create
storage, bulk and single insert, sequential scan open/next/close, delete and
update (through the row mask), fetch a row by item pointer, size estimation, and
truncate. It also holds _PG_init, which registers every pgcolumnar.* GUC (the
compression codec and level, the row-group and vector row limits, and the qual
pushdown, custom scan and vectorized aggregate toggles), the
pre-commit hook that flushes pending writes, and the object-access hook that
removes a table's metadata rows when the table is dropped.
columnar_compat.h¶
Major-version compatibility shims. pgColumnar keeps one source tree that builds on PostgreSQL 15 through 19. PostgreSQL changed several of its own API and callback contracts across those majors (for example the RelFileNode to RelFileLocator rename in 16, the table-AM signature changes in 19, and the planner hook that edits the index list, which is get_relation_info_hook through 18 and build_simple_rel_hook in 19, used to enable index-only scans). Each shim here only selects the correct core API for the running major; none of them change pgColumnar's behavior.
columnar_storage.c¶
The physical storage layer: the metapage, the logical-to-physical byte mapping,
and the append-only reservation model. A logical offset maps to a block number
and in-page offset by BYTES_PER_PAGE = BLCKSZ - SizeOfPageHeaderData. The
metapage holds three high-water marks (next stripe id, next row number, next
logical byte offset). The stripe id and row number are advanced under an
exclusive lock on the metapage buffer, taken when a writer begins buffering a
stripe, so every row gets its stable row number and item pointer at insert time;
no extension lock is needed there because no data pages are extended. The byte
offset is advanced separately at flush, once the stripe's size is known, under
the relation extension lock the caller already holds to extend the data pages.
Either way concurrent writers serialize their reservations and then write their
data independently. This module reads and writes the raw logical buffers through the
buffer manager.
columnar_metadata.c¶
Access to the pgcolumnar catalog tables and the storage-id sequence: the
storage, row_group, column_chunk, zone_map, bloom, delete_vector,
projection, free_space, and options tables. Metadata are ordinary heap tables keyed by storage id, read and
written with direct catalog access (heap opens, index scans, and inserts) rather
than SPI, so metadata access does not depend on SPI reentrancy from inside a scan
or write. Catalog reads use a snapshot whose command id is advanced so a
transaction sees its own just-written metadata (read-your-writes) while staying
isolated from other transactions. The storage-row insert is serialized by a
storage-id advisory lock so concurrent first-writers do not race.
columnar_write_state.c¶
The writer. It batches incoming rows into per-column buffers, closes a vector
when it reaches chunk_group_row_limit, and flushes a row group (its data pages
plus its catalog rows) when the group fills or at transaction pre-commit. The row
group number and the row-number range are reserved up front when a row group
starts buffering, so every row has its stable row number and synthetic item
pointer at insert time (needed for indexes and unique checks). Pending writes are
held per relation and per subtransaction for the life of the transaction,
promoted to the parent on subtransaction commit and discarded on rollback. At
flush it encodes each column chunk with the per-vector encoding cascade, computes
the zone maps and per-chunk bloom filters, and writes the storage, row_group,
column_chunk, zone_map, and bloom rows. The projection fan-out shares this
writer.
columnar_compression.c¶
The value-stream codecs: none, pglz (built into PostgreSQL), lz4, and
zstd with a compression level. Each chunk's value stream is compressed
independently. If a codec does not shrink the data, or was not compiled into
this binary (lz4 and zstd are linked only when the system libraries are found),
the raw bytes are stored as none instead, and a request for an absent codec
falls back to one that is present. The exists (null bitmap) stream is never
compressed.
columnar_encoding.c¶
Lightweight, type-aware value-stream encodings, applied per chunk before the
block codec and reversed after decompression, as reversible transforms of the
raw value-stream bytes (so all downstream decode is unchanged). Encodings: RLE,
frame-of-reference with bit-packing, delta and delta-of-delta (integer family),
Gorilla XOR (float4/float8), and a dictionary (any fixed-width or varlena type,
for low cardinality). ColumnarEncodeChunk measures each applicable encoding and
keeps the smallest; ColumnarDecodeChunk reverses it. This file also holds the
compression-block run iterator (ColumnarBlockReader) that exposes a chunk as
(value, run-length) pairs so an aggregate can fold a run at a time.
columnar_bloom.c¶
Per-chunk bloom filters for equality chunk-group skipping. The writer hashes each non-null value (hashable, non-collatable columns only) and builds a filter per chunk; the reader probes it for an equality predicate the min/max range could not rule out, skipping the group when the value is provably absent. Never a false negative, so results are unaffected.
columnar_reader.c¶
The reader and the shared value-stream decode path. It walks a relation's row
groups, decodes each projected column's per-vector encoded chunk into a per-group
context, applies the row group's row mask so deleted rows are skipped, and
reconstructs rows. It has two entry shapes: the scalar sequential scan
(ColumnarReadNextRow, one tuple at a time) and a fetch-by-row-number path
(ColumnarReadRowByNumber, used by index and bitmap scans and by UPDATE to
refill unchanged columns and by unique enforcement). Both honor column
projection, zone-map row-group and per-vector skipping, the per-chunk bloom
filter for equality predicates, and the row mask, and both yield a column's
missing value (from getmissingattr) for a row group written before an
ADD COLUMN. Under a parallel scan each worker claims distinct row groups from
the shared counter. The liveness cache (ColumnarBuildLivenessCache) reads the
row-group list and row masks once so a projection scan can test each row's base
row number cheaply.
columnar_delete_vector.c¶
Delete and update marking. A delete, and the old side of an update, sets a bit
in the pgcolumnar.delete_vector entry for the row's row group rather than
rewriting the group; an update inserts the new row with a fresh row number. Marks
accumulate in a per-subtransaction in-memory buffer and are applied to the
catalog in one upsert per row group at flush time. The reader loads a row group's
delete vector and skips marked rows.
columnar_customscan.c¶
Planner and executor integration. A set_rel_pathlist_hook replaces a columnar
base relation's sequential-scan path with a custom scan path (and removes the
parallel paths, so plans are stable). The custom scan computes the referenced
column set from the target list and restriction clauses and pushes it into the
reader (projection pushdown), and translates simple column op const clauses
into scan keys so the reader's zone maps remove row groups and vectors that
cannot match (qual pushdown). The executor always re-applies the full
restriction clauses as a filter, so skipping never changes results. The scan is
the scalar per-row path (ColumnarScanNext). Through a create_upper_paths_hook
the module adds the vectorized aggregate path for a supported
SELECT agg(col) FROM t [WHERE ...]. EXPLAIN reporting (projected columns, and
under ANALYZE the row groups and vectors read versus skipped) lives here.
columnar_vector.c¶
The vectorized aggregate path and its shared filter. A column-at-a-time filter
(ColumnarVecSelect) turns a plan's simple strict column op const clauses into
a selection vector over a decoded group; for integer, float, and date/time
columns it uses a typed, branch-free comparison loop (the btree strategy resolved
from the type's opfamily), falling back to the operator function otherwise (a
null value or a failed comparison excludes the row, matching SQL WHERE
semantics). The vectorized aggregate computes count, sum, avg, min, and
max, reproducing PostgreSQL's result types exactly (integer sum as int8,
integer average as numeric, min/max by the column type's default ordering). It is
answered from the zone-map metadata, falling back to a scan-and-fold when the
group has deletes. It is chosen only when every aggregate, column type, and
clause is supported; anything else falls back to the scalar plan.
columnar_vacuum.c¶
Compaction, statistics, and storage-id lookup. pgcolumnar.vacuum materializes a
relation's live rows (the reader skips row-mask-deleted rows), swaps the
relation to a fresh relfilenode, removes the old metadata, writes the live rows
back into full stripes, and rebuilds the indexes so their item pointers address
the renumbered rows. This combines small stripes and physically reclaims
deleted-row space. pgcolumnar.vacuum_sorted runs the same rewrite but feeds the
live rows through a tuplesort keyed on the chosen columns first, so the table is
stored physically sorted (a one-time reorder; see gap 26). pgcolumnar.stats
reports the per-stripe layout, and a storage-id lookup resolves a relation to its
storage id.
columnar_unique.c¶
Concurrent unique-key insert serialization (issue #5). Before a freshly inserted
row is handed back to the executor's index maintenance, the table-AM insert
paths call ColumnarLockUniqueKeys, which takes a transaction-scoped advisory
lock (the same SET_LOCKTAG_ADVISORY primitive as the columnar_delete_vector
chunk-group lock) keyed by the row's unique key value(s). Because a columnar
row's data is invisible to other backends until its stripe flushes at statement
end, the btree dirty-snapshot uniqueness check can miss a conflict against a row
still buffered in another backend; holding a per-key lock to commit forces a
second inserter of an equal key to wait until the first commits and flushes, so
the ordinary btree check then catches the duplicate. Equal keys map to one lock
by hashing each key column with its type's default hash function (consistent
with the index equality: numeric scale, citext case, collation-aware text),
combining columns, and mapping into a bounded number of buckets per index
(pgcolumnar.unique_lock_buckets) to bound the lock budget. Unique, immediate,
valid indexes are handled, including multi-column, partial (predicate evaluated
per row), and expression indexes; an index whose operator class is not provably
the type default, or whose key type has no hash proc, falls back to one coarse
per-index lock. A relcache-invalidated backend-local cache holds the per-relation
unique-index metadata so the per-row cost is a hash plus a lock acquire.
columnar_arrow.c¶
Arrow IPC stream export (pgcolumnar.export_arrow, gap 27). A self-contained
FlatBuffers builder emits the Schema and RecordBatch messages (MetadataVersion
V5); rows are read in physical order via the scalar reader and buffered one
RecordBatch at a time (validity bitmap, then values, with utf8/binary offsets).
No libarrow dependency. Supported types are int2/int4/int8, float4/float8, bool,
text/varchar, and bytea; other types are rejected. Little-endian hosts only.
columnar_parquet.c¶
Parquet export (pgcolumnar.export_parquet, gap 27). A self-contained Thrift
compact-protocol writer emits the file metadata (row groups, column chunks, page
headers) and PLAIN-encoded, UNCOMPRESSED data pages with RLE/bit-packed
levels. Columns shred into leaf column chunks with repetition and definition
levels (Dremel): scalars (one leaf), 1-D arrays (a 3-level LIST), and composites
(a group with one leaf per field). One row group per 65536 rows. No libparquet
dependency; same scalar type coverage as the Arrow writer.
columnar_parquet_reader.c¶
Parquet import and the external-Parquet read surface (gap 27 and Phase G). A self-contained reader: a Thrift compact-protocol decoder for the footer and page headers, clean-room Snappy plus GZIP (zlib), ZSTD, and LZ4_RAW page decompression, the RLE/bit-packed hybrid decoder for repetition/definition levels and dictionary indices, PLAIN and dictionary value decoding, and both DATA_PAGE v1 and v2 (what pyarrow writes). The schema tree is walked to derive each leaf column's Dremel level bounds; nested columns are reconstructed by decoding each leaf's full entry sequence (defs/reps/dense values) and grouping repeated runs (LIST to array, group to composite), the inverse of the nested Parquet exporter. Scalars remain byte-for-byte the flat path. No libparquet dependency.
One shared row-producing core (pq_read_rows, decode one row group into slots)
feeds three surfaces over the same file parse and type inference:
import_parquetinserts into a target table viatable_tuple_insert.read_parquetreturns rows as a set-returning function, andparquet_schemareports the leaf columns and their inferred PostgreSQL types.- The
pgcolumnar_parquetforeign-data wrapper materializes the file into a tuplestore that the scan drains. It pushes predicates down: it skips a row group when the minimum and maximum statistics prove that the group is empty. This applies to fixed-width ordered types only, and it guards against NaN and against an inverted interval. It also projects: it decodes only the columns that the plan refers to. It computes that set from the reltarget and the quals of the base rel.
A path that is a directory or a glob resolves to a sorted list of files. The
same core reads each file into the one sink. The decode buffers of a file are
freed before the next file.
A file is reached through a PqSource handle rather than an image of its bytes.
pq_source_open validates the two magics and bounds the footer length that the
file declares. It then reads and parses the footer only. It holds the footer for
the scan, because the chunk statistics that drive row-group skipping point into
it.
The decoder reads each page when it reaches it. First it reads a header window,
which grows if the parse is short. A v2 page header carries column statistics, so
the file controls its size. Then it reads the page body. The size of the body
comes from the compressed_size of that page, and not from the
total_compressed_size that the writer supplied for the chunk. Values are copied out of the
page, so each page is freed after use and peak raw memory is one page instead of
one file. Three consequences follow. File size is no longer a limit, where the earlier
whole-file palloc limited a readable file to MaxAllocSize. A row group that
predicate pushdown excludes costs no I/O. parquet_schema reads no page bytes at
all.
The decode paths guard against crafted files. They range-check these items:
- the sizes that the file declares
- the DECIMAL scale
- the chunk count of each row group
- the page offsets
- the compressed size of each page
- a page that claims no values A
malformed footer therefore gives an explicit error. It does not give a read
outside the buffer, an incorrect value, or work without a bound.
Each of those guards has a crafted-file test verified to fail when that specific
guard is removed;
test/mutate_guard.pyis the harness that does the removing.
columnar_visibilitymap.c¶
Index-only-scan support (gap 28). A columnar visibility-map fork records which
synthetic blocks (chunk groups) are all-visible. Lazy VACUUM
(columnar_relation_vacuum, ShareUpdateExclusiveLock) sets a block's bit only
when its stripe's inserting xid precedes the oldest removable-xid horizon and the
group has no deletes; every insert/delete/update clears the bit. Both set and
clear are WAL-logged (log_newpage_buffer). The core index-only-scan executor
reads these bits through visibilitymap_get_status, so an all-visible group is
answered from the index tuple and any other block falls back to the
snapshot-checked fetch. Gated by pgcolumnar.enable_index_only_scan (default on).
columnar_projection.c¶
Multiple projections DDL and reader (gap 26). add_projection /
drop_projection manage the pgcolumnar.projection catalog; add_projection
back-fills the projection from existing rows under ShareLock. The catalog CRUD
lives in columnar_metadata.c; write fan-out and the sorted per-stripe encoder
live in columnar_write_state.c (each projection stores the base row number as a
leading column); the planner selection and executor projection scan live in
columnar_customscan.c (a covering projection with a restricted sort key is
scanned instead of the base, pruning chunk groups by the projection's min/max,
with deletes/visibility taken from the base). pgcolumnar.vacuum rebuilds
projections aligned to the compacted base.
Data flow summaries¶
Insert:
- The executor calls the table-AM insert callback or multi-insert callback.
columnar_uniquetakes the per-key advisory lock for each unique index that applies (issue #5).columnar_write_stateputs the row into the per-column buffers. It gives the row its reserved row number and its item pointer.- When a vector is full, the writer closes it. When a row group is full,
columnar_encodingandcolumnar_compressionencode each column,columnar_storagewrites the pages, andcolumnar_metadatarecords the result. - The remaining buffers flush at the end of the statement and before the commit.
Scan:
- The planner installs the custom scan (
columnar_customscan) with a column projection and with scan keys. - The reader (
columnar_reader) goes through the row groups. It uses the zone maps incolumnar_metadatato skip groups and vectors. - The reader decodes the projected chunks through
columnar_encodingandcolumnar_compression. - The reader applies the delete vector (
columnar_delete_vector) and returns the rows one at a time. - The executor applies the full qual again, as a filter.
columnar_vector answers an ungrouped aggregate from the zone-map metadata.
Delete or update:
- The custom scan supplies the item pointer of each row.
columnar_delete_vectormarks the old row.- An update also inserts the new row through the writer.