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
106 changes: 106 additions & 0 deletions ci/journey.sh
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ assert_register_rejected() {
fi
}

# qdb <db> <sql>: q against another database on the same server. Extensions are
# per-database, so a database story 1 never touched is stock PostgreSQL.
qdb() { local d="$1"; shift; docker exec -e PGUSER="$CF_DBUSER" -e PGDATABASE="$d" "$HOST" "$CF_PSQL" -tA -c "$*"; }

# archive_only — run the archiver with ONLY the partition_config rows matching a
# keep predicate enabled, then restore the rows it disabled. The archiver takes
# its table set from coldfront.partition_config, never from the YAML
Expand Down Expand Up @@ -3328,6 +3332,62 @@ story_partitioner_remove() {
q "$HOST" "DROP SCHEMA rmtest CASCADE;" >/dev/null 2>&1
}

# ───────────────────────────────────────────────────────────────────────────
# Story: the standalone partition manager, on stock PostgreSQL. Every other story
# shares the database story 1 installed pg_duckdb and coldfront into, so nothing
# covers the configuration the partitioner is documented to run in. A database
# story 1 never touched is that configuration, on this same server: no extension,
# so partition_config is the Go-side DDL's to create, and a hot period has no
# cold tier behind it to type-check a column against.
# ───────────────────────────────────────────────────────────────────────────
story_partitioner_stock_pg() {
step "TC-151: standalone partitioner on stock PostgreSQL (no extension)"
local db=cf_stock_pg
local dsn="host=${DB_IP} port=5432 dbname=$db user=coldfront password=coldfront sslmode=disable"
q "$HOST" "DROP DATABASE IF EXISTS $db;" >/dev/null 2>&1
q "$HOST" "CREATE DATABASE $db;" >/dev/null
assert_eq "TC-151: the database carries neither extension" "0" \
"$(qdb $db "SELECT count(*) FROM pg_extension WHERE extname IN ('coldfront','pg_duckdb');")"
qdb $db "CREATE TABLE public.stockev (id bigint GENERATED ALWAYS AS IDENTITY,
ts timestamptz NOT NULL, PRIMARY KEY (id, ts)) PARTITION BY RANGE (ts);" >/dev/null

# With no extension to have created it, partition_config is materialized by
# the partitioner's own DDL, the copy that exists for exactly this case.
if "$PARTITIONER" register --dsn "$dsn" --table stockev --period monthly \
--retention "12 months" >$TMPD/stock-reg.log 2>&1; then
assert_eq "TC-151: register materialized its own partition_config" "1" \
"$(qdb $db "SELECT count(*) FROM coldfront.partition_config WHERE table_name='stockev';")"
else
fail "TC-151: register failed on stock PG"; tail -3 $TMPD/stock-reg.log
fi

# The tsvector column TC-150 refuses on a tiered node registers here: with no
# extension there is no cold tier for a column type to be wrong about.
qdb $db "CREATE TABLE public.stockfts (id bigint NOT NULL, ts timestamptz NOT NULL, body text,
tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(body,''))) STORED,
PRIMARY KEY (id, ts)) PARTITION BY RANGE (ts);" >/dev/null
if "$PARTITIONER" register --dsn "$dsn" --table stockfts --period monthly \
--hot-period "30 days" >$TMPD/stock-fts.log 2>&1; then
pass "TC-151: no extension, no cold tier, no column type check"
else
fail "TC-151: stock PG must not type-check a column"; tail -3 $TMPD/stock-fts.log
fi

# And the job itself: a reconcile run creates the forward window.
printf 'postgres: { dsn: "%s" }\n' "$dsn" > $TMPD/stock.yaml
if "$PARTITIONER" --config $TMPD/stock.yaml >$TMPD/stock-run.log 2>&1; then
assert_ne "TC-151: reconcile created partitions" "0" \
"$(qdb $db "SELECT count(*) FROM pg_inherits WHERE inhparent='public.stockev'::regclass;")"
else
fail "TC-151: reconcile failed on stock PG"; tail -5 $TMPD/stock-run.log
fi

"$PARTITIONER" remove --dsn "$dsn" --table stockev >/dev/null 2>&1
assert_eq "TC-151: remove unregistered it" "0" \
"$(qdb $db "SELECT count(*) FROM coldfront.partition_config WHERE table_name='stockev';")"
q "$HOST" "DROP DATABASE $db;" >/dev/null
}

# ───────────────────────────────────────────────────────────────────────────
# Story — TC-071: a PARTITION BY RANGE (col1, col2) table is rejected at
# archive time with a clear error. The PK check at register time passes (the
Expand Down Expand Up @@ -4158,6 +4218,50 @@ story_bad_source_names_rejected() {
q "$HOST" "DROP TABLE IF EXISTS public._mytest, public.${long53}, public.${long54} CASCADE;" >/dev/null 2>&1
}

# ───────────────────────────────────────────────────────────────────────────
# Story: a column type the cold tier cannot store is refused at register, not
# hours later on the first archive cycle. tsvector is the canonical case:
# PostgreSQL's full-text pattern is a generated tsvector column, and Iceberg has
# no type for it. Registration goes through the extension's own type map, the one
# every cold write already uses, so what registers is what an archive cycle
# accepts. Partition-only management is untouched, since nothing about such a
# table ever reaches Iceberg.
# ───────────────────────────────────────────────────────────────────────────
story_unmappable_column_rejected() {
step "TC-150: column type with no Iceberg mapping rejected at register"
local dsn="host=${DB_IP} port=5432 dbname=coldfront user=coldfront password=coldfront sslmode=disable"
q "$HOST" "CREATE TABLE IF NOT EXISTS public.tc150_fts (
id bigint GENERATED ALWAYS AS IDENTITY, ts timestamptz NOT NULL, body text,
tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(body,''))) STORED,
PRIMARY KEY (id, ts)
) PARTITION BY RANGE (ts);" >/dev/null
assert_eq "TC-150: fixture carries a generated tsvector column" "tsvector" \
"$(q "$HOST" "SELECT format_type(atttypid, atttypmod) FROM pg_attribute
WHERE attrelid='public.tc150_fts'::regclass AND attname='tsv';")"
assert_register_rejected "TC-150: register named the unstorable type" \
tc150_fts "PG type tsvector has no Iceberg-compatible mapping"
# The partitioner writes tiered rows through the same gate, and the archiver
# is what later reads them, so the refusal must not be dodgeable by
# registering from the binary that owns no cold tier.
if "$PARTITIONER" register --dsn "$dsn" --table tc150_fts \
--period monthly --hot-period "30 days" >$TMPD/unmappable-part.log 2>&1; then
fail "TC-150: partitioner accepted a tiered row the archiver cannot process"
else
assert_contains "TC-150: partitioner refused it too" "tsvector" "$(cat $TMPD/unmappable-part.log)"
fi
# The same table is fine partition-only: --dry-run validates everything and
# writes nothing, so the acceptance is asserted without leaving a config row.
if "$ARCHIVER" register --config $TMPD/archiver.yaml --table tc150_fts \
--period monthly --retention "5 years" --dry-run >$TMPD/unmappable-po.log 2>&1; then
pass "TC-150: the same table validates partition-only (no cold tier, no type check)"
else
fail "TC-150: partition-only must be unaffected, see $TMPD/unmappable-po.log"; tail -3 $TMPD/unmappable-po.log
fi
assert_eq "TC-150: still nothing registered after both binaries tried" "0" \
"$(q "$HOST" "SELECT count(*) FROM coldfront.partition_config WHERE table_name='tc150_fts';")"
q "$HOST" "DROP TABLE IF EXISTS public.tc150_fts CASCADE;" >/dev/null 2>&1
}

# ───────────────────────────────────────────────────────────────────────────
# Story — TC-114: TEMPORARY table invisible to archiver — register fails with
# a "does not exist" error. TEMP tables are session-local; the archiver
Expand Down Expand Up @@ -4783,6 +4887,7 @@ if [ "$MODE" = "tiered" ]; then
story_partitioner_set_retention # TC-052: set --retention updates partition_config
story_partitioner_disable_enable # TC-053: disable silently excludes; enable restores
story_partitioner_remove # TC-054: remove unregisters; table intact
story_partitioner_stock_pg # TC-151: standalone partitioner on stock PG (no extension)
story_composite_key_rejected # TC-071: RANGE (col1, col2) rejected at archive time
story_iceberg_metadata # TC-043: cold data confirmed via Parquet metadata
story_pg_dump_no_secrets # TC-058: storage secret not in pg_dump
Expand All @@ -4795,6 +4900,7 @@ if [ "$MODE" = "tiered" ]; then
story_unlogged_rejected # TC-113: UNLOGGED rejected at register
story_case_collision_rejected # TC-141: name differing only by case rejected at register
story_bad_source_names_rejected # TC-139/TC-142: leading underscore and over-long names rejected
story_unmappable_column_rejected # TC-150: column type with no Iceberg mapping rejected at register
story_quoted_table_names # TC-140/TC-143/TC-144: dot, hyphen, space in table name
story_temp_rejected # TC-114: TEMP table invisible to archiver
story_list_partition_rejected # TC-115: LIST partition accepted at register; rejected at archive
Expand Down
20 changes: 19 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ The following table describes each component, its role, and its license:
|-----------|------|---------|
| PostgreSQL 16+ | Heap storage; range partitioning for the tiered hot tier. Works uniformly on PG 16, 17, and 18 - the cold-tier secret is a DuckDB persistent secret loaded at instance init, with no version-gated mechanism. | PostgreSQL |
| pg_duckdb | DuckDB in-process. Iceberg read + write. Analytics. pg_duckdb 1.5.4 (PR #1025). The `duckdb-iceberg` carries the bakery-aware commit-refresh patch (async parquet overlap, no 409); see [Cold-write strategy](#cold-write-strategy-stock-vs-patched-duckdb-iceberg). | MIT |
| coldfront | PGXS C extension. `post_parse_analyze_hook` rewrites INSERT/UPDATE/DELETE on registered views to the correct tier; `ProcessUtility_hook` handles DDL; the hook lazily ATTACHes the Iceberg catalog on the first query touching a tiered view. | PostgreSQL |
| coldfront | PGXS C extension. `post_parse_analyze_hook` rewrites INSERT/UPDATE/DELETE 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 | Iceberg REST catalog. Single Rust binary. | Apache 2.0 |
| S3-compatible store | Any: SeaweedFS, MinIO, GCS, Azure Blob, etc. | Varies |
| Archiver (tiered mode) | Go binary, invoked by cron. Thin SQL orchestrator that moves rows hot→cold. | PostgreSQL |
Expand Down Expand Up @@ -258,6 +258,24 @@ writes funnel through the `_exec_iceberg_with_claim` chokepoint (see
[Concurrency](#concurrency-and-pgedge-spock-deployments)). 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
[usage.md → Supported column types](usage.md#supported-column-types)). 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 carries 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
[usage.md → Supported column types](usage.md#supported-column-types)).

The following table maps each operation to its interface and routing path:

| Operation | Interface | Routed via |
Expand Down
40 changes: 32 additions & 8 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,12 @@ one command rejects cannot be added by another. Registration fails when:
- the name leaves no room for the generated partition suffix: 53
characters for monthly and 50 for daily, within PostgreSQL's 63-byte
identifier limit.
- a tiered table carries a column whose type has no Iceberg equivalent
(see [Supported column types](#supported-column-types)). Every column
goes through the same type map the cold tier itself uses, so the answer
comes back at the prompt rather than hours later from cron.
Partition-only tables are exempt: nothing about them reaches Iceberg,
so their column types are PostgreSQL's business alone.

Registration validates the table as it is at that moment, not
continuously. Adding a `DEFAULT` partition to an already-registered table
Expand Down Expand Up @@ -637,9 +643,10 @@ The following PostgreSQL column types are supported:
`interval`

Anything else (unbounded `numeric`, `xml`, `tsvector`, range/multirange
types, custom enums, arrays, composite types) is rejected at
table-creation time. We refuse silent fallback to `varchar` - losing
precision/identity is worse than no support.
types, custom enums, arrays, composite types) is rejected when a tiered
table is registered, and when a decoupled table is created. We refuse
silent fallback to `varchar` - losing precision/identity is worse than
no support.

`char(N)` is stored and read as `varchar`. The data round-trips
losslessly: values, comparisons, and `length()` match a hot PG table,
Expand All @@ -653,8 +660,14 @@ native primitive). On read, `interval` is view-cast back to the rich PG
type; `json` and `jsonb` surface as DuckDB's `json` (the equivalent of
PG's `jsonb`), not the rich PG `jsonb` type, because Iceberg-backed reads
run entirely in DuckDB. Queries like `data->>'key'` and `data->'key'`
work, and ColdFront translates the `::jsonb` cast and `jsonb_array_length`
on read. The jsonb-only operators (`?`, `@>`, `<@`, `#>`, `#>>`) and most
work, and ColdFront translates the `::jsonb` cast, `jsonb_array_length`,
`jsonb_build_object` / `jsonb_agg` (and their `json_` twins, which DuckDB
also lacks) and `date_bin` on read: the builders become the `concat` /
`to_json` / `array_agg` form both engines evaluate identically (the result
is JSON-equal to jsonb's rendering, in compact form and in argument
order), and `date_bin` becomes DuckDB's `time_bucket`, which takes the
same arguments and agrees on every fixed-width bucket. The jsonb-only
operators (`?`, `@>`, `<@`, `#>`, `#>>`) and most
jsonb functions (`jsonb_typeof`, `jsonb_extract_path`,
`jsonb_extract_path_text`, `jsonb_set`, `jsonb_path_*`, `jsonb_each`,
`jsonb_object_keys`) are not supported on tiered or decoupled data:
Expand All @@ -666,6 +679,17 @@ iceberg-only view, which pg_duckdb plans entirely in DuckDB. Ordinary
(non-tiered) PostgreSQL tables are untouched by ColdFront and keep full
jsonb support, as does the hot partition table itself.

Bound parameters (`$1` from a prepared statement, a driver's extended
protocol, or a plpgsql variable) work in such reads. DuckDB types most
placeholders from their context (`ts > $1`); one that is a direct
argument of a DuckDB function with several overloads (`time_bucket`'s
origin, so `date_bin`'s) or any argument of a table function
(`generate_series`) it cannot, so ColdFront plans such a read from the
bound values on every execution instead of caching a generic plan. Under
`plan_cache_mode = force_generic_plan` those reads cannot run: the forced
generic plan has no values and fails with `only works with DuckDB
execution`.

A hot-only read is the exception. When a `SELECT` reads a tiered view
directly (no join, CTE, or sub-query) and its `WHERE` provably restricts
to the hot tier, ColdFront rewrites it to read the hot partition table in
Expand All @@ -686,9 +710,9 @@ index/compare them; cast on the hot side only if needed).
Keep the following caveats in mind when running either mode:

- **`jsonb` reads**: surface as `json`. The `->`/`->>` operators work,
and ColdFront translates the `::jsonb` cast and `jsonb_array_length`;
other jsonb operators and functions are unsupported on cold or
cross-tier reads. A hot-only read of the view runs in PostgreSQL with
and ColdFront translates the `::jsonb` cast, `jsonb_array_length`,
`jsonb_build_object` / `jsonb_agg` and `date_bin`; other jsonb
operators and functions are unsupported on cold or cross-tier reads. A hot-only read of the view runs in PostgreSQL with
full jsonb (see Supported column types).
- **Cross-tier isolation**: a long-running `SELECT` that touches the
Iceberg side multiple times within one transaction may see writes from
Expand Down
5 changes: 4 additions & 1 deletion extension/coldfront/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ 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 drop_iceberg_table vector_centroids vector_type_map vector_cold_render vector_param_render vector_ops vector_probe vector_status vector_assign vector_multicolumn
schema_collision drop_iceberg_table \
read_date_bin read_json_builders read_param_fold registry_snapshot \
cold_write_json_agg \
vector_centroids vector_type_map vector_cold_render vector_param_render vector_ops vector_probe vector_status vector_assign vector_multicolumn
REGRESS_OPTS = --inputdir=test --outputdir=test

PG_CONFIG ?= pg_config
Expand Down
Loading