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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ jobs:
| sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null
sudo timeout 300 apt-get -o Acquire::Retries=5 update
sudo timeout 600 apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} postgresql-${{ matrix.pg }}-partman \
liblz4-dev libzstd-dev zlib1g-dev
# NOT cached, deliberately. The suites job caches because it runs a matrix for
# an hour; a second copy of that cache-key derivation is a thing that goes
Expand Down Expand Up @@ -580,7 +580,7 @@ jobs:
run: |
set -euo pipefail
v=$(apt-cache policy \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} postgresql-${{ matrix.pg }}-partman \
liblz4-dev libzstd-dev zlib1g-dev python3-pip \
| awk '/Candidate:/ {print $2}' | sort)
# $ImageOS, from the shell, where it actually has a value. Reported
Expand Down Expand Up @@ -628,7 +628,7 @@ jobs:
mkdir -p "$HOME/apt-archives/partial"
sudo timeout 600 apt-get -o Acquire::Retries=5 \
-o Dir::Cache::archives="$HOME/apt-archives" install -y --no-install-recommends \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} postgresql-${{ matrix.pg }}-partman \
liblz4-dev libzstd-dev zlib1g-dev python3-pip 2>&1 | tee /tmp/apt-install.log
# The Arrow and Parquet suites use pyarrow as an independent reader and
# skip themselves without it, which would be a silent loss of coverage.
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ jobs:
run: |
set -euo pipefail
v=$(apt-cache policy \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} postgresql-${{ matrix.pg }}-partman \
liblz4-dev libzstd-dev zlib1g-dev python3-pip \
| awk '/Candidate:/ {print $2}' | sort)
# $ImageOS, from the shell, where it actually has a value. Reported
Expand Down Expand Up @@ -156,7 +156,7 @@ jobs:
mkdir -p "$HOME/apt-archives/partial"
sudo timeout 600 apt-get -o Acquire::Retries=5 \
-o Dir::Cache::archives="$HOME/apt-archives" install -y --no-install-recommends \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} postgresql-${{ matrix.pg }}-partman \
liblz4-dev libzstd-dev zlib1g-dev python3-pip 2>&1 | tee /tmp/apt-install.log
# pyarrow for root (the suites run under sudo); the silent-skip trap the
# per-PR workflow documents applies here too.
Expand Down Expand Up @@ -743,7 +743,7 @@ jobs:
| sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null
sudo timeout 300 apt-get -o Acquire::Retries=5 update
sudo timeout 600 apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }} postgresql-${{ matrix.pg }}-partman \
liblz4-dev libzstd-dev zlib1g-dev
# NOT cached, deliberately. The suites job caches because it runs a matrix for
# an hour; a second copy of that cache-key derivation is a thing that goes
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@ true until the next version shipped.
showed a vacuous arm: "under a tenth of the table" passed on empty SQL, because
a query that reads nothing reads under a tenth of anything. It now requires at
least one block read.
- `docs/how-to.md` documents managing time partitions with pg_partman (#1291), and
`test/partman_columnar.sh` with its pytest twin checks every statement the
section makes. Measured with pg_partman 5.4.1 on PostgreSQL 15 to 19:

without default_table_access_method every partition is heap, even on 17+
where the parent carries USING pgcolumnar
with it every partition is columnar, including the
DEFAULT one and those made ahead; rows
land there and read back as heap does
retention partitions dropped, no pgcolumnar
storage or row-group row left behind
run_maintenance holds ACCESS EXCLUSIVE on the parent

The first arm pins the trap rather than the recipe, so a pg_partman that starts
honouring the parent's access method turns it red instead of leaving the warning
stale. Without the drop hook's catalog cleanup, the retention arm reads 20
orphaned storage rows. pg_partman is an external extension, so a box without it
FAILS the suite unless `PGC_ALLOW_MISSING_PG_PARTMAN=1` is set. The CI suites
and pytest jobs now install `postgresql-N-partman` from PGDG, in the cache key
as well as the install.

### Changed

Expand Down
54 changes: 54 additions & 0 deletions docs/how-to.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,60 @@ declared retention raises an error rather than doing nothing.
`pgcolumnar.reset_options` has no argument for the retention. To stop expiring,
set `ttl_interval` to a span no row will reach.

## Manage time partitions with pg_partman

pg_partman can create and drop columnar partitions. It needs one setting to do
so, and without it the partitions it makes are not columnar.

Before you start: install pg_partman (PGDG ships it as `postgresql-N-partman`),
and create it in its own schema.

```sql
CREATE SCHEMA partman;
CREATE EXTENSION pg_partman SCHEMA partman;
```

1. Make columnar the default access method where pg_partman runs. A database
setting is the simplest place.

```sql
ALTER DATABASE mydb SET default_table_access_method = 'pgcolumnar';
```

2. Create the parent and hand it to pg_partman.

```sql
CREATE TABLE metrics (ts timestamptz NOT NULL, v float8)
PARTITION BY RANGE (ts);
SELECT partman.create_parent('public.metrics', 'ts', '1 day', p_premake := 4);
```

3. Set the retention, and run maintenance on a schedule.

```sql
UPDATE partman.part_config
SET retention = '90 days', retention_keep_table = false
WHERE parent_table = 'public.metrics';
SELECT partman.run_maintenance('public.metrics');
```

**Without step 1, every partition is a heap table.** pg_partman does not take the
access method from the parent. That holds even on PostgreSQL 17 and later, where
the parent itself can carry `USING pgcolumnar`. Changing the access method of
pg_partman's template table does not help either. Measured with pg_partman 5.4.1
on PostgreSQL 15 to 19.

**The setting has a cost.** Every table created in that database becomes
columnar unless it names another access method.

**Maintenance locks the parent.** `run_maintenance` holds `ACCESS EXCLUSIVE` on
the parent table while it runs, so queries on the table wait for it. pg_partman
also creates a `DEFAULT` partition.

When a partition is dropped by retention, its pgcolumnar catalog rows go with it.

`test/partman_columnar.sh` checks each of these statements.

## Keep tables optimized automatically

A background daemon can compact and recluster tables on a schedule. The threshold
Expand Down
12 changes: 12 additions & 0 deletions test/check_ledger.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -1759,6 +1759,18 @@ parallel_scan_cost parallel_scan_cost premise: the serial plan has no Gather 15;
parallel_scan_cost parallel_scan_cost premise: the serial plan is a columnar scan 15;16;17;18;19 never -
parallel_scan_cost parallel_scan_cost premise: the table holds every inserted row 15;16;17;18;19 never -
parallel_scan_cost parallel_scan_cost the leader-participation branch changes the divisor 15;16;17;18;19 never -
partman_columnar partman_columnar and no row-group row behind 15;16;17;18;19 2026-10-12 the drop hook stops deleting row_group rows, so they outlive their deleted storage row
partman_columnar partman_columnar and read back as heap does 15;16;17;18;19 2026-10-12 a sequential scan drops the last row group from its list, so every one-group partition reads empty
partman_columnar partman_columnar and the rows land in columnar partitions 15;16;17;18;19 2026-10-12 a sequential scan drops the last row group from its list, so every one-group partition reads empty
partman_columnar partman_columnar premise: pg_partman creates a DEFAULT partition, as docs/how-to.md says 15;16;17;18;19 never -
partman_columnar partman_columnar premise: pg_partman is installed in this database 15;16;17;18;19 never -
partman_columnar partman_columnar premise: retention dropped partitions 15;16;17;18;19 never -
partman_columnar partman_columnar premise: run_maintenance holds an exclusive lock on the parent, as docs/how-to.md says 15;16;17;18;19 never -
partman_columnar partman_columnar premise: the partitions maintenance creates ahead are columnar too 15;16;17;18;19 never -
partman_columnar partman_columnar premise: the rows went in 15;16;17;18;19 2026-10-12 a sequential scan drops the last row group from its list, so every one-group partition reads empty
partman_columnar partman_columnar premise: with default_table_access_method every partition pg_partman makes is columnar 15;16;17;18;19 never -
partman_columnar partman_columnar premise: without default_table_access_method pg_partman makes heap partitions 15;16;17;18;19 never -
partman_columnar partman_columnar retention leaves no pgcolumnar storage row behind 15;16;17;18;19 2026-10-12 the drop hook stops deleting the storage row, so a partition pg_partman drops leaves it behind
projection_parallel projection_parallel a covering projection can be a parallel scan 15;16;17;18;19 2026-09-18 -
projection_parallel projection_parallel a page cost that makes I/O dominate costs the parallel covering path 15;16;17;18;19 2026-09-23 the partial covering total amortises I/O as well as CPU: (ioRunProj + cpuRunProj) / divisor
projection_parallel projection_parallel a parallel covering projection returns the covering rows once 15;16;17;18;19 2026-10-12 a parallel worker never wires the shared row-group counter, so every worker scans every group
Expand Down
20 changes: 19 additions & 1 deletion test/check_ledger_budget.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1258,4 +1258,22 @@ suites_not_covered 247
# onto #1303's ledger, not merged by hand.
#
# never 1660 + 4 = 1664
checks_never_observed_red 1664
# #1291 (OffgridwithJD): 1664 -> 1671. partman_columnar enters the ledger with
# 12 checks from five-major runs, replayed from the saved logs onto this base.
# Every arm is dated, each by its own mutation:
#
# drop hook keeps the storage row retention leaves no pgcolumnar storage row behind
# drop hook keeps row_group rows and no row-group row behind
# the scan drops its last group and read back as heap does,
# and the rows land in columnar partitions
# (and the premise "the rows went in")
#
# The 7 still `never` are premises: the access method core gives a partition,
# pg_partman's DEFAULT partition and its parent lock. No change to this
# repository can redden them (the owner's #1236 rule).
#
# ledger rows 1883 + 12 = 1895
# never 1664 + 7 = 1671
# dated 219 + 5 = 224
# 1671 + 224 == 1895
checks_never_observed_red 1671
121 changes: 121 additions & 0 deletions test/partman_columnar.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
#
# pg_partman manages columnar partitions -- with one setting, and not without it (#1291).
#
# The owner's question on #1291 was whether pg_partman works with columnar
# partitions on every major; if it does, we document AND TEST that. Measured with
# pg_partman 5.4.1 on PG 15..19, and asserted here:
#
# OUT OF THE BOX, every partition pg_partman makes is HEAP -- even on 17+, where
# the parent itself carries USING pgcolumnar. pg_partman does not take the access
# method from the parent, and switching its template table does not help either.
#
# WITH default_table_access_method = pgcolumnar where its maintenance runs, every
# partition it makes is columnar, the rows land there, and retention drops
# partitions leaving no pgcolumnar catalog row behind.
#
# run_maintenance holds ACCESS EXCLUSIVE on the parent. That is pg_partman's
# behaviour, not ours; it is asserted because docs/how-to.md tells users so, and a
# pg_partman that changed it should turn this red rather than leave the doc wrong.
#
# The first check pins the trap rather than the recipe, on purpose: it is the reason
# the documentation exists, and a pg_partman that started honouring the parent's
# access method should be noticed, not silently make the warning stale.
#
# CHECKS NO CHANGE TO THIS REPOSITORY CAN REDDEN ARE PREMISES (the owner's rule,
# #1236). Which access method a partition gets, whether pg_partman makes a DEFAULT
# partition, and which lock its maintenance holds are core's and pg_partman's
# behaviour. They are still asserted -- a pg_partman release that changes one
# turns it red, and the document with it -- but as premises, not as arms of this
# repository. The arms are what pgcolumnar decides: what a scan returns, and what
# the drop hook leaves in the catalogs.
#
# pg_partman is an external extension. A box without it FAILS this suite (lib.sh's
# rule for every capability but pyarrow); set PGC_ALLOW_MISSING_PG_PARTMAN=1 to make
# that a named skip. CI installs postgresql-N-partman from PGDG.
#
# Usage: test/partman_columnar.sh [PG_CONFIG]

set -uo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
pgc_setup "${1:-/usr/local/pg17/bin/pg_config}"

q "CREATE EXTENSION IF NOT EXISTS pgcolumnar;" >/dev/null
if [ "$(q "SELECT count(*) FROM pg_available_extensions WHERE name = 'pg_partman';")" != 1 ]; then
pgc_skip pg_partman "pg_partman is not installed for this major"
fi
q "CREATE SCHEMA partman; CREATE EXTENSION pg_partman SCHEMA partman;" >/dev/null
check_text "premise: pg_partman is installed in this database" \
"$(q "SELECT count(*) FROM pg_extension WHERE extname = 'pg_partman';")" "1"
echo "-- pg_partman $(q "SELECT extversion FROM pg_extension WHERE extname = 'pg_partman';")"

# am_counts PARENT -> "am=n am=n" over the partitions pg_partman made
am_counts() {
q "SELECT coalesce(string_agg(amname || '=' || n, ' ' ORDER BY amname), 'none')
FROM (SELECT a.amname, count(*) n FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid JOIN pg_am a ON a.oid = c.relam
WHERE i.inhparent = '$1'::regclass GROUP BY 1) x;"
}
# On 17+ a partitioned parent can carry USING; give it to them where it can be
# given, so the first arm shows pg_partman ignores even that.
using=""; [ "$PGC_MAJOR" -ge 17 ] && using="USING pgcolumnar"

# ---- 1. the trap: no setting, heap partitions -------------------------------
q "CREATE TABLE plain_m (ts timestamptz NOT NULL, v int) PARTITION BY RANGE (ts) $using;" >/dev/null
q "SELECT partman.create_parent('public.plain_m', 'ts', '1 day', p_premake := 2,
p_start_partition := (date_trunc('day', now()) - interval '2 days')::text);" >/dev/null
check "premise: without default_table_access_method pg_partman makes heap partitions" \
"$(am_counts plain_m | sed -E 's/=[0-9]+//g')" "heap"

# ---- 2. the recipe -----------------------------------------------------------
q "ALTER DATABASE \"$PGC_DB\" SET default_table_access_method = 'pgcolumnar';" >/dev/null
q "CREATE TABLE ts_m (ts timestamptz NOT NULL, v int) PARTITION BY RANGE (ts) $using;" >/dev/null
q "SELECT partman.create_parent('public.ts_m', 'ts', '1 day', p_premake := 3,
p_start_partition := (date_trunc('day', now()) - interval '40 days')::text);" >/dev/null
check_num "premise: pg_partman creates a DEFAULT partition, as docs/how-to.md says" \
"$(q "SELECT count(*) FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'ts_m'::regclass
AND pg_get_expr(c.relpartbound, c.oid) = 'DEFAULT';")" "1"
check "premise: with default_table_access_method every partition pg_partman makes is columnar" \
"$(am_counts ts_m)" "pgcolumnar=$(q "SELECT count(*) FROM pg_inherits WHERE inhparent = 'ts_m'::regclass;")"

q "CREATE TABLE ts_h (ts timestamptz, v int) USING heap;" >/dev/null
for t in ts_m ts_h; do
q "INSERT INTO $t SELECT date_trunc('day', now()) - interval '40 days' + g * interval '17 min', g
FROM generate_series(1, 3000) g;" >/dev/null
done
check_num "premise: the rows went in" "$(q "SELECT count(*) FROM ts_m;")" "3000"
check "and the rows land in columnar partitions" \
"$(q "SELECT string_agg(DISTINCT a.amname, ',') FROM ts_m t
JOIN pg_class c ON c.oid = t.tableoid JOIN pg_am a ON a.oid = c.relam;")" "pgcolumnar"
# Every row, in order, hashed: an unfiltered count/sum/min/max is answered from
# zone maps and would not exercise the scan at all.
check "and read back as heap does" \
"$(q "SELECT md5(string_agg(ts::text || ':' || v, ',' ORDER BY ts, v)) FROM ts_m;")" \
"$(q "SELECT md5(string_agg(ts::text || ':' || v, ',' ORDER BY ts, v)) FROM ts_h;")"

# ---- 3. maintenance: ahead, retention, and the parent lock ------------------
q "UPDATE partman.part_config SET premake = 6, retention = '20 days',
retention_keep_table = false WHERE parent_table = 'public.ts_m';" >/dev/null
st_before="$(q "SELECT count(*) FROM pgcolumnar.storage;")"
kids_before="$(q "SELECT count(*) FROM pg_inherits WHERE inhparent = 'ts_m'::regclass;")"
lock="$(q "BEGIN; SELECT partman.run_maintenance('public.ts_m');
SELECT coalesce(string_agg(DISTINCT mode, ','), 'none') FROM pg_locks
WHERE relation = 'ts_m'::regclass AND pid = pg_backend_pid(); COMMIT;" | grep -vE '^(BEGIN|COMMIT)$|^$' | tail -1)"
kids_after="$(q "SELECT count(*) FROM pg_inherits WHERE inhparent = 'ts_m'::regclass;")"
echo "-- partitions $kids_before -> $kids_after, storage rows $st_before -> $(q "SELECT count(*) FROM pgcolumnar.storage;")"
dropped=$((kids_before - kids_after))
check "premise: retention dropped partitions" \
"$([ "$dropped" -ge 1 ] && echo dropped || echo "dropped $dropped")" "dropped"
check "premise: the partitions maintenance creates ahead are columnar too" \
"$(am_counts ts_m)" "pgcolumnar=$kids_after"
check_num "retention leaves no pgcolumnar storage row behind" \
"$(q "SELECT count(*) FROM pgcolumnar.storage s
WHERE NOT EXISTS (SELECT 1 FROM pg_class c WHERE c.oid = s.relation_oid);")" "0"
check_num "and no row-group row behind" \
"$(q "SELECT count(*) FROM pgcolumnar.row_group r
WHERE NOT EXISTS (SELECT 1 FROM pgcolumnar.storage s WHERE s.storage_id = r.storage_id);")" "0"
check "premise: run_maintenance holds an exclusive lock on the parent, as docs/how-to.md says" \
"$lock" "AccessExclusiveLock"

pgc_summary
Loading
Loading