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
46 changes: 30 additions & 16 deletions test/check_ledger.tsv

Large diffs are not rendered by default.

28 changes: 27 additions & 1 deletion test/check_ledger_budget.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2533,4 +2533,30 @@ suites_not_covered 230
# never 2344 + 1 = 2345
# dated 767 + 3 = 770
# 2345 + 770 == 3115
checks_never_observed_red 2345
#
# #1341 item 75, the changelog names recluster with expire, compact_rewrite and
# VACUUM as recomputing the column extent, and only the other three had arms:
# 2345 -> 2346. column_extent gains 3 checks from five-major runs (42/42 on each
# of 15-19). Two are dated on PG17 by the existing mutation "PgColumnarRetireGroup
# without marking the storage for a recompute", which also re-dates the four
# arms it already held. "recluster rewrites the table's row groups" arrives never.
#
# ledger rows 3115 + 3 = 3118
# never 2345 + 1 = 2346
# dated 770 + 2 = 772
# 2346 + 772 == 3118
#
# #1341, tier_partition and partition_create_ahead on the caller's own privileges
# (OffgridwithJD): 2346 -> 2352. Eleven new checks, all in already-covered suites.
# Five are dated, by each function made SECURITY DEFINER: tier_partition's refusal,
# the partition left local, no foreign table and no export file (four), and
# partition_create_ahead's refusal (one). The two owner controls run on tables of
# their own, so neither mutation reddens them; they and four premises arrive never.
# Five-major runs: tier_partition 79/79 (80 on 18-19, which run the virtual column)
# and partition_lifecycle 64/64 on 15-19.
#
# ledger rows 3118 + 11 = 3129
# never 2346 + 6 = 2352
# dated 772 + 5 = 777
# 2352 + 777 == 3129
checks_never_observed_red 2352
15 changes: 15 additions & 0 deletions test/column_extent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,21 @@ q "CREATE TABLE vr (ts timestamptz, v int) USING pgcolumnar;
q "VACUUM vr;" >/dev/null
check "after VACUUM retires a fully deleted group the extent is the live rows'" "$(ext vr ts)" "$(truth vr ts)"

# recluster rewrites every group too, and the changelog names it with the three
# above (#1341 item 75): the rows a DELETE removed are not written back, so its
# extent must be the live rows', not wider by rows no longer stored. The deleted
# rows hold the minimum, so a stale extent keeps it.
q "CREATE TABLE rc (ts timestamptz, v int) USING pgcolumnar;
SELECT pgcolumnar.set_options('rc', stripe_row_limit => 1000, chunk_group_row_limit => 1000);
INSERT INTO rc SELECT '2026-01-01'::timestamptz + g * interval '1 min', (g * 7919) % 3001 FROM generate_series(1, 3000) g;
DELETE FROM rc WHERE ts < '2026-01-01'::timestamptz + interval '400 min';" >/dev/null
rc_before="$(ext rc ts)"
reclustered="$(q "SELECT pgcolumnar.recluster('rc', 'ts', 'v');")"
check "recluster rewrites the table's row groups" "$([ "${reclustered:-0}" -ge 1 ] && echo yes || echo "no ($reclustered)")" "yes"
check "after recluster the extent is the live rows'" "$(ext rc ts)" "$(truth rc ts)"
check "and it did move: the deleted rows held the old minimum" \
"$([ -n "$rc_before" ] && [ "$rc_before" != "$(ext rc ts)" ] && echo yes || echo "no ($rc_before)")" "yes"

# ---- no complete extent where the zone maps cannot give one ----------------------
q "CREATE TABLE ac (ts timestamptz) USING pgcolumnar;
SELECT pgcolumnar.set_options('ac', stripe_row_limit => 1000, chunk_group_row_limit => 1000);
Expand Down
34 changes: 34 additions & 0 deletions test/partition_lifecycle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,40 @@ check "a parent with a DEFAULT partition is refused" \
check "and nothing was created for it" \
"$(kids td)|$(q "SELECT count(*) FROM pg_proc WHERE proname = 'partition_create_ahead';")" "1|1"

# ---- the caller's own privileges (#1341) -------------------------------------
#
# partition_create_ahead is not SECURITY DEFINER, so its CREATE TABLE ... PARTITION OF
# runs as the caller, and core refuses a role that does not own the parent. Made
# SECURITY DEFINER, it would run as the extension's owner and let any caller add
# partitions, and the access-exclusive lock that comes with them, to a table it does
# not own. Both roles hold USAGE and CREATE on the schema, so ownership is all that
# separates them.
for r in pl_own pl_peer; do
psql_run "DROP ROLE IF EXISTS $r; CREATE ROLE $r LOGIN NOSUPERUSER;
GRANT USAGE, CREATE ON SCHEMA public TO $r; GRANT USAGE ON SCHEMA pgcolumnar TO $r;"
done
as_role() { # as_role ROLE SQL -> the single value SQL returns as ROLE, or the SQLSTATE it fails with
local out
out=$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U "$1" -d "$PGC_DB" \
-X -At -v ON_ERROR_STOP=1 -v VERBOSITY=sqlstate -c "$2" 2>&1)
case "$out" in
*ERROR:*|*FATAL:*) sed -n 's/^\(ERROR\|FATAL\): *\([0-9A-Z]\{5\}\).*/\2/p' <<<"$out" | head -1 ;;
*) printf '%s\n' "$out" | tail -1 ;;
esac
}
# The owner's control has a parent of its own (pw2), so a refusal that let the peer
# through cannot make the control fail for the peer's reason.
q "CREATE TABLE pv (ts timestamptz NOT NULL, v int) PARTITION BY RANGE (ts); ALTER TABLE pv OWNER TO pl_own;
CREATE TABLE pw2 (ts timestamptz NOT NULL, v int) PARTITION BY RANGE (ts); ALTER TABLE pw2 OWNER TO pl_own;" >/dev/null
check "premise: pl_own owns the parent, and neither role is a superuser" \
"$(q "SELECT string_agg(DISTINCT pg_get_userbyid(relowner), ',') FROM pg_class WHERE oid IN ('pv'::regclass, 'pw2'::regclass);")|$(q "SELECT count(*) FROM pg_roles WHERE rolname IN ('pl_own', 'pl_peer') AND rolsuper;")" \
"pl_own|0"
check "premise: pl_peer can open a session" "$(as_role pl_peer "SELECT 1")" "1"
check "a role that does not own the parent is refused by its own privileges (42501), and creates nothing" \
"$(as_role pl_peer "SELECT pgcolumnar.partition_create_ahead('pv', '1 day', 2)")|$(kids pv)" "42501|0"
check "a non-superuser owner creates partitions ahead on its own table" \
"$(as_role pl_own "SELECT pgcolumnar.partition_create_ahead('pw2', '1 day', 2)")|$(kids pw2)" "3|3"

# ==== the drop half: partition_drop_plan (#1297) ================================
#
# partition_drop_plan(parent regclass, cutoff timestamptz) -> SETOF text
Expand Down
16 changes: 16 additions & 0 deletions test/pgc_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,16 @@ def read_ledger(path):
f"{path}:{n}: major {m!r} is neither a number nor "
f"{MAJOR_UNKNOWN!r}, so this row names no version it applies to")
muts = set() if f[5] == NONE else {m for m in f[5].split(";") if m}
# A PIECE WITH WHITESPACE AT AN END IS A FRAGMENT, not a mutation. The writer
# joins the set with a bare ";", so it never produces one; a text that held
# "; " does, when it is split on the ";" (12 rows on the shipped ledger, 8 of
# them from one recovery_minimal_wal mutation, re-sorted by every later merge).
for m in sorted(muts):
if m != m.strip():
raise LedgerError(
f"{path}:{n}: mutation {m!r} has whitespace at an end, so it is a "
f"fragment of one mutation's text split on ';'. Reword that mutation "
f"without ';' and keep the row's other mutations as they are.")
key = (f[0], f[1], f[2])
# A REPEATED KEY, REFUSED RATHER THAN OVERWRITTEN. This was
# `rows[key] = [...]`, so a duplicated row collapsed silently and the LAST line
Expand Down Expand Up @@ -530,6 +540,12 @@ def cmd_merge(args):
"--target names what a mutation killed, so it needs --mutation to say "
"which mutation. On its own it attributes nothing.")

if args.mutation and ";" in args.mutation:
raise LedgerError(
f"--mutation {args.mutation!r} holds ';', which separates mutations in the "
f"ledger's mutation column, so it would be stored as several. Reword it "
f"without ';' (', and' reads the same).")

if args.mutation:
failed = sorted({key for _p, seen in runs for key, vs in seen.items()
if any(v == "FAIL" for v, _m in vs)})
Expand Down
22 changes: 22 additions & 0 deletions test/pytest/TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2445,6 +2445,26 @@ Last-write-wins records the most recent attack rather than the catalogue the col
exists to become. One `--mutation` copied across several logs attributes a deliberate
change to failures it had nothing to do with, and is refused.

### `test_a_mutation_named_with_a_semicolon_is_refused_rather_than_split`

The mutation column is a `;`-separated set, written back sorted, so a `--mutation`
whose text held `; ` was stored as two mutations, one a fragment starting with a space.
Every later merge re-sorted it, and three branches in a row carried the same eight
recovery_minimal_wal rows as a change of their own. `merge` now refuses such a text with
rc 2 and leaves the ledger as it was; the same text with `, and` is recorded whole.

### `test_a_ledger_row_whose_mutation_holds_a_fragment_is_refused`

The writer joins the set with a bare `;`, so a piece with whitespace at an end is a
fragment of a split sentence, never a mutation it wrote. The reader refuses the ledger
by line. Two whole mutations separated by a bare `;` still read.

### `test_the_committed_ledger_names_no_mutation_fragment`

The committed ledger, read by the same reader. Red on the tree before this change at
line 1783, the first of the 12 rows it rewords: 8 from #1427's recovery_minimal_wal
mutation and 4 older harness_selftest rows.

### `test_a_mutation_with_two_targets_can_be_recorded_when_both_are_named`

A mutation with two GENUINE targets is ordinary: reverting the
Expand Down Expand Up @@ -6623,6 +6643,7 @@ Removal proofs, each red in both twins: the prototype's zone handling; `CREATE T
| --- | --- |
| `test_partition_create_ahead` | from nothing it reaches now plus the lead, columnar, contiguous and one width wide; a second call creates nothing, and asking further ahead creates only the difference; rows land in columnar partitions; the parent is never held ACCESS EXCLUSIVE; west of UTC, for both key types, partitions continue a hand-made one contiguously and a row at every boundary routes; east of UTC, a timestamp parent covers the local now and starts on the width's boundary; inputs that broke the first version (a generated column, a 63-character name, more than 1000 partitions, a MAXVALUE bound) are handled or refused before any DDL; a DEFAULT partition is refused and nothing is created |
| `test_partition_drop_plan` | the drop half: `partition_drop_plan` lists `DETACH ... CONCURRENTLY` and `DROP` for the partitions wholly before the cutoff, in range order, from partitions created newest first. A cutoff on an upper bound includes the partition, and one before them all plans nothing. Running the plan, each statement top-level, leaves the rest with their rows and drops the storage rows. A `timestamp` key with a `timestamp` cutoff plans the same in Tokyo and Denver, and a cutoff of the other type is refused (`42804`). MINVALUE is listed once past, and MAXVALUE never. Quoted schema and partition names run as returned. A sub-partitioned partition is planned whole and dropped with its children. A tiered partition is planned with `DROP FOREIGN TABLE`, and its Parquet file stays. A partition whose name holds the drop block's dollar-quote tag drops as planned. A detach cancelled in its second phase is planned as `FINALIZE` and dropped. The DEFAULT and non-timestamp parents are refused (`55000`) |
| `test_partition_create_ahead_runs_on_the_callers_own_privileges` | not SECURITY DEFINER: a role that does not own the parent, holding USAGE and CREATE on the schema, is refused `42501` and creates nothing; a non-superuser owner creates partitions ahead on a parent of its own |

## 94. test_approx_aggregates.py: hll and tdigest over columnar tables, the compatibility statement

Expand Down Expand Up @@ -6771,6 +6792,7 @@ On a private database, because foreign servers are per database. A local path st
| `test_tier_partition_moves_a_partition_exactly_once` | the move returns the rows moved; the parent's answers are the same; the cold range is a foreign partition with the same name and bound, reading the exported file; no pgcolumnar catalog rows are left; pruning keeps the ranges apart; an insert into the tiered range is refused; five refusals move nothing; the parent is held ACCESS EXCLUSIVE until commit, and a rollback leaves the rows local; a write in flight is exported; a fault after each of the five steps leaves the rows exactly once, still local |
| `test_tier_partition_refuses_what_it_cannot_move_and_survives_concurrency` | at each isolation level a row committed between the snapshot and the lock survives, and only READ COMMITTED moves; a UNIQUE parent and four kinds of dependent are refused with no file left, and the partition moves once they are gone; grants and comment carry over; a racing insert leaves every row once; lock_wait times a held parent out with nothing attached; the caller's lock_timeout survives; a retry over a failed attempt's file moves the partition |
| `test_tier_partition_moves_a_virtual_generated_column` | PostgreSQL 18+: a partition whose parent has a virtual generated column moves and reads back the same; below 18, `UNSUPPORTED_MAJOR`, listed in `expected_unrunnable.txt` |
| `test_tier_partition_runs_on_the_callers_own_privileges` | not SECURITY DEFINER: a role that does not own the table, holding the server-file role, USAGE on the server, CREATE on the schema and SELECT on the partition, is refused `42501` before any file is written, and the partition stays local and attached with no foreign table behind it; a non-superuser owner tiers a partition of its own table |

## 101. test_prepared_writes.py: a write prepared and finished in one session leaves the backend alive

Expand Down
9 changes: 7 additions & 2 deletions test/pytest/expected_tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,10 @@
# tests, the twin of selftest part 640, by collection over the NO_CLUSTER files.
# guard_tests 433 -> 435 for #1420's two arms in test_writes_wrote_rows.py (a setting
# on a watched connection or cursor is refused), by collection over NO_CLUSTER.
guard_tests 435
# guard_tests 435 -> 438 for three arms in test_mutation_ledger.py: a mutation text holding
# ';' is refused at merge, a ledger piece with whitespace at an end is refused, and the
# committed ledger holds none, by collection over NO_CLUSTER.
guard_tests 438

# The complement: tests that need the driver and a throwaway cluster. Until #1016 these ran
# in no CI job at all -- a quarter of the corpus, green when somebody ran them by hand and
Expand Down Expand Up @@ -702,4 +705,6 @@ guard_tests 435
# cluster_tests 586 -> 587 for #1341's test_serializable.py, one test, by collection
# cluster_tests 587 -> 588 for #1341's daemon TimeZone test in test_autovacuum_expire.py,
# by collection over the cluster files on this tree.
cluster_tests 588
# cluster_tests 588 -> 590 for #1341's caller-privilege tests in test_tier_partition.py and
# test_partition_lifecycle.py, one each, by collection over the cluster files on this tree.
cluster_tests 590
16 changes: 16 additions & 0 deletions test/pytest/test_column_extent.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,22 @@ def test_a_shrink_is_recomputed_never_guessed(pgc_own_db, expect):
expect.text(_ext(conn, "vr", "ts"), _truth(conn, "vr", "ts"),
"after VACUUM retires a fully deleted group the extent is the live rows'")

# recluster, named by the changelog with the three above (#1341 item 75): the
# deleted rows are not written back, and they held the minimum.
_run(conn, "CREATE TABLE rc (ts timestamptz, v int) USING pgcolumnar")
_small_groups(conn, "rc")
_run(conn, "INSERT INTO rc SELECT '2025-05-05'::timestamptz + g * interval '3 min', (g * 104729) % 2711"
" FROM generate_series(1, 3600) g",
"DELETE FROM rc WHERE ts < '2025-05-05'::timestamptz + interval '1500 min'")
before = _ext(conn, "rc", "ts")
reclustered = _one(conn, "SELECT pgcolumnar.recluster('rc', 'ts', 'v')")
expect.text("yes" if (reclustered or 0) >= 1 else f"no ({reclustered})", "yes",
"recluster rewrites the table's row groups")
after = _ext(conn, "rc", "ts")
expect.text(after, _truth(conn, "rc", "ts"), "after recluster the extent is the live rows'")
expect.text("yes" if before and before != after else f"no ({before})", "yes",
"and it did move: the deleted rows held the old minimum")


def test_no_complete_extent_where_the_zone_maps_cannot_give_one(pgc_own_db, expect):
conn = pgc_own_db
Expand Down
48 changes: 48 additions & 0 deletions test/pytest/test_mutation_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,54 @@ def test_the_mutation_column_accumulates_rather_than_overwriting(tmp_path, expec
"one mutation cannot be attributed across several runs at once")


def test_a_mutation_named_with_a_semicolon_is_refused_rather_than_split(tmp_path, expect):
"""The mutation column is a ";"-separated SET, written back sorted. A `--mutation`
whose text held "; " was stored as two "mutations", one a sentence fragment that
starts with a space, and the next merge sorted the fragment first. Measured on the
shipped ledger: 12 rows, 8 of them from one recovery_minimal_wal mutation (#1427),
re-sorted by three later branches until each looked like a change of its own.
"""
ledger = _w(tmp_path, "l.tsv", "")
red = _w(tmp_path, "r.log", RED)
out, rc = _run("merge", "--ledger", ledger, "--date", "2026-10-15",
"--mutation", "the sync not registered; this build then asserts", red)
expect.num(rc, 2, "a mutation whose text holds ';' is refused, not split in two")
expect.num(out.count("separates mutations"), 1, "and the refusal says why")
expect.num(len(_rows(ledger)), 0, "and the ledger is left as it was")

out, rc = _run("merge", "--ledger", ledger, "--date", "2026-10-15",
"--mutation", "the sync not registered, and this build then asserts", red)
expect.num(rc, 0, f"control: the same text without ';' is recorded: {out.strip()}")
expect.text({r[2]: r[5] for r in _rows(ledger)}["first check"],
"the sync not registered, and this build then asserts",
"and it is recorded whole, as one mutation")


def test_a_ledger_row_whose_mutation_holds_a_fragment_is_refused(tmp_path, expect):
"""A ";"-piece with whitespace at either end cannot be a mutation the writer
produced: it joins the set with a bare ";". It is the fragment of a split sentence,
so the ledger is refused by line rather than read as two mutations."""
green = _w(tmp_path, "g.log", GREEN)
split = _w(tmp_path, "split.tsv",
"demo\tpart1\tfirst check\t18\t2026-10-15\tthe sync not registered; this build asserts\n")
out, rc = _run("merge", "--ledger", split, green)
expect.num(rc, 2, "a mutation piece that starts with a space is refused")
expect.num(out.count("split.tsv:1:"), 1, "and the refusal names the line")

two = _w(tmp_path, "two.tsv",
"demo\tpart1\tfirst check\t18\t2026-10-15\tbloom neutered;SAOP 128 -> 0\n")
out, rc = _run("merge", "--ledger", two, green)
expect.num(rc, 0, f"control: two whole mutations separated by a bare ';' still read: {out.strip()}")


def test_the_committed_ledger_names_no_mutation_fragment(tmp_path, expect):
"""The committed ledger read by the same reader, so a split row on main is an
integrity failure of the file every gate reads, not a cosmetic one."""
copy = _w(tmp_path, "ledger.tsv", (REPO / "test" / "check_ledger.tsv").read_text())
out, rc = _run("merge", "--ledger", copy, _w(tmp_path, "g.log", GREEN))
expect.num(rc, 0, f"the committed ledger reads with no fragment in it: {out.strip()[:400]}")


def test_a_mutation_with_two_targets_can_be_recorded_when_both_are_named(tmp_path, expect):
"""#1014. A mutation with TWO GENUINE TARGETS is ordinary, not exotic.

Expand Down
Loading
Loading