pgEdge ColdFront - Architecture
ColdFront makes a single PostgreSQL table transparently span two storage tiers:
recent rows stay in the PostgreSQL heap, older rows live in Apache Iceberg on
object storage. Applications see one table (in fact a view); a C extension
rewrites reads and writes to hit the right tier, and all Iceberg I/O goes
through pg_duckdb running in-process inside PostgreSQL - no external query
engine, no Go Iceberg libraries.
For mode-specific design, see architecture_tiered.md (hot PG + cold Iceberg) · architecture_decoupled.md (all-Iceberg) · architecture_vectors.md (vector storage).
Operating Modes and Topologies
Three independent axes describe any ColdFront deployment; they compose freely - e.g. tiered + mesh + permissive writes. The following table shows how each axis is selected:
| Axis | Values | Selected by |
|---|---|---|
| Storage mode | In tiered mode, a UNION ALL view unifies the hot PG heap and cold Iceberg, and an archiver moves rows from hot to cold on a cron. In decoupled mode, the table lives entirely in Iceberg, and PG holds only a wrapper view and a registry row (no archiver, no PG storage, no watermark). |
The is_iceberg_only flag on coldfront.tiered_views selects the mode per relation at creation, and the hook's classify_tier() short-circuits on that flag. |
| Topology | In a vanilla topology (a single node, with spock/snowflake not loaded), cold writes serialize on a local advisory lock, and a node that runs Spock without the bakery's two settings refuses them. In a mesh (3-node pgEdge Spock active-active), cold writes serialize cluster-wide via the bakery protocol. |
The topology follows whether spock/snowflake are in shared_preload_libraries. One image and one SQL surface serve both. The serializer follows coldfront._bakery_armed(): the bakery when both snowflake.node and coldfront.loopback_dsn are set, and a local advisory lock otherwise; the image sets both only when MESH=on. |
| Write mode | In permissive mode (the default), an ambiguous cross-tier UPDATE/DELETE writes both tiers. In strict mode, such a statement is rejected with a hint. |
coldfront.allow_mixed_writes (USERSET) selects the write mode. |
Both storage modes coexist in one database and share one code path: the
transparent view and read rewriter, the INSERT/UPDATE/DELETE/MERGE hook
(emit_cold / emit_hot / emit_dual in
extension/coldfront/src/coldfront.c),
and the per-table claim (coldfront._take_iceberg_claim) that every cold write
path in the hook takes, mostly through _exec_iceberg_with_claim. Decoupled
mode always classifies as TIER_COLD and never reaches emit_hot; vanilla and
mesh differ only in how that claim serializes cold writes. This document covers
the shared mechanics and the tiered path; see
architecture_decoupled.md for the decoupled mode's
ACID model and distributed scaling story.
Read Target: Primary or Physical Standby
Orthogonal to the three axes above, any ColdFront node - vanilla or a mesh
member - can have one or more physical (streaming) standbys that serve
read-only cross-tier reads. The hot tier arrives by physical replication; the
cold tier is read by DuckDB executing on the read-only backend. A base
backup contains the coldfront catalog (tiered_views, archive_watermark,
storage_secret) and the GUCs (in postgresql.conf, not ALTER SYSTEM), so a
replica is byte-identical to its primary (same OIDs). The DuckDB persistent
secret file sits under the OS user's home directory, outside PGDATA, so with
static credentials run SELECT coldfront.materialize_storage_secret() once on
the replica. Cold writes are refused on a standby: every cold write path in
the hook calls coldfront._reject_on_standby, which raises when
pg_is_in_recovery(), before it takes the claim, so a read replica can never
become an uncoordinated writer to the shared Iceberg table (hot writes hit a PG
heap and PG rejects them natively).
Standby reads are gated by
ci/probe-standby.sh
(the risk-first check that iceberg_scan runs on a read-only backend at all)
and exercised in the journey by story_standby_reads (the ·standby matrix
cells). Failover/promotion is delegated to Patroni and is out of scope for the
test matrix - see
ci/runbooks/failover-patroni.md.
System Overview
A ColdFront database is PostgreSQL with two extensions preloaded (pg_duckdb +
coldfront), backed by an Iceberg REST catalog and an object store, as shown
below:
┌──────────────────────────────────────────────────────────┐
│ PostgreSQL + pg_duckdb + coldfront │
│ • one transparent view per managed table │
│ • coldfront hooks: post_parse_analyze (DML rewrite), │
│ ProcessUtility (DDL); the C hook lazily ATTACHes the │
│ Iceberg catalog on the first query touching a view │
│ • coldfront.tiered_views registry + bakery claims │
│ • pg_duckdb runs DuckDB in-process: │
│ DuckDB reads cold data, duckdb.raw_query() │
│ writes it │
└──────────────┬───────────────────────────────────────────┘
│
┌──────────────▼───────────────────────────────────────────┐
│ Lakekeeper - Iceberg REST catalog (own dedicated Postgres)│
│ Manages Iceberg metadata, snapshots, commit concurrency │
└──────────────┬───────────────────────────────────────────┘
│
┌──────────────▼───────────────────────────────────────────┐
│ S3-compatible object store (SeaweedFS, MinIO, GCS, …) │
│ Parquet data files + Iceberg metadata files │
└────────────────────────────────────────────────────────────┘
The following table describes each component, its role, and its license:
| Component | Role | License |
|---|---|---|
| PostgreSQL 16+ | Provides heap storage and range partitioning for the tiered hot tier. ColdFront works uniformly on PG 16, 17, and 18, because the cold-tier secret is a DuckDB persistent secret loaded at instance init, with no version-gated mechanism. | PostgreSQL |
| pg_duckdb | Runs DuckDB in-process for Iceberg reads, writes, and analytics; the build is pg_duckdb at commit c04e6a2 (PR #1025) on DuckDB 1.5.4. The bundled duckdb-iceberg includes the bakery-aware commit-refresh patch (async parquet overlap, no 409); see Cold-Write Strategy. |
MIT |
| coldfront | This PGXS C extension's post_parse_analyze_hook rewrites INSERT/UPDATE/DELETE/MERGE on registered views to the correct tier and, on a SELECT DuckDB will run, the spellings DuckDB lacks (date_bin, ::jsonb, the JSON builders); planner_hook folds bound parameters into such a read; ProcessUtility_hook handles DDL; the hook lazily ATTACHes the Iceberg catalog on the first query touching a tiered view. |
PostgreSQL |
| Lakekeeper | Provides the Iceberg REST catalog as a single Rust binary. | Apache 2.0 |
| S3-compatible store or Azure ADLS Gen2 | Stores the cold data; any S3-compatible store works, including SeaweedFS, MinIO, AWS S3, and GCS, and Azure ADLS Gen2 works through set_storage_secret_azure. |
Varies |
| Archiver (tiered mode) | Moves rows from hot to cold; this Go binary is a thin SQL orchestrator that cron invokes. | PostgreSQL |
How rows move through this depends on the storage mode: the tiered hot heap +
archiver + UNION ALL data-flow is in the
Data Flow section of the Tiered Mode page;
the all-Iceberg flow is in
architecture_decoupled.md.
Core Mechanics: pg_duckdb
All Iceberg I/O goes through SQL executed against PostgreSQL. There are no Go DuckDB/Iceberg/Arrow libraries. DuckDB Iceberg writes require a REST catalog - Lakekeeper fills this role.
Session Setup
The cold-tier S3 secret is set once per cluster. A single call records the credentials and materializes a DuckDB persistent secret:
SELECT coldfront.set_storage_secret('<key>', '<secret>', '<endpoint>');
-- Azure ADLS Gen2 instead of an S3-compatible store:
SELECT coldfront.set_storage_secret_azure('<connection string>');
set_storage_secret does two things. (1) The function stores the secret in the
coldfront.storage_secret table - an extension-member table, so its data is
excluded from pg_dump by default, and coldfront.ensure_replicated() adds
it to the Spock replication set, so the secret replicates by value to every
mesh node with no per-node file syncing. (2) The function materializes a
DuckDB persistent secret, which DuckDB loads automatically at instance
init - so every backend, including the first fresh one, sees the secret at a
committed timestamp before any query runs. set_storage_secret_azure takes
one connection string holding the account name, account key and endpoint
suffix, writes the same row, and materializes a TYPE azure persistent
secret.
For deployments that must not store a credential at all,
coldfront.set_storage_secret_vended() records a vended
coldfront.storage_secret row that holds no credential and materializes no
secret. The row's vended flag drives coldfront._attach_delegation_mode(),
so ensure_attached() attaches the catalog with
ACCESS_DELEGATION_MODE VENDED_CREDENTIALS: Lakekeeper issues short-lived
per-table credentials (S3 STS, or Azure SAS) that duckdb-iceberg consumes
directly. A static row attaches with ACCESS_DELEGATION_MODE NONE (the
persistent secret supplies the credential); the vended path sidesteps the
fresh-transaction limitation below because duckdb-iceberg re-creates the
per-table secret inside the commit transaction.
For backup and restore (pg_dump), the durable tiering metadata -
coldfront.tiered_views (registry), archive_watermark (cutoffs) and
partition_config - is marked with pg_extension_config_dump, so a logical
pg_dump includes it and a restore re-attaches to the same Iceberg cold
tier with no re-provisioning. Two things are deliberately not dumped: the
credential (coldfront.storage_secret above - re-run set_storage_secret (or
set_storage_secret_azure or set_storage_secret_vended) once after restoring
into a fresh instance) and the bakery's transient claim tables (claims /
claim_acks / deferred_acks, per-node mesh state). Until the credential is
re-established a restored node serves hot reads but fails cold I/O cleanly.
ci/ops.sh Check 4 exercises exactly this.
The Iceberg catalog ATTACH is lazy: the coldfront C extension hook issues
ATTACH IF NOT EXISTS against Lakekeeper - using the cluster's
coldfront.warehouse and coldfront.lakekeeper_endpoint GUCs - on the first
query that touches a tiered view (read or write), per DuckDB cached
connection. There is no setup step and no per-session boilerplate: both reads
(the view's duckdb.query) and writes (duckdb.raw_query) work on a fresh
psql session. The hook attempts no ATTACH until a query touches a tiered
view, so a missing warehouse never blocks a connection opened before bootstrap.
Non-Superuser App Roles (Least Privilege)
pg_duckdb force-disables DuckDB's LocalFileSystem for any role that lacks
both pg_read_server_files and pg_write_server_files (see
Upstream requests),
which would block the side-loaded iceberg/postgres DuckDB extensions from
loading on ATTACH. So coldfront.ensure_attached() / ensure_pg_attached()
are SECURITY DEFINER with a pinned search_path: the extension load +
ATTACH run elevated (gates key off GetUserId(), the effective user), and
because the DuckDB instance is per-backend the attach persists for the
session - every subsequent cold read / _exec_iceberg_with_claim then runs
as the app role over S3/httpfs, never touching LocalFileSystem. The app
role needs only duckdb.postgres_role membership, object grants, and SET on
the superuser-only duckdb.unsafe_allow_execution_inside_functions
parameter, which the cross-tier move needs; no superuser, no
pg_{read,write}_server_files.
Because the attach helpers run elevated, the deployment-config GUCs they
consume (coldfront.warehouse, coldfront.lakekeeper_endpoint,
coldfront.local_pg_dsn) are registered PGC_SUSET (the last also
GUC_SUPERUSER_ONLY) in _PG_init, so a non-superuser cannot redirect the
elevated ATTACH at an attacker endpoint. coldfront.loopback_dsn, the DSN of
the bakery's loopback, is PGC_SUSET as well, because the loopback runs claim
statements as the user that DSN names. It stays readable by every role, since
the invoker-rights coldfront._bakery_armed() reads it on every cold write.
All four GUCs default to an empty string. Onboarding is one operator call,
coldfront.grant_app_access(role) - idempotent, registry-derived (schemas,
views, the hot heap + its identity sequence, the cold-path function EXECUTE
allow-list), not PUBLIC-executable. The image defaults
duckdb.postgres_role = coldfront_duckdb (env COLDFRONT_DUCKDB_ROLE) and
creates the role, so the path needs no further setup.
In a Spock mesh the role and its grants replicate via Spock DDL - onboard
once on any node. Mesh cold writes route through the bakery protocol; its
coordination function _claim_iceberg_lock is itself SECURITY DEFINER
(search_path-pinned, fully schema-qualified) so a non-superuser drives the
cross-node serialization (pg_stat_replication liveness + the loopback claim)
with the privilege it requires. _exec_iceberg_with_claim deliberately stays
SECURITY INVOKER - it runs the caller's cold DML, which must execute as the
caller. This SECURITY DEFINER setting is protocol-neutral: it changes the
PG execution privilege, not the claim/ack/lock/ticket protocol, re-verified
against the TLA+ model (all safe configs pass; the race
config still violates NoLakekeeperConflict).
See README "Security"; the setting is asserted by the journey's
story_app_privilege, ci/ops.sh check 3, and the privilege_model
pg_regress test.
Temp Table Bridge: PG → Iceberg
duckdb.raw_query() cannot see PG tables directly. The bridge is a DuckDB temp
table:
CREATE TEMP TABLE duck_stage USING duckdb AS
SELECT * FROM public.p_2026_01;
SELECT duckdb.raw_query($$INSERT INTO ice.public.events
SELECT * FROM pg_temp.duck_stage$$);
DROP TABLE duck_stage;
Cold-Side Column References
iceberg_scan() requires r['col']::type syntax:
SELECT r['id']::bigint, r['ts']::timestamptz, r['status']::text
FROM iceberg_scan('ice.public.events') r
WHERE r['ts'] < '2026-03-01'::timestamptz;
Application Interface
Applications use the transparent view exactly like a table. A
post_parse_analyze_hook in the coldfront extension intercepts
INSERT/UPDATE/DELETE/MERGE whose target is a registered relation -
resolved in coldfront.tiered_views by name (schema_name, relname) - and
rewrites the parsed Query so it lands in the correct tier; cold-side writes
go through _exec_iceberg_with_claim (see
Concurrency). A
ProcessUtility_hook handles DDL on the same relations.
The same parse-analyze hook prepares a SELECT that DuckDB will run, wherever
in the statement the view is named (a CTE, a sub-select, a set-operation
branch): date_bin, the ::jsonb cast, jsonb_array_length and the JSON
builders (jsonb_build_object, jsonb_agg and their json_ twins) are
rewritten into spellings both engines accept (see the
Supported Column Types section of the Using
ColdFront guide). A
planner_hook folds bound parameters into such a read before pg_duckdb plans
it when a parameter sits where DuckDB cannot type a placeholder (a direct
argument of a pg_duckdb function, any argument of a table function); the plan
cache's generic-plan build, which has no values, gets a PostgreSQL plan priced
above any custom plan, so under the plan cache's cost-based selection the read
is planned from its values on every execution.
plan_cache_mode = force_generic_plan bypasses that selection and picks the
value-less generic plan, which fails with only works with DuckDB execution
(see the Supported Column Types section of
the Using ColdFront guide).
The following table maps each operation to its interface and routing path:
| Operation | Interface | Routed via |
|---|---|---|
| SELECT | SELECT FROM events |
pg_duckdb (UNION ALL in tiered, wrapper view in decoupled); a single-table tiered read whose WHERE proves it hot is rerouted to the hot heap in plain PostgreSQL |
| INSERT | INSERT INTO events ... |
coldfront post_parse_analyze_hook |
| UPDATE / DELETE | ... events WHERE ... |
coldfront post_parse_analyze_hook |
| DDL (ALTER / RENAME) | ALTER TABLE _events ... |
coldfront ProcessUtility_hook (see Transparent DDL) |
| DROP / TRUNCATE | DROP TABLE _events |
The hook blocks the statement (see Transparent DDL). |
With duckdb.force_execution = true, hot-side queries are also accelerated by
DuckDB's vectorized columnar engine.
How the hook splits a write is mode-specific:
- In tiered mode, the hook routes by the partition-column watermark - hot heap
vs cold Iceberg, with dual-tier writes for ambiguous predicates. See
Transparent
INSERTand TransparentUPDATE/DELETEin the Tiered Mode page. - In decoupled mode, the hook always classifies
TIER_COLD, so every write is a single-tier Iceberg write. See architecture_decoupled.md.
Cold-Tier DML from Inside PL/pgSQL (Functions, DO Blocks, Triggers)
Cold-tier INSERT/UPDATE/DELETE work as top-level statements and from
inside a plpgsql function / DO block / trigger, via two mechanisms:
-
Parameters are emitted as a runtime
format(<template>, $1, $2, …)call (cold_sql_arg), with the param types declared on the re-parse (parse_analyze_fixedparams). PG binds the values at execution, so DuckDB only ever sees finished literals. This applies everywhere - a driver's parameterized coldUPDATEat the top level is the same case. -
A cold call parsed inside plpgsql is wrapped as a DML over a permanent single-row carrier,
coldfront._dummy_dml_target. plpgsql rejects a bare result-returningSELECTwith noINTO/PERFORM("query has no destination for result data") and the cold target is a DuckDB-attached object PG cannot tag a real DML against, so the call is reshaped as a no-row DML:UPDATE coldfront._dummy_dml_target SET anchor = anchor WHERE coldfront._exec_iceberg_with_claim(...) IS NULLThe cold call runs exactly once in the WHERE qual; because
_exec_iceberg_with_claimreturnsvoidandvoid IS NULLis always false, zero rows match - the carrier is never written: no dead rows, no WAL, no bloat. For dual-tier and tiered-INSERT the hot DML is the outer statement and this same UPDATE runs inside a data-modifyingWITH-CTE. At the top level the rewrite keeps the plainSELECT coldfront._exec_iceberg_with_claim(...)shape.
"Inside plpgsql" is detected via pstate->p_post_columnref_hook != NULL
(plpgsql installs that hook to resolve identifiers as variables; a top-level
statement, including a parameterized one, never does) - a precise, stateless,
side-effect-free signal that touches no PL/pgSQL plugin slot.
Concurrency and pgEdge Spock Deployments
ColdFront coordinates concurrent writes across the cluster as follows:
- The archiver runs on one node only (via cron). Its cold writes route through the same bakery claim as any other cold write, so catalog conflicts are prevented up front rather than retried; the only retry is the cutover's lock acquisition (10 attempts, exponential backoff from 100 ms to 51.2 s).
- Hot writes are replicated by Spock normally (standard PG DML).
- Cold writes via
duckdb.raw_query()from multiple nodes are serialized PG-side by the bakery protocol in the coldfront extension - every iceberg-onlyINSERT/UPDATE/DELETE/MERGEwraps incoldfront._exec_iceberg_with_claim, which holds a globally-ordered Snowflake ticket via the Spock-replicatedcoldfront.claimstable and waits for its turn before issuing the iceberg commit. There are no 409s and no app-level retry. See the Concurrency section of the Decoupled Mode page for the full design and benchmarks.
Cold-Write Strategy: Stock vs Patched duckdb-iceberg
Most cold writes run through _exec_iceberg_with_claim; the tiered INSERT's
cold sink and the cross-tier move take the claim through _take_iceberg_claim
themselves. What differs is when the bakery ticket is held. The async
ordering is used only on a mesh node with the bakery enabled, and only when
both coldfront.iceberg_async_parquet (default off) and the build marker
coldfront.iceberg_bakery_patch (asserting the loaded duckdb-iceberg includes
the patch) are on (coldfront._iceberg_async_active()); a vanilla node always
takes its lock before uploading. The following table shows the two orderings:
| Ordering | duckdb-iceberg | Behavior |
|---|---|---|
| stock (default) | stock upstream | The writer takes the bakery ticket first, then uploads parquet and commits inside the ticket. This ordering is correct on an unpatched binary, but the whole parquet upload happens under the lock, so concurrent writers serialize on upload and commit. |
async (both GUCs on) |
patched (iceberg-bakery-aware-commit-refresh-v15.patch) |
The writer uploads parquet in the background first, then takes the ticket only for the Lakekeeper commit. Concurrent writers' uploads overlap; only the short commit POST is serialized. |
The code path and the application-visible behavior are identical, so the GUCs
are purely a performance knob. The patch relocates parent-snapshot stamping
from upload time into PG's pre-commit phase (inside the bakery ticket, against
a freshly-fetched table), so overlapping uploads cannot commit a stale parent.
Async requested without the build marker downgrades safely to the stock
ordering, noted once per session with a server LOG line - never a silent 409.
The Docker image ships the patched binary and sets both GUCs on
(docker/entrypoint.sh); bare-metal users on a stock binary leave both off
and lose only the upload overlap. See
docker/Dockerfile.duckdb15-base
for the build.
Transparent DDL via coldfront
A ProcessUtility_hook in the coldfront extension intercepts DDL that targets
a registered tiered table's hot heap or a decoupled table's wrapper view. A
hot heap is matched by resolving the DDL target relation to an OID and
comparing it with the OID of the registry's hot_table - never by string, so
it is schema-agnostic - and a view by its schema and name, the registry's key.
A view the hook rebuilds keeps the owner and the table-level grants the view
had. The hook rebuilds the view and updates the registry as the extension's
owner, once the ownership check PostgreSQL makes for the statement has passed.
The role running the statement needs what PostgreSQL requires for the
statement itself, USAGE on the schema of the relation it names included, but
neither ownership of the view nor CREATE on its schema, and the hook's own
registry lookups and updates need no privilege on the registry. The Iceberg
change, which is cold I/O, runs as that role, so a column change also needs the
cold access grant_app_access gives, SELECT on the registry included. The
following table summarizes the DDL it handles:
| DDL | Behavior |
|---|---|
ALTER TABLE _t ADD/DROP COLUMN, ALTER COLUMN ... TYPE, RENAME COLUMN |
The DDL is mirrored to Iceberg: the hook drops the view (except for RENAME COLUMN), runs the hot-side change, then coldfront._mirror_iceberg_alter issues the matching Iceberg ALTER (one bakery-serialized, claim-first catalog change) and rebuilds the view, so both tiers evolve in one statement. Renaming a column of the view itself is rejected. Column types map through coldfront._iceberg_storage_type, so an unsupported type (e.g. inet) is rejected up front; ALTER COLUMN TYPE is limited to the safe promotions duckdb-iceberg accepts (int to bigint, float to double, date to timestamp, and decimal widening). |
ALTER TABLE v ADD/DROP COLUMN, ALTER COLUMN ... TYPE, RENAME COLUMN on a decoupled table |
PostgreSQL runs none of the statement: it cannot add, drop or retype a view's column, and a rename has to reach the Iceberg table too. After the owner check, coldfront._mirror_iceberg_alter issues the Iceberg ALTER with the declared type, then coldfront._rebuild_iceberg_view rebuilds the view from its own columns plus the change. The type map and the promotions are the tiered row's. A vector column, a table adopted read-only, a default, constraint, collation, storage or compression option on an added column, a USING or COLLATE clause on a type change, and a subcommand other than a column change in the same statement are refused, and an object built on the view makes the change fail. |
ALTER TABLE _t RENAME TO ... |
The rename is supported because it touches no Iceberg schema: the hook updates tiered_views.hot_table and rebuilds the view. |
ALTER VIEW v RENAME TO ... |
The rename is supported: the hook migrates the name-keyed registry and archive_watermark rows to the new view name, then rebuilds the view (otherwise the lookups miss and the cold UNION branch silently disappears). |
DROP TABLE _t / DROP VIEW v |
The statement is blocked by design, because it would orphan the Iceberg cold tier. Dismantling tiering is a deliberate operator action, never a side effect of a habitual statement: call coldfront.drop_iceberg_table(schema, table, purge), which unregisters the table, drops the view, renames the hot table back, and drops the cold tier, deleting its objects only if purge says so. |
TRUNCATE _t |
The statement is blocked by design, because cold-tier rows would remain visible through the view. To empty the table, truncate the hot partitions individually and delete cold rows through the view. |
The hook's view rebuild does DROP VIEW + CREATE VIEW (not
CREATE OR REPLACE VIEW, which PG only allows for appending columns at the
end); the archiver's cutover, which only moves the cutoff, instead uses
CREATE OR REPLACE VIEW and keeps the view OID. The registry is keyed by the
view's (schema_name, relname), which either rebuild leaves unchanged, so
there is nothing to re-point. A column change is mirrored to Iceberg through
ensure_attached() + the bakery, so it requires a configured
coldfront.warehouse; a RENAME TABLE/VIEW touches no Iceberg schema and
rebuilds the view regardless. Concurrent schema changes are serialized by the
same bakery as cold DML.
In active-active deployments, Spock replicates only the top-level ALTER TABLE
or ALTER VIEW. The image sets spock.allow_ddl_from_functions = on, which
would also replicate the hook's SPI-issued view and trigger DDL, so the
hook issues that DDL with spock.enable_ddl_replication off. The registry
tables replicate for the archiver's writes, so the hook makes a rename's
registry update in Spock's repair mode (spock.repair_mode), and each
peer's hook makes the same update itself. A peer's apply worker re-runs
the replicated statement; the hook rebuilds that peer's own local view,
but coldfront._mirror_iceberg_alter skips the Iceberg ALTER there (it
runs under session_replication_role = replica) because the originator
already evolved the shared Lakekeeper catalog. Because the registry is
name-keyed (see Registry Keying), the
row is identical on every node: the rebuild needs no re-pointing. DROP and
TRUNCATE are blocked on every node. What a tiered table additionally needs
to be usable on a peer is covered next.
The tiered-specific cross-node behavior - what replicates so a tiered table is usable on every peer, and why both the registry and the watermark join the replication set - is in the Tiered Tables in a Spock Mesh section of the Tiered Mode page.
Registry Keying: By Name, Not OID
coldfront.tiered_views is keyed by (schema_name, relname) - the transparent
view's qualified name. The C hook reads the whole registry, joined to the
watermark, once per command, and matches each candidate relation's schema and
name in memory; the hook already holds the target relid, from which the
schema and name are an inexpensive syscache lookup.
The name is the right key because it is stable across the churn the system
actually produces. The DDL-rebuild path does DROP+CREATE on the view,
minting a new view OID each time, and the archiver's cutover replaces it with
CREATE OR REPLACE VIEW (same OID) - in both cases the name is unchanged. An
OID key would have to be re-pointed on every DDL rebuild; a name key is not.
The one event that does change the name, ALTER VIEW … RENAME, migrates
the registry row and the watermark to the new name in a single step
(_rename_tiered_view), exactly as the watermark is name-keyed.
The name also replicates cleanly across a Spock mesh: it is node-independent, so the registry row is identical on every node and the replication set copies it by value (an OID is node-local and could not be). That is what makes cross-node tiered tables work with no per-node re-resolution - see the Tiered Tables in a Spock Mesh section of the Tiered Mode page.
Lower-level operations that genuinely need an OID - catalog lookups, the DDL
hook matching the hot heap - resolve name→OID via to_regclass /
get_rel_name at the point of use: names everywhere, OIDs only where required.
Per-Table Config: coldfront.partition_config
Which tables are managed and their lifecycle (hot_period, retention_period,
partition_period, premake, mode, expiration_strategy) live in
coldfront.partition_config - like tiered_views and archive_watermark, a
name-keyed table (see Registry Keying).
coldfront.partition_config is added to the default replication set on a
Spock node by the binaries themselves and by coldfront.ensure_replicated()
(a no-op on vanilla, where there is one node), so every node reads identical
config with no per-node file syncing. hot_period and
retention_period are native PostgreSQL interval columns - the column type
validates each value on write, and expiry cutoffs are computed in-DB with
calendar-accurate interval arithmetic (now() - period: real months, leap
years), never an approximate fixed-day duration in Go. CHECK constraints
encode the structural lifecycle rules (a destroy boundary is required; id
mode forbids a hot tier - the cold tier is time-only; 2-level needs an explicit
RANGE column; expiration_strategy is drop|detach, and detach - expire
by detaching only, not dropping - is allowed partition-only), so an invalid row
is rejected at write time. The one rule that is operator-config policy rather
than a storage invariant - retention_period must exceed hot_period - is
validated at the register/set CLI boundary and at binary startup
(partition.ValidatePeriods, a calendar-aware interval comparison),
deliberately not a CHECK. The standalone partitioner self-materializes the
table on stock PostgreSQL via EnsureTable, needing no extension. Connection
config (DSN, Iceberg/S3 credentials) is deliberately not stored here - it
is per-node and must never be replicated.
The config replicates by value; the partition lifecycle DDL must also
reach every node. CREATE … PARTITION OF … and DROP TABLE are ordinary
transactional DDL, so Spock's DDL replication carries them automatically. The
retention DETACH PARTITION … CONCURRENTLY is the exception: CONCURRENTLY
cannot run in a transaction block, so Spock skips it
(WARNING: This DDL statement will not be replicated) - left alone, a
partition would stay attached on every peer while the origin detaches it. The
partition manager therefore detaches locally (top-level, non-blocking) and then
fans the identical concurrent detach to each peer itself: it enumerates the
Spock nodes and re-runs the detach on each peer's own connection (skipping any
node where the partition is already detached). The fan-out is gated on Spock
being present, so on vanilla single-node PostgreSQL it is skipped entirely and
the manager needs no extension. The archiver's cold-tiering cutover instead
uses a plain, transactional DETACH inside its atomic watermark+view+detach
commit, so that one replicates on its own. This is verified before any mesh
partitioner run by story_mesh_partition_ddl in ci/journey.sh (an N×(N-1)
probe: create from every node, detach, drop, asserting each lands on all
nodes).
Both binaries read this table, each loading only the enabled rows of its own
mode (the archiver those with a hot_period, the partitioner those without),
and exit with an error when they find none; tables enter it via register, or
from a YAML archiver.tables list fed to import. Both expose a management
CLI - register / list / set / remove / import / export - documented
in usage.md.
Known Limitations
These apply to both storage modes. Tiered-only limitations (cold RETURNING,
dual-tier command tag, crash-safety of permissive writes, partition-scheme
constraints, autovacuum-vs-cutover) are in the
Tiered-Specific Limitations
section of the Tiered Mode page.
The cross-cutting limitations are:
-
The storage secret needs a one-time setup after warehouse bootstrap: after Lakekeeper is provisioned, an operator calls
SELECT coldfront.set_storage_secret(...)once per cluster; the catalog ATTACH itself is lazy, so there is no per-session setup (see Session Setup). -
jsonbreads asjsonthrough the view, unless the read is rerouted to the hot heap: DuckDB has no nativejsonb, and pg_duckdb takes over any query that references a DuckDB read (all-or-nothing plan takeover), so the cold branch cannot produce a PGjsonbdirectly. The view generator castsjsonbcolumns to DuckDB-safejsonon both sides: hot emits"col"::json, cold emitsr['col']::json, the UNION unifies onjson. Standard JSON access (->>,->,#>) works without any caller-side cast; jsonb-only operators (?,@>, containment, de-dup) need an explicitdata::jsonbin the caller's query. Storage in Iceberg remains VARCHAR (Iceberg has no JSON type either). The cold sink sends the incoming value as its text form. -
Lakekeeper remote signing may not work with all S3-compatible stores; the workaround is
ACCESS_DELEGATION_MODE NONEwith a direct DuckDB S3 secret. -
Interception happens at the planner level, with no per-query decision engine:
pg_duckdbdecides whether to take over a query by inspecting the parse tree for signals (a pg_duckdb table function, theduckdb.force_executionGUC, DuckDB-only functions). Once pg_duckdb takes over, the whole statement runs in DuckDB; there is no cost-based hot-vs-cold split per predicate. The one exception is a single-table read whose WHERE provests >= cutoff: the hook rewrites it to read the hot heap in plain PostgreSQL, which returns nativejsonb. Other hot-only queries can target_eventsdirectly (native PG, nopg_duckdbroundtrip); queries that need cross-tier semantics go through the view. -
Query execution is single-node: a query runs on the PG backend it landed on.
pg_duckdbdoes not distribute the DuckDB plan across nodes. Replication (single- or multi-master via pgEdge Spock) is supported on the hot tier and transparent to the application; scaling read throughput requires more replicas rather than parallelizing one query. Those replicas, including read-only physical standbys, serve cross-tier reads (see Read Target). -
ColdFront supports Iceberg only, not Delta Lake. Adding Delta would require either a second writer path in
pg_duckdb's Iceberg extension or a different analytical engine.
Infrastructure (Docker)
Three services run: PG+pg_duckdb+coldfront, Lakekeeper, and any S3-compatible
store. Both extensions must be in shared_preload_libraries - coldfront
installs its hook in _PG_init, which runs when the postmaster loads the
library.
A two-layer image serves every deployment: a prebuilt base
(docker/Dockerfile.duckdb15-base) including pg_duckdb (PR #1025) on DuckDB
1.5.4 + the patched duckdb-iceberg on a pgEdge *-spock5-minimal base (Spock +
Snowflake), and a thin app layer (docker/Dockerfile.duckdb15,
--build-arg PG_MAJOR=16|17|18) that compiles coldfront on top. The same image
plays both topology roles - vanilla leaves spock/snowflake out of
shared_preload_libraries; mesh loads them (MESH=on, set by the entrypoint).
The following compose file shows the three services - the database, Lakekeeper, and the S3-compatible store:
services:
db:
build:
context: .
dockerfile: docker/Dockerfile.duckdb15
args: { PG_MAJOR: 18 }
environment: { PG_MAJOR: 18, MESH: "off" } # entrypoint configures preload + GUCs
lakekeeper:
image: quay.io/lakekeeper/catalog:latest
command: serve
seaweedfs:
image: chrislusf/seaweedfs:latest
command: "server -s3 -dir=/data -s3.config=/etc/seaweedfs/s3.json"
Lakekeeper needs the following: bootstrap (POST /management/v1/bootstrap)
then warehouse creation (POST /management/v1/warehouse) with S3 credentials
and sts-enabled: false, remote-signing-enabled: false.
Lakekeeper keeps its own catalog tables in a PostgreSQL database (and its
schema migration needs the uuid-ossp contrib extension). Lakekeeper runs on
its own dedicated Postgres - a separate lakekeeper-db container in the
test stack, and a separate managed instance in production - never co-located
on a ColdFront data node. Co-locating would couple the catalog's availability
and load to a data node and would drag contrib into the ColdFront image for no
reason; the dedicated store keeps the ColdFront image lean (core uuid type
only, no uuid-ossp).
Upstream Requests
The behaviors in upstream projects that ColdFront works around are kept here as architectural notes: what is missing, the workaround in use today, and the shape of the upstream capability that would let ColdFront drop the workaround.
pg_duckdb: Non-Superuser LocalFileSystem Blocks Side-Loaded Extensions
pg_duckdb force-disables DuckDB's LocalFileSystem for any role that lacks
both pg_read_server_files and pg_write_server_files, which also blocks
loading a locally-installed DuckDB extension - and ColdFront side-loads a
patched iceberg (and postgres) extension from disk, which DuckDB lazily
loads on ATTACH, so a non-superuser's first cold query fails at the elevated
load step.
The workaround in use today is the SECURITY DEFINER attach helpers described
under Non-Superuser App Roles.
The upstream shape that would drop it is either a way to mark specific
locally-installed extensions as loadable without LocalFileSystem access, or
distinguishing extension-load file access from user-initiated raw file access
in the non-superuser sandbox - so a non-superuser member of
duckdb.postgres_role could ATTACH (TYPE ICEBERG, …) directly, without
ColdFront's SECURITY DEFINER shim.
pg_duckdb: Native PG-Reader → Iceberg Streaming (No libpq Round-Trip)
pg_duckdb has a fully-native, in-process Postgres-table reader for analytics on PG heap data, but that machinery is not reachable from the write path into an attached Iceberg catalog.
As the workarounds in use today, a tiered INSERT renders its cold rows in the
backend (coldfront._cold_sink) and writes them to Iceberg in batched
duckdb.raw_query INSERTs, and a decoupled INSERT … SELECT from a
PostgreSQL table reads it through the DuckDB postgres extension's
pglocal.<schema>.<table> ATTACH, pipelining rows over libpq (loopback) →
DuckDB executor → Iceberg writer → S3. Neither is the in-process reader:
the first pays for the per-row rendering, the second for the libpq round-trip
per row batch.
The desired end-state is a way to drive the native in-process reader straight
into the Iceberg writer - e.g. a COPY form:
COPY (SELECT * FROM public.events_partition) TO ICEBERG 'ice.public.events';
That would make pg_duckdb the only place in the data path that touches the rows: PG executor (heap reader) → pg_duckdb vector format → Iceberg writer, one pass, in-process, with no libpq loopback and no temp-disk.
pg_duckdb: A Statement DuckDB Cannot Run Is Refused, Not Delegated
pg_duckdb's planner hook takes over any statement whose parse tree references a
DuckDB item, and then requires that statement to be one DuckDB can execute:
IsAllowedStatement runs with throw_error set, so a statement that needs
DuckDB but modifies a PostgreSQL relation raises "DuckDB does not support
modifying Postgres tables" rather than being handed back to the PostgreSQL
planner. Two statement shapes reach that error through a view whose body reads
a cold table:
UPDATE/DELETEon a view withINSTEAD OFtriggers. PostgreSQL expands the view to scan its rows and fire the trigger, the expanded body contains the DuckDB item, and the statement errors before any trigger runs.INSERT ... SELECTwhose source needs DuckDB, even where the target's ownINSTEAD OF INSERTtrigger would have taken the row.
A plain INSERT ... VALUES through an INSTEAD OF INSERT trigger is
unaffected, because that plan never expands the view, so no DuckDB item reaches
the hook.
As the workaround in use today, ColdFront's post_parse_analyze_hook rewrites
INSERT/UPDATE/DELETE/MERGE on a registered view into its hot, cold or
dual emit before planning, so on the paths ColdFront owns pg_duckdb only ever
sees a shape it accepts. What has no workaround is a view an application
defines over cold data with its own INSTEAD OF UPDATE/DELETE triggers, or
an INSERT ... SELECT drawing from such a view.
In the upstream shape that would drop it, where NeedsDuckdbExecution is true
and IsAllowedStatement is false, pg_duckdb chains to the previous planner
hook instead of throwing, so PostgreSQL plans the statement and its
INSTEAD OF triggers fire. Every statement DuckDB can run stays on the DuckDB
path, and the check that decides this already exists in a non-throwing form.
DuckDB: Spill Files Are Not Namespaced per Instance
DuckDB names a spill file from a counter that starts at zero in every instance
(duckdb_temp_storage_<class>-<index>.tmp, duckdb_temp_block-<id>.block),
and an instance being torn down deletes every duckdb_temp_* file in its temp
directory. pg_duckdb gives each backend its own DuckDB instance but one shared
duckdb.temporary_directory (default $PGDATA/pg_duckdb/temp), so concurrent
backends that spill open the same paths, read each other's bytes, and delete
each other's files. On the 1.5.4 stack, of four sessions spilling a 3M-group
aggregate under a 150 MB memory limit, three died with
IO Error: Could not read enough bytes. The defect is tracked as duckdb#15173
(open, reproduced); pg_duckdb#887 is an unmerged fix one layer up.
As the workaround in use today, each backend takes a subdirectory of the
configured path named after its own PID, assigned by cf_own_duckdb_temp_dir
from the parse-analyze hook on the session's first statement, which is ahead of
pg_duckdb building its instance and reading the setting. The same hook reclaims
the subdirectories whose PID no live backend holds, removing their spill files
and the directory and reporting what that freed. That sweep runs once per
session and costs single-digit milliseconds per spill file, so even a directory
abandoned with 600 GB in it (about 600 files at DuckDB's file size) is a couple
of seconds on the one statement that finds it. See the
Tuning Knobs section of the Using ColdFront guide.
The PID is what makes that reclaim possible, and is why ColdFront does not
apply pg_duckdb#887, which names the directory with a random uuid and removes
it from an on_proc_exit hook. That hook does not run when a backend is killed
by SIGKILL, an immediate shutdown or the OOM killer, which is precisely when
spill files are left behind, and a random name cannot be attributed to an owner
afterwards: nothing can decide whether the directory belongs to a live session
or a dead one, so the disk is never reclaimed. A PID can be checked against the
live backends (BackendPidGetProc), so any later session reclaims the orphans,
whole gigabytes at a time. A reused PID resolves to a live backend and is left
alone, as is any directory holding something other than spill files.
The upstream shape that would drop it is a spill filename that includes the instance's own identity, so instances sharing a temp directory cannot collide and teardown deletes only what it wrote. That belongs in DuckDB, where the naming is, rather than in each embedder.
duckdb-iceberg: Secret Visibility Under Fresh Transactions
A secret created with CREATE SECRET from a caller's still-active transaction
is not visible to the fresh transaction duckdb-iceberg opens for its
commit-time I/O, so a fresh PG backend's first cold-tier write would fail with
HTTP 403 against any non-AWS S3-compatible endpoint.
The workaround in use today is the DuckDB persistent secret materialized by
coldfront.set_storage_secret(...) (see Session Setup) -
loaded at instance init, it already sits at a committed timestamp before any
backend's first cold-tier write looks it up.
In the desired end-state, either IcebergTransaction::Commit runs its
commit-time I/O under the caller's ClientContext (rather than a
freshly-opened Connection), or any extension that synthesises CREATE SECRET
from external state commits that transaction explicitly, so the secret sits at
a committed timestamp before a consumer's fresh transaction looks it up. Either
would let a per-session synthesized secret work without relying on the
persistent-secret mechanism.
duckdb-iceberg: Append to the Manifest List Instead of Rebuilding It
At commit, duckdb-iceberg (v1.5) rebuilds the new snapshot's manifest list from
every entry of the existing one (CreateFromEntries plus
IcebergAddSnapshot), so the commit reads the whole existing manifest list.
Under ColdFront's serialized cold-write protocol that read happens while the
writer holds the bakery ticket, and its cost grows with the manifest-list
length, so it inflates the serialized critical section (compaction only partly
offsets it).
As the workaround in use today, the iceberg-bakery-aware-commit-refresh patch
re-reads the manifest list from the freshly-loaded catalog head inside the
ticket (RefreshExistingManifestList). The re-read is correct - it folds in
any peer manifests committed since this writer staged its parquet - but it pays
the full re-scan of the manifest list under the lock.
The upstream shape that would drop it is a manifest-list commit that appends the new manifest to the existing manifest-list file, referenced by path from the current catalog head, instead of rebuilding from all scanned entries. That keeps peer inclusion (the fresh head already points at peers' manifests) while removing the full re-scan from the commit, so the serialized section no longer grows with manifest-list length. That change is correctness-sensitive (manifest-list integrity, commit conflicts, silent data loss) and its throughput value is unmeasured and workload dependent, so it belongs upstream with fleet benchmarking rather than as a patch ColdFront maintains.
Next Steps
To go further with ColdFront, consult the following documents:
- The Tiered Mode deep dive describes the archive pipeline, transparent DML, and tiered tables in a Spock mesh.
- The Decoupled Mode deep dive describes iceberg-only tables, their ACID model, and the bakery protocol.
- The Vector Storage deep dive describes how embeddings are stored, assigned to clusters, and searched.
- The Using ColdFront guide covers day-to-day use of both modes.