Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ INSERT INTO events VALUES (1, now(), 'hello');
SELECT count(*) FROM events;
```

To remove a table again, `coldfront.drop_iceberg_table()` unregisters it
and drops the Iceberg table, deleting the stored objects only when asked
to. See
[Dropping an Iceberg table](docs/usage.md#dropping-an-iceberg-table-both-modes).

For compliance environments that cannot store an object-store credential,
`coldfront.set_storage_secret_vended()` runs with no credential in the
database: Lakekeeper mints short-lived per-table credentials at access
Expand Down
134 changes: 134 additions & 0 deletions ci/journey.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2647,6 +2647,139 @@ story_composite_key_rejected() {
q "$HOST" "DROP TABLE IF EXISTS public.composite_part;" >/dev/null 2>&1
}

# ───────────────────────────────────────────────────────────────────────────
# Story: coldfront.drop_iceberg_table. One verb, both modes, and the caller
# decides whether the stored objects go with the catalog entry.
#
# Throwaway tables in their own namespace, so the journey's own fixtures are
# untouched and this runs in any mode. The tiered registration is built directly
# (real Iceberg table + hot table + view + registry rows) instead of by running
# the archiver: story_provision_tiered already covers the archiver's path, and
# what is under test here is the drop.
# ───────────────────────────────────────────────────────────────────────────

# http status of a dropprobe catalog entry: 200 while it exists, 404 once dropped.
dit_catalog() { curl -s -o /dev/null -w '%{http_code}' \
"http://${LK_IP}:8181/catalog/v1/${DIT_WH}/namespaces/dropprobe/tables/$1"; }

# The table's storage location, resolved while the catalog entry still exists:
# after the drop it is a 404, which is exactly when the location is needed to
# check whether the objects survived.
dit_location() {
curl -s "http://${LK_IP}:8181/catalog/v1/${DIT_WH}/namespaces/dropprobe/tables/$1" \
| grep -oE '"location":"[^"]+"' | head -1 | cut -d'"' -f4
}

# Objects under a location. The URI comes from Lakekeeper, so the glob follows
# whatever object store this cell runs against.
dit_count() {
[ -n "$1" ] || { echo ""; return; }
q "$HOST" "SELECT coldfront.ensure_attached(); SELECT r['n'] FROM duckdb.query('SELECT count(*) AS n FROM glob(''$1/**'')') AS t(r);" | tail -1
}

# dit_provision <table> <mode>: a registered relation of the given mode,
# holding real Iceberg data.
dit_provision() {
local t="$1" mode="$2"
if [ "$mode" = decoupled ]; then
q "$HOST" "SELECT coldfront.create_iceberg_table('dropprobe','$t','[{\"name\":\"id\",\"type\":\"bigint\"}]'::jsonb);" >/dev/null 2>&1
else
# Tiered by construction: a real Iceberg cold table, a hot heap under the
# archiver's _-prefixed name, the transparent view in its place, and the
# registration rows the archiver would have written.
q "$HOST" "SELECT coldfront.ensure_attached(); SELECT duckdb.raw_query('CREATE TABLE IF NOT EXISTS ice.dropprobe.$t (id BIGINT)');" >/dev/null 2>&1
q "$HOST" "CREATE TABLE IF NOT EXISTS dropprobe._$t (id bigint);
CREATE OR REPLACE VIEW dropprobe.$t AS SELECT * FROM dropprobe._$t;
INSERT INTO coldfront.tiered_views(schema_name, relname, hot_table, iceberg_table, partition_col)
VALUES ('dropprobe','$t','dropprobe._$t','ice.dropprobe.$t','id')
ON CONFLICT (schema_name, relname) DO NOTHING;
INSERT INTO coldfront.partition_config(schema_name, table_name, partition_period, hot_period)
VALUES ('dropprobe','$t','monthly','30 days')
ON CONFLICT (schema_name, table_name) DO NOTHING;" >/dev/null 2>&1
fi
q "$HOST" "SELECT coldfront.ensure_attached(); SELECT duckdb.raw_query('INSERT INTO ice.dropprobe.$t SELECT i FROM range(1,11) t(i)');" >/dev/null 2>&1
}

# dit_assert_pg <label> <table> <mode>: the PostgreSQL side after a drop. The
# registration and wrapper view always go; a tiered table additionally loses its
# archiver config and gets its hot table back under the relation's own name.
dit_assert_pg() {
local label="$1" t="$2" mode="$3"
assert_eq "$label: registry row gone" "0" \
"$(q "$HOST" "SELECT count(*) FROM coldfront.tiered_views WHERE schema_name='dropprobe' AND relname='$t';")"
if [ "$mode" = decoupled ]; then
assert_eq "$label: nothing left in PostgreSQL" "0" \
"$(q "$HOST" "SELECT count(*) FROM pg_class WHERE relname='$t' AND relnamespace='dropprobe'::regnamespace;")"
return
fi
# partition_config must go too, or the next archiver run would re-tier into a
# catalog entry that no longer exists.
assert_eq "$label: partition_config row gone" "0" \
"$(q "$HOST" "SELECT count(*) FROM coldfront.partition_config WHERE schema_name='dropprobe' AND table_name='$t';")"
assert_eq "$label: hot table retained under its own name" "r" \
"$(q "$HOST" "SELECT relkind FROM pg_class WHERE relname='$t' AND relnamespace='dropprobe'::regnamespace;")"
assert_eq "$label: the _-prefixed hot table is gone" "0" \
"$(q "$HOST" "SELECT count(*) FROM pg_class WHERE relname='_$t' AND relnamespace='dropprobe'::regnamespace;")"
}

# dit_assert_objects <label> <loc> <before> <purge>: Lakekeeper deletes objects
# from its own background queue, so neither outcome can be read immediately:
# purge has to be waited for, and "retained" only means something once a purge
# would have had time to run.
dit_assert_objects() {
local label="$1" loc="$2" before="$3" purge="$4" after i=0
if [ "$purge" != true ]; then
sleep 5
assert_eq "$label: stored objects retained" "$before" "$(dit_count "$loc")"
return
fi
while [ "$i" -lt 30 ]; do
after=$(dit_count "$loc")
[ "$after" = "0" ] && break
sleep 1; i=$((i + 1))
done
assert_eq "$label: stored objects purged" "0" "$after"
}

# dit_case <label> <table> <mode> <purge>: provision, drop, then assert the PG
# side, the catalog entry and whether the stored objects survived.
dit_case() {
local label="$1" t="$2" mode="$3" purge="$4" loc before out
dit_provision "$t" "$mode"
loc=$(dit_location "$t")
before=$(dit_count "$loc")
assert_gt "$label: objects exist before the drop" 0 "${before:-0}"

out=$(q_may "$HOST" "SELECT coldfront.drop_iceberg_table('dropprobe','$t',$purge);")
case "$out" in
*ERROR*) fail "$label: drop_iceberg_table failed: $out"; return;;
*) pass "$label: drop_iceberg_table succeeded";;
esac

dit_assert_pg "$label" "$t" "$mode"
assert_eq "$label: catalog entry dropped" "404" "$(dit_catalog "$t")"
dit_assert_objects "$label" "$loc" "$before" "$purge"
}

story_drop_iceberg_table() {
step "drop_iceberg_table: decoupled and tiered, purge and keep-files"
DIT_WH=$(curl -s "http://${LK_IP}:8181/management/v1/warehouse" \
| grep -oE '"warehouse-id":"[^"]+"' | head -1 | cut -d'"' -f4)
[ -n "$DIT_WH" ] || { fail "drop_iceberg_table: could not resolve the warehouse id"; return; }

# The Iceberg namespace must be committed before any CREATE TABLE references
# it (DuckDB defers CREATE SCHEMA to COMMIT; see story_provision_decoupled).
q "$HOST" "SELECT coldfront.ensure_attached(); SELECT duckdb.raw_query('CREATE SCHEMA IF NOT EXISTS ice.dropprobe');" >/dev/null 2>&1
q "$HOST" "CREATE SCHEMA IF NOT EXISTS dropprobe;" >/dev/null 2>&1

dit_case "decoupled/purge" dpurge decoupled true
dit_case "decoupled/keep" dkeep decoupled false
dit_case "tiered/purge" tpurge tiered true
dit_case "tiered/keep" tkeep tiered false

q "$HOST" "DROP SCHEMA IF EXISTS dropprobe CASCADE;" >/dev/null 2>&1
}

# ── orchestrate ────────────────────────────────────────────────────────────
# Setup is shared. The story set then branches on mode: tiered exercises the
# hot+cold partitioned path; decoupled exercises the all-Iceberg wrapper. (The
Expand Down Expand Up @@ -2702,6 +2835,7 @@ else
story_decoupled_concurrency
story_decoupled_ryw
fi
story_drop_iceberg_table # both modes, purge and keep-files (own throwaway tables)
[ "$MESH" = 1 ] && [ "$MODE" = decoupled ] && story_mesh # tiered+mesh runs story_mesh_tiered (above)
[ "$MESH" = 1 ] && story_mesh_multiwriter # >1 cold writer/node cross-node (tiered: events, decoupled: iceonly)
[ -n "$STANDBY" ] && story_standby_reads
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ the following table summarizes:
| `ALTER TABLE _t ADD/DROP COLUMN`, `ALTER COLUMN ... TYPE`, `RENAME COLUMN` | **Mirrored to Iceberg** - the hook drops the view, 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. 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→bigint, float→double, date→timestamp, decimal-widen). |
| `ALTER TABLE _t RENAME TO ...` | Supported (touches no Iceberg schema): update `tiered_views.hot_table`, rebuild the view. |
| `ALTER VIEW v RENAME TO ...` | Supported: migrate the name-keyed registry + `archive_watermark` rows to the new view name, then rebuild (otherwise the lookups miss and the cold UNION branch silently disappears). |
| `DROP TABLE _t` / `DROP VIEW v` | **Blocked by design** - would orphan the Iceberg cold tier. Dismantling tiering is a deliberate operator action (unregister with `partitioner remove`/`archiver remove`, which deletes the `partition_config` row, then drop each tier explicitly), never a one-shot call. |
| `DROP TABLE _t` / `DROP VIEW v` | **Blocked by design** - 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` | **Blocked by design** - cold-tier rows would remain visible through the view. The operator truncates each tier explicitly. |

The hook's view rebuild does `DROP VIEW` + `CREATE VIEW` (not
Expand Down
74 changes: 74 additions & 0 deletions docs/architecture_decoupled.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,80 @@ The helper doesn't add capability over raw_query - it composes the
existing primitives into a single call so applications get a
normal-looking PG table.

## Wrapper helper: `coldfront.drop_iceberg_table()`

Drops the Iceberg table backing a registered relation, in either mode:

```sql
-- catalog entry and stored objects both go
SELECT coldfront.drop_iceberg_table('public', 'events', true);

-- catalog entry goes; Parquet and metadata objects stay in the bucket
SELECT coldfront.drop_iceberg_table('public', 'events', false);
```

One verb covers both modes because in both the thing being dropped is
the Iceberg table. What that means for PostgreSQL differs, because the
modes differ, and the function says which path it took in a NOTICE:

- Iceberg-only: the Iceberg table is the whole relation, so nothing
remains. This is the inverse of `create_iceberg_table()`.
- Tiered: the Iceberg table is the cold tier, so the cold tier goes and
the hot table returns under the relation's own name. This is the
inverse of tiering, so the table ends up an ordinary partitioned
Postgres table again, holding the data that had not yet aged out.

`p_purge` has no default, because the two outcomes are irreversible in
opposite directions. `true` deletes the data and metadata objects, which
for the cold tier are the only copy of that data. `false` leaves those
objects in the object store with no catalog entry, where no coldfront
component reclaims them, since the compactor's expiry and orphan passes
walk the snapshots of a table that still exists. The caller states which
one they mean.

What the function does:

1. Refuses a relation that is not registered in
`coldfront.tiered_views`, so a drop never runs against the catalog
blindly.
2. Deletes the registration. For a tiered table that includes the
`partition_config` and `archive_watermark` rows, because the archiver
resolves its work from `partition_config` and a surviving row would
re-tier the table into a catalog entry that no longer exists.
Deleting the `tiered_views` row also disarms the C DDL hook for the
relation, which is what permits step 3.
3. Drops the wrapper view, and for a tiered table renames the hot table
back, reversing the rename the archiver's first run performed.
4. Takes the same per-table claim every other cold write takes, then
drops the Iceberg table through a second attachment carrying
`PURGE_REQUESTED`. Lakekeeper performs the object deletion itself,
with the warehouse credential, so purge works unchanged under vended
credentials.

Plain `DROP TABLE` and `DROP VIEW` on a registered relation stay blocked
by the DDL hook; this function is the sanctioned path. Destroying a cold
tier is deliberate, and a habitual statement is the wrong trigger for it.

Three properties worth knowing:

- The purge decision is an ATTACH option in duckdb-iceberg rather than a
statement clause, so the drop runs through a scoped attachment whose
alias encodes the flag. The long-lived `ice` attachment is never
purge-armed.
- Purge is asynchronous. The catalog entry disappears with the drop, but
the objects are removed by Lakekeeper's own background purge queue
shortly afterwards, so a check made immediately after the call can
still see them.
- Whether a purged table is recoverable is a property of the Lakekeeper
warehouse, not of coldfront. Under the soft delete profile the dropped
table stays restorable for the warehouse's expiration window before its
objects are deleted; under the hard profile, which is Lakekeeper's
default, there is no recovery window and the purge is queued as soon as
the table is dropped. A table on which a Lakekeeper hold
(`set_table_protection`) is set cannot be dropped at all, and this
function cannot override that: the drop carries `PURGE_REQUESTED` but
never `force`.

## ACID model

(Summarises material from [architecture.md](architecture.md) §Concurrency
Expand Down
5 changes: 5 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ INSERT INTO events VALUES (1, now(), 'hello');
SELECT count(*) FROM events;
```

To remove a table again, `coldfront.drop_iceberg_table()` unregisters it
and drops the Iceberg table, deleting the stored objects only when asked
to. See
[Dropping an Iceberg table](usage.md#dropping-an-iceberg-table-both-modes).

For compliance environments that cannot store an object-store credential,
`coldfront.set_storage_secret_vended()` runs with no credential in the
database: Lakekeeper mints short-lived per-table credentials at access
Expand Down
46 changes: 46 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,52 @@ appearing values automatically:
values_source: "SELECT code FROM regions"
```

## Dropping an Iceberg table (both modes)

`coldfront.drop_iceberg_table()` removes the Iceberg table backing a
registered relation. It is the only sanctioned way to do so: a plain
`DROP TABLE` or `DROP VIEW` on a registered relation stays blocked,
because it would leave the cold tier behind with nothing pointing at it.

The call takes the schema, the table, and an explicit purge decision:

```sql
-- drop the catalog entry and delete the stored objects
SELECT coldfront.drop_iceberg_table('public', 'events', true);

-- drop the catalog entry and leave the objects in the object store
SELECT coldfront.drop_iceberg_table('public', 'events', false);
```

What remains afterwards depends on the mode, and the call reports which
path it took:

- in decoupled mode the Iceberg table is the whole relation, so nothing
remains in PostgreSQL.
- in tiered mode the Iceberg table is the cold tier, so the cold tier is
removed and the hot table returns under the relation's own name, as an
ordinary partitioned table holding the data that had not yet aged out.

In tiered mode the call also deletes the table's `partition_config` row,
which stops the archiver from tiering it again on the next run.

There is no default for the purge argument, because the two outcomes are
irreversible in opposite directions. Passing `true` deletes the Parquet
and metadata objects, which for the cold tier are the only copy of that
data. Passing `false` keeps those objects but removes the catalog entry
that ColdFront would need to reach them again, so nothing reclaims them
afterwards; use it when another system is taking ownership of the files.

ColdFront has no guard of its own against dropping the wrong table. The
preventive control lives in Lakekeeper: table protection has to be enabled
manually, per table, and a protected table cannot be dropped at all,
whether or not purge is requested. This function cannot override a hold.

Deletion is not instantaneous. The catalog entry disappears with the
call, and Lakekeeper's own background queue removes the objects shortly
afterwards, so a listing taken immediately after the call can still show
them.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Managing partitioned tables (CLI)

ColdFront splits configuration into two kinds. **Connection** config -
Expand Down
2 changes: 1 addition & 1 deletion extension/coldfront/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ REGRESS = load_order update_unregistered_view update_heap_table \
param_cold_via_plpgsql async_requires_patch \
storage_secret_azure storage_secret_vended privilege_model \
partition_config_interval self_join_rejected returning_cold_rejected \
schema_collision
schema_collision drop_iceberg_table
REGRESS_OPTS = --inputdir=test --outputdir=test

PG_CONFIG ?= pg_config
Expand Down
Loading