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

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion test/check_ledger_budget.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2533,4 +2533,16 @@ 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
checks_never_observed_red 2346
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
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
20 changes: 20 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
5 changes: 4 additions & 1 deletion 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
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