ColdFront walkthrough demos
This page contains the four demos of the guided walkthrough. Run the steps in What setup does before you start Demo 1.
Demo 1: tiered storage
Tiered storage is a brownfield retrofit: you begin with a plain PostgreSQL database full of data, add ColdFront to it, and let the archiver relocate the cold majority to object storage - without migrating to a new database or changing a line of application SQL.
The following table shows the eleven steps this demo covers:
| Step | What you will do |
|---|---|
| 1. Start the stack | Bring up Postgres, Lakekeeper, and the object store |
| 2. Create a table and load history | An ordinary partitioned table with months of data |
| 3. See the problem | All rows in hot Postgres storage, and it only grows |
| 4. Enable the extensions | Two extensions retrofit tiering onto the existing database |
| 5. Point at object storage | Tell ColdFront where cold data lives |
| 6. Show the archiver policy | hot_period: 30 days - the hot/cold boundary |
| 7. Run the archiver | Move everything older than 30 days to object storage |
| 8. Where it lives now | The hot/cold split: rows and space in each tier |
| 9. Query across tiers | One table, one query, hot + cold together |
| 10. Write to cold data | UPDATE an archived row in place - no rehydration |
| 11. Prove it stuck | Reconnect and confirm the edit persisted in cold storage |
Step 1 - Start the stack (setup)
Setup starts the infrastructure: PostgreSQL (your database), plus Lakekeeper
and SeaweedFS. The cold-storage side sits idle until you point ColdFront at it
in Step 5. Run the docker compose up command shown in
What setup does. Then confirm that the db,
lakekeeper-db, and lakekeeper services report healthy:
docker compose -f examples/walkthrough/docker-compose.yml ps
PostgreSQL is your existing database. The catalog and object store are also running - that is the cold-storage side, unused until Step 5. Locally the store is SeaweedFS; in production it is your AWS S3, Azure Blob, or GCS bucket.
Step 2 - Create a table and load months of history
This step stands in for the database you already run: an ordinary range-partitioned PostgreSQL table, filled with months of accumulated data. Nothing here is ColdFront-specific.
Running the cells from this page? Skip ahead - every SQL cell below opens its own connection. Following along in a local shell instead, connect once with the walkthrough credentials (notices suppressed so the output stays clean) and paste each SQL block into the session:
PGOPTIONS='-c client_min_messages=warning' \
psql -h localhost -p 5432 -U coldfront -d coldfront
Create the partitioned table with 25 monthly partitions covering about two years of history plus the current month:
SET search_path = public;
CREATE TABLE events (
id bigint GENERATED BY DEFAULT AS IDENTITY,
ts timestamptz NOT NULL,
status text,
data jsonb,
PRIMARY KEY (id, ts)
) PARTITION BY RANGE (ts);
DO $do$
DECLARE m date;
BEGIN
FOR i IN 0..24 LOOP
m := (
date_trunc('month', now())
- make_interval(months => 24 - i)
)::date;
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I'
' PARTITION OF events'
' FOR VALUES FROM (%L) TO (%L)',
'events_p_' || to_char(m, 'YYYY_MM'),
m,
(m + interval '1 month')
);
END LOOP;
END $do$;
The loop creates 25 monthly partitions spanning about two years. Months older than 30 days will tier to cold; the last month or two stays hot.
Insert approximately one million rows spread evenly across the partition window
(roughly two years, 730 days, from now()):
INSERT INTO events (id, ts, status, data)
SELECT i,
now() - ((1000000 - i) * (interval '730 days' / 1000000)),
(ARRAY['ok','warn','error'])[1 + i % 3],
jsonb_build_object('level', (ARRAY['info','warn','error'])[1 + i % 3],
'region', 'us-east-1', 'latency_ms', (i % 500))
FROM generate_series(1, 1000000) i;
Confirm all rows landed:
SELECT count(*) FROM events;
count
---------
1000000
Months of data now live in an ordinary PostgreSQL table - no ColdFront involved yet.
Step 3 - See the problem
This step measures how much hot Postgres storage that data occupies and confirms that none of it is anywhere cheaper yet. This is the baseline you will compare against after tiering in Step 8.
pg_total_relation_size() on a partitioned parent counts only the empty parent
itself and reports zero. Sum across pg_partition_tree to get the true heap
size:
SELECT pg_size_pretty(
pg_total_relation_size('events') +
COALESCE((
SELECT sum(pg_total_relation_size(relid))
FROM pg_partition_tree('events')
WHERE relid <> 'events'::regclass
), 0)
) AS hot_size;
hot_size
----------
152 MB
Every row - all one million - occupies hot, expensive primary storage, and the table only grows. Remember this figure; Step 8 shows where it goes after tiering.
Step 4 - Enable the extensions
This step retrofits tiering onto the database you already have, using two
extensions. pg_duckdb gives PostgreSQL an in-process engine that can read
Parquet in object storage. coldfront adds the layer that routes each query to
the right tier and rewrites DML. No migration, no new database - these install
onto the running one.
Run the following SQL to create both extensions:
CREATE EXTENSION IF NOT EXISTS pg_duckdb;
CREATE EXTENSION IF NOT EXISTS coldfront;
Confirm both are installed:
\dx
Name | Version | Schema | Description
-----------+---------+------------+------------------------------------------------------------------------------------------------
coldfront | 1.0 | coldfront | Transparent tiered storage: route DML on tiered views across hot (PG) and cold (Iceberg) tiers
pg_duckdb | 1.1.0 | public | DuckDB Embedded in Postgres
plpgsql | 1.0 | pg_catalog | PL/pgSQL procedural language
Two extensions - that is the entire ColdFront install. No sidecar, no proxy, no data movement yet.
Step 5 - Point ColdFront at the object store
This step tells ColdFront where cold data goes and how to authenticate to it. The credentials below are throwaway values for the local SeaweedFS emulator. In production, pass your real bucket's key, secret, and endpoint here - application SQL is unchanged when you swap stores.
Register the local SeaweedFS credentials:
SELECT coldfront.set_storage_secret(
'admin', 'adminsecret', 'seaweedfs:8333'
);
The cold-storage warehouse is now wired. Confirm the warehouse that setup
created (wh) exists:
curl -s localhost:8181/management/v1/warehouse \
| grep -o '"name":"wh"'
"name":"wh"
Step 6 - Show the archiver policy
The archiver policy is one rule in a YAML file: data older than 30 days belongs in cheap object storage; the most recent data stays hot in PostgreSQL. Nothing moves yet - this step just shows the boundary.
Inspect the archiver configuration:
cat examples/walkthrough/config/archiver.yaml
# ColdFront walkthrough archiver config: Demo 1 (tiered).
#
# Runs INSIDE the compose network (docker compose run --rm archiver), so every
# endpoint is a compose SERVICE NAME, not localhost. hot_period "30 days" keeps
# the current month hot and tiers the older months to Iceberg/S3, deterministic
# because the walkthrough seeds now()-relative timestamps.
postgres:
dsn: "host=db port=5432 dbname=coldfront user=coldfront password=coldfront sslmode=disable"
iceberg:
warehouse: "wh"
lakekeeper_endpoint: "http://lakekeeper:8181/catalog"
s3:
endpoint: "seaweedfs:8333"
region: "us-east-1"
access_key: "admin"
secret_key: "adminsecret"
use_ssl: false
url_style: "path"
archiver:
tables:
- source_table: events
partition_period: monthly
hot_period: "30 days"
The hot_period: 30 days value is the hot/cold line. Any partition whose data
is entirely older than 30 days will move to object storage when the archiver
runs.
Step 7 - Register the table, then run the archiver
The archiver reads its managed-table set from coldfront.partition_config, not
from the YAML at run time. Seed that table once from the YAML's
archiver.tables block with import (equivalently, register one table by
hand), then run the archiver: it moves every partition older than 30 days out
of the PostgreSQL heap into Parquet files in object storage and rebuilds
events as a unified view over the hot remainder and the cold data.
docker compose \
-f examples/walkthrough/docker-compose.yml \
run --rm --no-deps archiver import --config /config/archiver.yaml
docker compose \
-f examples/walkthrough/docker-compose.yml \
run --rm --no-deps archiver --config /config/archiver.yaml
The --no-deps flag reuses the already-running, data-loaded db container;
without it, docker compose run re-evaluates depends_on and can recreate
db from its config hash, wiping the rows you just loaded.
The archiver connects inside the Compose network (service name db, not
localhost), detaches the partitions older than 30 days from PostgreSQL,
exports them to Iceberg via pg_duckdb, and replaces the events table with a
unified view that queries both tiers.
Proof (a) - the cold rows are really in S3 as Parquet. The
iceberg_metadata() table function resolves its argument as a filesystem path,
so a REST-catalog table cannot be addressed by name directly. Resolve the
table's metadata.json S3 location from the Lakekeeper catalog first, then
point iceberg_metadata() at that path:
# Resolve the warehouse id and the table's metadata location.
WH_ID=$(curl -s http://localhost:8181/management/v1/warehouse \
| grep -o '"warehouse-id":"[^"]*"' \
| head -1 | cut -d'"' -f4)
META_LOC=$(curl -s \
"http://localhost:8181/catalog/v1/${WH_ID}/namespaces/public/tables/events" \
-H 'accept: application/json' \
| grep -o '"metadata-location":"[^"]*"' \
| head -1 | cut -d'"' -f4)
echo "$META_LOC"
# Query the Parquet data files registered in that Iceberg snapshot.
psql "postgresql://coldfront@localhost:5432/coldfront" \
-v ON_ERROR_STOP=1 -P pager=off <<SQL
SELECT file_path
FROM iceberg_metadata('${META_LOC}')
WHERE file_path LIKE '%.parquet'
LIMIT 3;
SQL
s3://iceberg/<warehouse-uuid>/<table-uuid>/metadata/00023-<uuid>.gz.metadata.json
file_path
-----------------------------------------------------------------
s3://iceberg/.../data/month_ts_2=656/01a0ec3d-8abb-....parquet
s3://iceberg/.../data/month_ts_2=657/01a0ec3d-8b52-....parquet
s3://iceberg/.../data/month_ts_2=658/01a0ec3d-8bd4-....parquet
Real .parquet objects are in the bucket. The cold rows are no longer in
PostgreSQL - they are objects in object storage. Each month's files sit under
their own month_ts_<n>= directory: the cold table is partitioned by month,
the way the hot table is.
Proof (b) - the table changed shape. Inspect the relation type:
\d events
events is now a view. The _events table holds only the hot remainder. Run
the following query to see the recorded hot/cold cutoff:
SELECT * FROM coldfront.archive_watermark;
Step 8 - Where the data lives now
This step accounts for every row after tiering: how many are still hot in PostgreSQL, how many are now cold in object storage, and how much space each tier uses. This is the direct payoff against the Step 3 baseline.
Count the hot rows still in the PostgreSQL heap:
SELECT count(*) AS hot_rows FROM _events;
Measure the hot heap size (sum over the partition tree, as in Step 3):
SELECT pg_size_pretty(
pg_total_relation_size('_events') +
COALESCE((
SELECT sum(pg_total_relation_size(relid))
FROM pg_partition_tree('_events')
WHERE relid <> '_events'::regclass
), 0)
) AS hot_size;
Count the total rows across both tiers and derive the cold count:
SELECT count(*) AS total_rows FROM events;
Together, the three queries give the following hot/cold split:
Tier Rows Postgres heap
---------------------- ----------- ----------------
Hot (Postgres) ~68,000 ~10 MB
Cold (Parquet in S3) ~932,000 0 bytes in PG
---------------------- ----------- ----------------
Total 1,000,000
Before tiering (Step 3): one million rows, all hot, approximately 152 MB of Postgres heap. After tiering: only the last month or two remains in PostgreSQL; over 90% of the hot storage is gone while the total row count is unchanged.
Step 9 - Query across tiers
This step runs one ordinary query against events that spans both tiers, then
queries the hot-only table for contrast. The application issuing this query
cannot tell which rows came from the heap and which came from object storage.
Query the whole table - hot and cold together:
SELECT count(*) AS total FROM events;
Query only the hot heap:
SELECT count(*) AS hot FROM _events;
Retrieve specific rows from a cold month:
SELECT id, ts, status
FROM events
WHERE ts < date_trunc('month', now()) - interval '3 months'
ORDER BY ts
LIMIT 3;
id | ts | status
----+-------------------------------+--------
1 | 2024-09-29 08:17:49.334041+00 | warn
2 | 2024-09-29 08:23:04.694041+00 | error
3 | 2024-09-29 08:28:20.054041+00 | ok
The timestamps track your load: row 1 sits exactly 730 days before it. One table, one query, no application change required.
Step 10 - Write to cold data
This step takes a specific row from a cold (archived) month and updates it
through the same events view. Watch for what does not happen: no rehydration
of the partition back into PostgreSQL, no ETL job, no restore from archive, and
no second tool.
Capture a cold row's id in a separate query. A sub-select over the tiered view inside the same DML statement is rejected by the extension, because the rewrite retargets the leading reference:
SELECT id, ts, status
FROM events
WHERE ts < date_trunc('month', now()) - interval '2 months'
ORDER BY ts
LIMIT 1;
id | ts | status
----+-------------------------------+--------
1 | 2024-09-29 08:17:49.334041+00 | warn
Update the archived row through the same table using the captured id:
UPDATE events SET status = 'corrected' WHERE id = 1;
Read the row back immediately:
SELECT id, ts, status FROM events WHERE id = 1;
id | ts | status
----+-------------------------------+-----------
1 | 2024-09-29 08:17:49.334041+00 | corrected
The row's status flipped warn to corrected. That row is still sitting in
object storage - ColdFront wrote through to it directly.
Step 11 - Prove it stuck
This step opens a fresh psql connection (nothing cached from the session that
did the write) and re-checks the row, the total row count, and the hot heap
size. This confirms that the cold edit is durable persistent state and that the
data did not quietly return to PostgreSQL to make the edit possible.
Each runnable cell on this page already opens a fresh connection, so cell-runners can simply run the checks below. In a local shell, open a new terminal and connect with a fresh session first:
PGOPTIONS='-c client_min_messages=warning' \
psql -h localhost -p 5432 -U coldfront -d coldfront
Confirm the archived row is still corrected:
SELECT id, status FROM events WHERE id = 1;
id | status
-----+-----------
1 | corrected
Confirm the total row count is unchanged:
SELECT count(*) AS total_rows FROM events;
total_rows
------------
1000000
Confirm the hot heap is still small (the data did not rehydrate):
SELECT pg_size_pretty(
pg_total_relation_size('_events') +
COALESCE((
SELECT sum(pg_total_relation_size(relid))
FROM pg_partition_tree('_events')
WHERE relid <> '_events'::regclass
), 0)
) AS hot_size;
hot_size
----------
~10 MB
Fresh connection: the archived row is still corrected, all one million rows
are present, and the hot heap is still small. The data never came back to
PostgreSQL. Over 90% of the original storage now lives in object storage as
Parquet, and that data is still a normal, writeable part of the table -
corrected in place, no rehydration, no separate system.
Demo 2: decoupled (Iceberg-only)
Decoupled mode stores a table entirely in Iceberg from the first row. PostgreSQL holds a thin wrapper view and a registry entry. This is a fresh table - there is no migration from the tiered demo, and the two modes are independent. Demo 2 still needs the extensions and storage secret from Steps 4 and 5 of Demo 1. If you start here, run those two steps first.
Create an Iceberg-only table
One SQL call provisions the Iceberg table and the PostgreSQL view. The public
namespace was seeded during setup, and the call wraps both steps in a single
transaction:
SELECT coldfront.create_iceberg_table(
'public',
'events_lake',
'[
{"name":"id", "type":"bigint"},
{"name":"ts", "type":"timestamptz"},
{"name":"status", "type":"text"},
{"name":"data", "type":"jsonb"}
]'::jsonb
);
After the call returns, events_lake is a view; every row lives in Iceberg on
S3.
Read and write the lake table
events_lake behaves like any PostgreSQL table:
INSERT INTO events_lake VALUES
(1, now(), 'ok', '{"a":1}'),
(2, now(), 'ok', '{"a":2}');
SELECT count(*) AS rows_in_lake FROM events_lake;
UPDATE events_lake SET status = 'upd' WHERE id = 1;
SELECT id, status FROM events_lake ORDER BY id;
DELETE FROM events_lake WHERE id = 2;
SELECT count(*) AS after_delete FROM events_lake;
All four DML operations reach the Iceberg table transparently. The coldfront extension intercepts each statement on the view and rewrites it to the Iceberg path via pg_duckdb.
Demo 2b: adopt a table already in the lake
Not every Iceberg table starts in ColdFront. A table another engine wrote needs no provisioning, only a wrapper view and a registry row. Adoption builds both from the schema the catalog already holds.
Stand in for the external writer
These three statements are the only ones in this walkthrough written in DuckDB
SQL, because they represent what some other engine already did to your lake.
The namespace create commits on its own, ahead of the table create, because
DuckDB defers an Iceberg CREATE SCHEMA to commit while posting CREATE TABLE
immediately:
SELECT coldfront.ensure_attached();
SELECT duckdb.raw_query('CREATE SCHEMA IF NOT EXISTS ice.lake');
The table has a VARCHAR column holding JSON, which matters in a moment:
SELECT coldfront.ensure_attached();
SELECT duckdb.raw_query($$
CREATE TABLE IF NOT EXISTS ice.lake.orders (
order_id BIGINT, placed_at TIMESTAMP WITH TIME ZONE,
customer VARCHAR, amount DECIMAL(12,2), meta VARCHAR)$$);
SELECT duckdb.raw_query($$
INSERT INTO ice.lake.orders VALUES
(1, now(), 'acme', 19.99, '{"tier":"gold"}'),
(2, now(), 'globex', 249.50, '{"tier":"silver"}')$$);
Adopt it read-only
One call, and no column list; the schema comes from the catalog. The view goes
in the PostgreSQL schema public; lake is the Iceberg namespace, and no
PostgreSQL schema of that name is needed:
SELECT coldfront.adopt_iceberg_table('public', 'orders', 'lake');
SELECT order_id, customer, amount FROM orders ORDER BY order_id;
The call reports what it registered:
NOTICE: coldfront: adopted "ice"."lake"."orders" as public.orders (5 columns, read-only)
Writes are refused, and the message says what to do:
UPDATE orders SET amount = 0 WHERE order_id = 1;
ERROR: coldfront: "public.orders" is adopted read-only
HINT: Release it with coldfront.release_iceberg_table() and adopt again with p_writable => true to arm INSERT/UPDATE/DELETE.
Enable writes
Adoption binds the name once, so enabling writes is a release followed by a
second adopt with p_writable => true, which gives the view the same DML
rewrite a created table gets. Every write below is one bakery-serialized
Iceberg snapshot:
SELECT coldfront.release_iceberg_table('public', 'orders');
SELECT coldfront.adopt_iceberg_table('public', 'orders', 'lake',
p_writable => true);
INSERT INTO orders VALUES (3, now(), 'initech', 42.00, '{"tier":"bronze"}');
UPDATE orders SET amount = 21.00 WHERE order_id = 3;
DELETE FROM orders WHERE order_id = 1;
SELECT order_id, customer, amount FROM orders ORDER BY order_id;
Restore a type Iceberg cannot record
The meta column reads as text, because Iceberg stores JSON as VARCHAR and
records no PostgreSQL type. p_types restores it, and the override is accepted
because it maps to what the catalog stores:
SELECT coldfront.release_iceberg_table('public', 'orders');
SELECT coldfront.adopt_iceberg_table('public', 'orders', 'lake',
p_writable => true,
p_types => '{"meta":"jsonb"}'::jsonb);
SELECT order_id, meta->>'tier' AS tier FROM orders ORDER BY order_id;
Hand it back
Release removes the view and the registry row and performs no Iceberg I/O, so the table keeps every row and stays in the catalog:
SELECT coldfront.release_iceberg_table('public', 'orders');
Two things to take from this demo. A VARCHAR column comes back as text rather
than as jsonb unless p_types says otherwise, because Iceberg records no
PostgreSQL type. And the bakery serializes ColdFront's own writers, not an
external engine writing the same table.
Demo 3: standalone partitioner
The partitioner binary manages PostgreSQL range partitions without any cold tier. If all you need is automated partition maintenance on stock PostgreSQL, the partitioner is the whole product - no Iceberg, no DuckDB, no archiver cold path.
Create the demo table
Create a partitioned table with no existing partitions:
SET search_path = public;
CREATE TABLE part_demo (
id bigint GENERATED ALWAYS AS IDENTITY,
ts timestamptz NOT NULL,
note text,
PRIMARY KEY (id, ts)
) PARTITION BY RANGE (ts);
Register and reconcile
Register the table with the partitioner (monthly period, 12-month retention)
and run a reconcile pass. Both commands run inside the Compose network against
service name db:
# Register the table.
docker compose \
-f examples/walkthrough/docker-compose.yml \
run --rm --no-deps --entrypoint partitioner archiver \
register \
--config /config/partitioner.yaml \
--table part_demo \
--period monthly \
--retention "12 months"
# Run a reconcile pass to premake forward partitions.
docker compose \
-f examples/walkthrough/docker-compose.yml \
run --rm --no-deps --entrypoint partitioner archiver \
--config /config/partitioner.yaml
The partitioner config at examples/walkthrough/config/partitioner.yaml uses a
partition-only configuration with no iceberg or s3 sections:
# ColdFront walkthrough partitioner config: Demo 3 (standalone partitioner).
#
# PARTITION-ONLY: just postgres + archiver.tables. No iceberg/s3: this demo is
# the "you don't need the cold tier" story: automated PostgreSQL range-partition
# maintenance against stock Postgres. Runs INSIDE the compose network
# (docker compose run --rm --entrypoint partitioner archiver), so the host is the
# compose SERVICE NAME (db), not localhost.
postgres:
dsn: "host=db port=5432 dbname=coldfront user=coldfront password=coldfront sslmode=disable"
archiver:
tables:
- source_table: part_demo
partition_column: ts
partition_period: monthly
retention_period: 12 months
Verify the partitions
Each reconcile pass premakes the next three monthly partitions ahead of now and ensures a partition covering today always exists. It also drops any partition older than the retention period:
SELECT count(*) AS partitions
FROM pg_inherits
WHERE inhparent = 'part_demo'::regclass;
Demo 4: distributed
Distributed mode points two or more PostgreSQL nodes at the same lake. The nodes form an active-active Spock mesh; the table data lives once, in Iceberg, and each node adds query and write capacity over that one shared copy. A write on one node is readable on the other with nothing copied between them, and concurrent cold writes from different nodes are serialized cluster-wide so they never collide.
This demo uses a different stack from the single-node walkthrough - two
MESH=on nodes (db1, db2) plus a shared Lakekeeper and object store. The
interactive guide automates the whole switch (it stops the single-node stack
first, since a laptop rarely has room for both):
This demo is not click-runnable. It needs a different two-node stack and separate psql sessions against each node (
db1on port 5442,db2on 5443), so the blocks below are shown for reading and hand-pasting. The easiest way to run it is the interactive guide:
bash examples/walkthrough/guide.sh # then choose: 4) Distributed
The sections below show what that option does, so you can follow along or reproduce it by hand.
Bring up the two-node mesh
Start the mesh stack, then form the Spock mesh - create a node on each member, subscribe each to the other, and set up the cold-write coordination on both. First, start the mesh stack and build its images:
docker compose -f examples/walkthrough/docker-compose.mesh.yml up -d --build
If port 5442, 5443, 8191, or 8343 is already in use on your host, set
COLDFRONT_MESH_PG1_PORT, COLDFRONT_MESH_PG2_PORT, COLDFRONT_MESH_LK_PORT,
or COLDFRONT_MESH_S3_PORT before up.
Next, run the curl block from
What setup does with port 8191 in place
of 8181. That creates the wh warehouse and public namespace on the mesh's
own Lakekeeper.
Then create the extensions and form the mesh, running each statement on the node its comment names:
-- On BOTH nodes - create the extensions. The container preloads the libraries
-- (shared_preload_libraries) but does not run CREATE EXTENSION, so the SQL
-- objects (the spock schema, coldfront functions) do not exist until you do:
CREATE EXTENSION IF NOT EXISTS snowflake;
CREATE EXTENSION IF NOT EXISTS spock;
CREATE EXTENSION IF NOT EXISTS pg_duckdb;
CREATE EXTENSION IF NOT EXISTS coldfront;
-- Create BOTH nodes first: a subscription can only be created once its
-- provider is already a spock node, so both node_create calls must run
-- before either sub_create.
-- On db1:
SELECT spock.node_create('db1', 'host=db1 user=coldfront dbname=coldfront port=5432');
-- On db2:
SELECT spock.node_create('db2', 'host=db2 user=coldfront dbname=coldfront port=5432');
-- Then subscribe each node to the other (order between these two does not matter):
-- On db1:
SELECT spock.sub_create('sub_db1_from_db2', 'host=db2 user=coldfront dbname=coldfront port=5432');
-- On db2:
SELECT spock.sub_create('sub_db2_from_db1', 'host=db1 user=coldfront dbname=coldfront port=5432');
-- On BOTH nodes - wait for the subscription to sync, then replicate the bakery's
-- claim + config tables and set the cold-store secret, before any cold write:
SELECT spock.sub_wait_for_sync(sub_name) FROM spock.subscription;
SELECT coldfront._ensure_claims_replicated();
SELECT spock.repset_add_table('default', 'coldfront.partition_config'::regclass, false);
SELECT spock.repset_add_table('default', 'coldfront.storage_secret'::regclass, false);
SELECT coldfront.set_storage_secret('admin', 'adminsecret', 'seaweedfs:8333');
The nodes reach each other over the Compose network (service names db1/db2,
port 5432). The bakery replicates only small coordination metadata between
nodes - never the table data, which stays in the lake. The set_storage_secret
call is what lets each node's DuckDB write Parquet to the shared object store;
without it, cold writes fail to authenticate.
See the mesh
Both nodes are present, each subscribed to the other:
SELECT node_name FROM spock.node ORDER BY node_name; -- db1, db2
SELECT sub_name FROM spock.subscription; -- one per node
Write on one node, read on the other
Create a lake-native table on db1 and register it on db2 as well (the call
is idempotent and the registry is keyed by name, so each node ends up with an
identical local view):
-- On db1, then on db2 - same call:
SELECT coldfront.create_iceberg_table(
'public', 'events_lake',
'[{"name":"id","type":"bigint"},{"name":"ts","type":"timestamptz"},
{"name":"status","type":"text"},{"name":"data","type":"jsonb"}]'::jsonb
);
Write three rows on db1, then read them back on db2:
-- db1:
INSERT INTO events_lake VALUES
(1, now(), 'ok', '{"n":"db1"}'),
(2, now(), 'ok', '{"n":"db1"}'),
(3, now(), 'warn', '{"n":"db1"}');
-- db2 - a different node, which stored none of this data:
SELECT id, status, data->>'n' AS written_by FROM events_lake ORDER BY id;
SELECT relkind, pg_size_pretty(pg_relation_size('events_lake')) AS pg_bytes
FROM pg_class WHERE relname = 'events_lake'; -- v, 0 bytes
db2 returns every row db1 wrote and stores zero bytes for the table - it
reads straight from the shared lake. That is the point of distributed mode: add
a node for compute over one copy of the data, with no storage to replicate.
Concurrent writes serialize (the bakery)
Two nodes committing the same Iceberg table at once would normally collide -
the catalog rejects the second commit with a 409 Conflict and the application
has to retry. ColdFront's bakery protocol prevents that: each cold write takes
a globally-ordered ticket (replicated via Spock, verified in the TLA+ model
under docs/formal/) and waits its turn.
The bookkeeping is ordinary rows in coldfront.claims and
coldfront.claim_acks, and a write's commit clears them, so the way to read
them is to hold a transaction open. In one session on db1:
BEGIN;
INSERT INTO events_lake VALUES (301, now(), 'held', '{"n":"db1"}');
-- leave the transaction open
In a second session, the claim is on db1, keyed by a ticket that also names
the issuing node (a snowflake id), and it is already on db2: the claim is
written over its own connection and committed at once, so it replicates while
the transaction that took it is still open:
-- On db1, then on db2 - the same row on both:
SELECT ticket, snowflake.get_node(ticket) AS issued_by_node, iceberg_table
FROM coldfront.claims;
db2 acknowledges the ticket and the ack replicates back. A writer commits
only once every peer has acked its ticket, which is what orders writers across
nodes (the Ricart-Agrawala rule):
-- On db1:
SELECT ticket, ack_from_name AS acked_by FROM coldfront.claim_acks;
The row itself is not in the lake yet: the Iceberg snapshot is written when the
transaction commits, under the claim, so on db2 the count of rows with
id = 301 is still 0. Now COMMIT in the first session. The release deletes
the claim and its acks, both deletes replicate, and the row is readable from
db2:
-- On db2:
SELECT (SELECT count(*) FROM coldfront.claims) AS claims,
(SELECT count(*) FROM coldfront.claim_acks) AS acks,
(SELECT count(*) FROM events_lake WHERE id = 301) AS rows_with_id_301;
-- 0 | 0 | 1
Tickets are never reused, so an empty ledger is the steady state between writes. Now fire many writers at once - several on each node, on both nodes, all into the same table:
-- concurrently, on BOTH nodes at the same instant:
INSERT INTO events_lake VALUES (101, now(), 'storm', '{"n":"db1"}'); -- db1
INSERT INTO events_lake VALUES (201, now(), 'storm', '{"n":"db2"}'); -- db2
-- ...5 concurrent on db1 (101-105) and 5 on db2 (201-205)
Every write lands, with no conflicts and no application-level retry. Two layers serialize them: a node-local advisory lock keeps one cold writer per node in the bakery at a time, and the cross-node Ricart-Agrawala claim protocol orders writers across nodes. The durable record is the lake's own: every commit adds one snapshot to the table's metadata, in a single chain of sequence numbers. Resolve the table's metadata location from the mesh stack's Lakekeeper (port 8191), as in Demo 1, and list the history from either node:
WH_ID=$(curl -s http://localhost:8191/management/v1/warehouse \
| grep -o '"warehouse-id":"[^"]*"' | head -1 | cut -d'"' -f4)
META_LOC=$(curl -s \
"http://localhost:8191/catalog/v1/${WH_ID}/namespaces/public/tables/events_lake" \
-H 'accept: application/json' \
| grep -o '"metadata-location":"[^"]*"' | head -1 | cut -d'"' -f4)
psql "postgresql://coldfront@localhost:5443/coldfront" -P pager=off <<SQL
SELECT sequence_number, snapshot_id, timestamp_ms
FROM iceberg_snapshots('${META_LOC}')
ORDER BY sequence_number;
SQL
Twelve writes from two nodes, twelve snapshots, no gap and no fork.
Teardown
To stop the stack and remove all data volumes, run the following command:
docker compose \
-f examples/walkthrough/docker-compose.yml \
down -v
The -v flag removes the named volumes (pgdata and s3data). Omit it to
keep the data for a later session.
If you ran the distributed demo, tear down its separate mesh stack too:
docker compose \
-f examples/walkthrough/docker-compose.mesh.yml \
down -v
Next Steps
To go further with ColdFront, consult the following guides:
- The Using ColdFront guide covers both modes in depth, including the full one-time setup, supported column types, the partition manager CLI, and tuning options.
- The Object Store Setup guide takes you from an empty bucket to a working cold tier on cloud S3, GCS, or Azure.
- The Architecture overview explains the shared mechanics and links to the per-mode deep dives.
- The Compaction guide covers cold-tier maintenance: compaction, snapshot expiry, and orphan-file removal.