Skip to content

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 (db1 on port 5442, db2 on 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.