diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index 734b4a672..694fb5d6e 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -1805,8 +1805,8 @@ harness_selftest 540-an-arm-must-carry-its-measurement a new arm that cannot say harness_selftest 540-an-arm-must-carry-its-measurement a repaired arm must have its row removed, so the list only shrinks 15;16;17;18;19 2026-10-12 part 540's awk sweep never reports an indeterminate verdict (the print guarded by 0);part 540's sweep stops skipping arms whose text ends in $ (the awk 'if (arms ~ /\$/) next' made 'if (0) next') harness_selftest 540-an-arm-must-carry-its-measurement control: a determinate or carrying arm is not swept up 15;16;17;18;19 2026-10-12 part 540's sweep stops skipping arms whose text ends in $ (the awk 'if (arms ~ /\$/) next' made 'if (0) next') harness_selftest 540-an-arm-must-carry-its-measurement control: a determinate or carrying awk arm is not swept up 15;16;17;18;19 2026-10-12 part 540's awk sweep never reports an indeterminate verdict (the print guarded by 0);part 540's awk_determinate treats a comparison against > 0 as indeterminate (return 1 made return 0) -harness_selftest 540-an-arm-must-carry-its-measurement control: the carve-outs hold and the innocent arm above the helper is not named 15;16;17;18;19 2026-09-24 else echo 0; fi)` is invisible again (mutation B, the same-line recorder conjunct removed from the `[` rule, reddens the SAME two arms -- they are not independent); then echo 1;the shell if/then/else spelling removed from the matcher, so `$(if [ C ] -harness_selftest 540-an-arm-must-carry-its-measurement control: the sweep sees a shell if/then/else that discards its operand 15;16;17;18;19 2026-09-24 else echo 0; fi)` is invisible again (mutation B, the same-line recorder conjunct removed from the `[` rule, reddens the SAME two arms -- they are not independent); then echo 1;the shell if/then/else spelling removed from the matcher, so `$(if [ C ] +harness_selftest 540-an-arm-must-carry-its-measurement control: the carve-outs hold and the innocent arm above the helper is not named 15;16;17;18;19 2026-09-24 the shell if/then/else spelling removed from the matcher, so a one-line `$(if [ C ]... then echo 1... else echo 0... fi)` is invisible again (mutation B, the same-line recorder conjunct removed from the `[` rule, reddens the SAME two arms -- they are not independent) +harness_selftest 540-an-arm-must-carry-its-measurement control: the sweep sees a shell if/then/else that discards its operand 15;16;17;18;19 2026-09-24 the shell if/then/else spelling removed from the matcher, so a one-line `$(if [ C ]... then echo 1... else echo 0... fi)` is invisible again (mutation B, the same-line recorder conjunct removed from the `[` rule, reddens the SAME two arms -- they are not independent) harness_selftest 540-an-arm-must-carry-its-measurement control: the sweep sees a threshold arm and a floor arm 15;16;17;18;19 2026-10-12 part 540's sweep stops skipping arms whose text ends in $ (the awk 'if (arms ~ /\$/) next' made 'if (0) next') harness_selftest 540-an-arm-must-carry-its-measurement control: the sweep sees an awk verdict that discards its operands 15;16;17;18;19 2026-10-12 part 540's awk sweep never reports an indeterminate verdict (the print guarded by 0);part 540's awk_determinate treats a comparison against > 0 as indeterminate (return 1 made return 0) harness_selftest 540-an-arm-must-carry-its-measurement premise: the comment fixture quotes a whole recorder call, not a fragment 15;16;17;18;19 never - @@ -1852,10 +1852,10 @@ harness_selftest 570-a-checkout-test-must-ask-git without the trailing boundary harness_selftest 580-a-hand-rebuild-must-record a PostgreSQL symbol is NOT exempt, so the check still has a subject 15;16;17;18;19 2026-10-12 pgc_symbol_is_toolchain deleted from the real lib.sh;the symbol exemption matches every name, so the check has no subject left and a genuinely mislinked major would resolve clean harness_selftest 580-a-hand-rebuild-must-record a log that is not there also produces a diagnosis 15;16;17;18;19 2026-09-24 _hr_diagnose restored to its silent `[ -s ] || return 0` form, so an empty or missing rebuild.log prints nothing at all harness_selftest 580-a-hand-rebuild-must-record a log with content produces a diagnosis to print 15;16;17;18;19 2026-09-24 rebuild.sh forced to fail after its stamp AND _hr_diagnose gutted, so the failure prints no reason -harness_selftest 580-a-hand-rebuild-must-record a rebuild that failed skips them rather than reading a stamp it did not write 15;16;17;18;19 2026-09-24 driven alone, with no forced rebuild failure, because these two arms call the gate with literals and the rebuild's status never reaches them;the gate reverted so a failed rebuild still runs the arms beneath it +harness_selftest 580-a-hand-rebuild-must-record a rebuild that failed skips them rather than reading a stamp it did not write 15;16;17;18;19 2026-09-24 the gate reverted so a failed rebuild still runs the arms beneath it, driven alone with no forced rebuild failure, because these two arms call the gate with literals and the rebuild's status never reaches them harness_selftest 580-a-hand-rebuild-must-record a rebuild that succeeded runs the arms that read its stamp 15;16;17;18;19 2026-09-24 the gate always skips, so a successful rebuild runs none of the arms that read its stamp harness_selftest 580-a-hand-rebuild-must-record an empty log still produces a diagnosis, because silence is the bug 15;16;17;18;19 2026-09-24 _hr_diagnose restored to its silent `[ -s ] || return 0` form, so an empty or missing rebuild.log prints nothing at all -harness_selftest 580-a-hand-rebuild-must-record and any other status skips too, rather than being read as success 15;16;17;18;19 2026-09-24 driven alone, with no forced rebuild failure, because these two arms call the gate with literals and the rebuild's status never reaches them;the gate reverted so a failed rebuild still runs the arms beneath it +harness_selftest 580-a-hand-rebuild-must-record and any other status skips too, rather than being read as success 15;16;17;18;19 2026-09-24 the gate reverted so a failed rebuild still runs the arms beneath it, driven alone with no forced rebuild failure, because these two arms call the gate with literals and the rebuild's status never reaches them harness_selftest 580-a-hand-rebuild-must-record and it distinguishes missing from empty, so the two causes do not merge 15;16;17;18;19 2026-09-24 _hr_diagnose restored to its silent `[ -s ] || return 0` form, so an empty or missing rebuild.log prints nothing at all harness_selftest 580-a-hand-rebuild-must-record and it names the log as empty rather than printing a bare banner 15;16;17;18;19 2026-09-24 _hr_diagnose restored to its silent `[ -s ] || return 0` form, so an empty or missing rebuild.log prints nothing at all harness_selftest 580-a-hand-rebuild-must-record and it never reaches the pg_config check it would otherwise fail on 15;16;17;18;19 2026-09-25 rebuild.sh's fail-closed assertion is removed, so a missing exemption function leaves the undefined list empty and the check reports success @@ -2609,14 +2609,14 @@ range_pruning range_pruning premise: the range type collates differently from it range_pruning range_pruning so does a containment probe 15;16;17;18;19 never - range_pruning range_pruning the overlap qual is pushed down, where it used to be dropped 15;16;17;18;19 never - range_pruning range_pruning the same for a containment probe far to its right 15;16;17;18;19 never - -recovery_minimal_wal recovery_minimal_wal a table created and loaded in one transaction at wal_skip_threshold 0 committed its rows, and the server came back through recovery 15;16;17;18;19 2026-10-15 this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c);the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered -recovery_minimal_wal recovery_minimal_wal a table created and loaded in one transaction at wal_skip_threshold 64MB committed its rows, and the server came back through recovery 15;16;17;18;19 2026-10-15 this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c);the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered -recovery_minimal_wal recovery_minimal_wal a table truncated and reloaded in one transaction at wal_skip_threshold 0 committed its rows, and the server came back through recovery 15;16;17;18;19 2026-10-15 this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c);the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered -recovery_minimal_wal recovery_minimal_wal a table truncated and reloaded in one transaction at wal_skip_threshold 64MB committed its rows, and the server came back through recovery 15;16;17;18;19 2026-10-15 this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c);the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered -recovery_minimal_wal recovery_minimal_wal and after the truncate the columnar table holds what its heap mirror holds, at wal_skip_threshold 0 15;16;17;18;19 2026-10-15 this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c);the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered -recovery_minimal_wal recovery_minimal_wal and after the truncate the columnar table holds what its heap mirror holds, at wal_skip_threshold 64MB 15;16;17;18;19 2026-10-15 this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c);the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered -recovery_minimal_wal recovery_minimal_wal and the columnar table holds what its heap mirror holds, at wal_skip_threshold 0 15;16;17;18;19 2026-10-15 this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c);the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered -recovery_minimal_wal recovery_minimal_wal and the columnar table holds what its heap mirror holds, at wal_skip_threshold 64MB 15;16;17;18;19 2026-10-15 this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c);the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered +recovery_minimal_wal recovery_minimal_wal a table created and loaded in one transaction at wal_skip_threshold 0 committed its rows, and the server came back through recovery 15;16;17;18;19 2026-10-15 the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered, and this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c) +recovery_minimal_wal recovery_minimal_wal a table created and loaded in one transaction at wal_skip_threshold 64MB committed its rows, and the server came back through recovery 15;16;17;18;19 2026-10-15 the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered, and this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c) +recovery_minimal_wal recovery_minimal_wal a table truncated and reloaded in one transaction at wal_skip_threshold 0 committed its rows, and the server came back through recovery 15;16;17;18;19 2026-10-15 the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered, and this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c) +recovery_minimal_wal recovery_minimal_wal a table truncated and reloaded in one transaction at wal_skip_threshold 64MB committed its rows, and the server came back through recovery 15;16;17;18;19 2026-10-15 the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered, and this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c) +recovery_minimal_wal recovery_minimal_wal and after the truncate the columnar table holds what its heap mirror holds, at wal_skip_threshold 0 15;16;17;18;19 2026-10-15 the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered, and this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c) +recovery_minimal_wal recovery_minimal_wal and after the truncate the columnar table holds what its heap mirror holds, at wal_skip_threshold 64MB 15;16;17;18;19 2026-10-15 the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered, and this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c) +recovery_minimal_wal recovery_minimal_wal and the columnar table holds what its heap mirror holds, at wal_skip_threshold 0 15;16;17;18;19 2026-10-15 the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered, and this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c) +recovery_minimal_wal recovery_minimal_wal and the columnar table holds what its heap mirror holds, at wal_skip_threshold 64MB 15;16;17;18;19 2026-10-15 the relfilenode created with smgrcreate instead of RelationCreateStorage, so no commit-time sync is registered, and this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c) recovery_minimal_wal recovery_minimal_wal premise: the cluster runs at wal_level=minimal 15;16;17;18;19 never - rewrite_storage_oid rewrite_storage_oid a matview created WITH DATA leaves its storage row pointing at itself 15;16;17;18;19 2026-10-12 the repair gate skips every relation, as if no inheritor were locked by the statement;the rewrite repair gate no longer covers CreateTableAsStmt, so a matview created WITH DATA keeps the transient's OID rewrite_storage_oid rewrite_storage_oid a type rewrite leaves the storage row pointing at the live table 15;16;17;18;19 2026-10-12 the repair gate skips every relation, as if no inheritor were locked by the statement;the rewrite hook does not retarget storage.relation_oid diff --git a/test/pgc_ledger.py b/test/pgc_ledger.py index 1d880f395..83c6666dc 100755 --- a/test/pgc_ledger.py +++ b/test/pgc_ledger.py @@ -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 @@ -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)}) diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index 4425ba7e6..ed83fde0e 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -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 diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index 783fc01c6..8e812d734 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -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 diff --git a/test/pytest/test_mutation_ledger.py b/test/pytest/test_mutation_ledger.py index a06e83064..07650ec7f 100644 --- a/test/pytest/test_mutation_ledger.py +++ b/test/pytest/test_mutation_ledger.py @@ -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.