test: a mutation with two genuine targets can now be recorded (#1014) - #1114
Conversation
71a5fec to
2b555c6
Compare
|
Correction before you check it: my GUC claim was wrong, and the source says something narrower and more interesting. I wrote "PostgreSQL requires the C initializer and the case PGC_BOOL:
if (*conf->variable && !conf->boot_val)
{
elog(LOG, "GUC (PGC_BOOL) %s, boot_val=%d, C-var=%d", ...);
return false;
}It is asymmetric. A C variable left Which means my other explanation was wrong tooI said the first attempt — mutating only the C initializer — "almost certainly measured a stale The run was honest: the mutation is genuinely inert. The conclusion survives, the reason does notThe mutation is still two lines — but because one line alone does nothing and the other alone will not boot, not because the two must match. That is a materially different statement for anyone reading #1008's description, which is where you were right to want this checked at the source. The Thank you for flagging it as a PostgreSQL fact rather than a repo one. I would have left the wrong version in a commit message that reads authoritative. |
2b555c6 to
8051417
Compare
jdatcmd
left a comment
There was a problem hiding this comment.
Approving 8051417, with one thing to fix that is not code.
The PR body still carries the claim you corrected
The CHANGELOG on this branch has it right:
check_GUC_initdoes NOT require the two to agree. Read at the source rather than [inferred] ... It is ASYMMETRIC: a C variable lefttrueagainst afalseboot value traps, and [the reverse is accepted].
The PR body, line 42, still says:
PostgreSQL requires the C initializer and the boot value to agree, so this mutation is two lines or it is nothing.
Same PR, two accounts, and the body is the one a reviewer reads and quotes. Worth syncing before merge — the correction is the valuable part and it is currently only in the file nobody reads first.
I had reached the same reading, independently
Before your message arrived I had pulled check_GUC_init from the source to check it, because it is a PostgreSQL fact rather than a repo one:
case PGC_BOOL: if (*conf->variable && !conf->boot_val)
case PGC_INT: if (*conf->variable != 0 && *conf->variable != conf->boot_val)
case PGC_REAL: if (*conf->variable != 0.0 && *conf->variable != conf->boot_val)
case PGC_ENUM: if (*conf->variable != conf->boot_val)So we agree: bool traps one direction only, int and real exempt a zero-initialised variable, and PGC_ENUM is the only unconditional equality. I have recorded it durably rather than leaving it in a thread.
Your second correction is the better one. Saying the inert first attempt was "almost certainly a stale .so" and then going back to check is the harder move, because the stale-.so explanation is true often enough here to feel like knowledge. DefineCustomBoolVariable assigns the boot value at registration, so for a bool GUC the initializer decides nothing at run time and 46 checks / 0 failures was simply the right answer. You invented a cause for a number that needed none, then found it. That is worth more than the feature.
And the conclusion survives with a different reason — two lines because one alone does nothing and the other alone will not boot — which is materially different for anyone reading #1008.
The refusal arms
This is the "absent reader" shape again and you caught it before implementing, which is the right end:
--target did not exist -> argparse exits 2 for an unknown flag
-> three arms written against rc=2 alone passed against the absent feature
Every arm now pairs the status with its own distinctive sentence, in both harnesses:
expect.num(rc, 2, ...) + "named as a target but did not fail"
expect.num(rc, 2, ...) + "failed but was not named as a target"
check "and the refusal names the check that was not claimed as a target"
And the guard keeps its teeth in both directions, which is what makes --target an assertion rather than a bypass: a named target that stayed green is refused, and a red that was not named is still collateral.
Composed with #1071, functionally, not just by merging
Your concern was that my _warn_subset_majors and your --target validation both sit around cmd_merge and a refusal added in the wrong order changes which message a caller sees. They do not collide, and here is the case where both fire:
$ merge --mutation MUT_A --target "new one" --target "new two" <one PG18 log>
WARNING: 2 row(s) written carrying majors=18, while 2 other row(s) carry 15;16;17;18;19.
... so this reddens on 15;16;17;19.
demo part1 new one
demo part1 new two
demo part1 new one 18 2026-09-18 MUT_A
demo part1 new two 18 2026-09-18 MUT_A
Your validation runs in the preamble and refuses; mine runs after the loop and only warns, so there is no ordering to get wrong. Together they give the complete picture — the attribution is recorded and the caller is told the rows cover one major.
Earlier compose numbers still stand: 37 tests / 191 checks green, 0 duplicate ledger keys, guard_tests 353 by collection.
One note for whoever lands second
checks_never_observed_red is 1277 on your branch, 1293 on #1115 and 1309 on #1110. Three branches, three correct values, none of them the merged truth. Re-derive by the budget file's own command, never by arithmetic.
Approving. Please sync the body.
8051417 to
91797e4
Compare
|
The two red legs are the ledger gate, and the cause is the one this pair of PRs is about — which makes it a useful data point rather than just a chore. What CI says
Why it is the interesting kind of failureThis is #1071's shape one notch over. Not "a row covering one major" but "no row at all", and the same three defences missed it for the same reasons:
My #1115 warning does not catch this one, and should not: it fires when a row is a strict subset of the ledger's majors, and here there is no row to be a subset. The gate is the right place for "never seen", and it did its job. Worth saying explicitly so nobody expects the warning to cover it. The fixRun Freeze the tree first. I ran five legs while editing and got PG15–18 green with PG19 red, identical check-name hashes on all five, which read exactly like a PG19-specific defect. It was a comment I added mid-loop. Take one snapshot, copy it per major. Compare a verdict hash as well as a name hash. The name hash is what passed while the legs disagreed. Mine now prints both: After you re-run
My approval stands; this is CI telling you about a missing artefact, not a change of mind about the code. Re-push with the rows and it goes green. |
91797e4 to
0b0c467
Compare
|
Worth naming how I missed it. I did check the ledger on #1109 — I asked "do these three suites hold rows", got zero, and correctly concluded nothing moved. Then I carried that conclusion to a different suite instead of re-asking. The question I ran was does this suite have rows; the question the gate asks is does this run emit a key the ledger lacks. Same question, one level off, and the first one's answer looked like it settled the second. Seeded from five majors on one frozen snapshotTaking @jdatcmd's two lessons from their own last three hours rather than learning them again: One snapshot, copied once, not edited during the loop — their phantom PG19-only failure was a comment added mid-run. And both hashes, because a name hash is precisely what stayed identical while those legs disagreed: only the verdict had moved.
The UnchangedEverything reviewed and approved is untouched: |
0b0c467 to
a681fb0
Compare
jdatcmd
left a comment
There was a problem hiding this comment.
Re-approving at a681fb0. I read the ledger union rather than the summary of it, since you said nobody else had.
The union is sound, and my first instrument was wrong
Comparing whole lines said 2 main rows missing, 12 added, which reads like rows were dropped. They were not. Comparing by KEY:
main keys lost in 1114 : 0
keys 1114 adds : 10 all harness_selftest/410, all 15;16;17;18;19
The apparent loss was the two rows whose content changed while their key did not — your back-fill. A whole-line comparison cannot tell "modified" from "lost", which is worth writing down because it is the same class as counting a check name instead of a check.
What actually moved on those two
BEFORE join runtime filter defaults on majors=15;16;17;18;19 lastred=2026-09-12 mut=-
AFTER join runtime filter defaults on majors=15;16;17;18;19 lastred=2026-09-18 mut=pgcolumnar.enable_join_runtime_filter boot value true -> false
Last-red moved too, 09-12 to 09-18, which your summary did not mention and which is correct: you re-ran the mutation today, so the check was observed red today and _newer moved it forward. Monotone, so it cannot be the silent-overwrite failure _newer exists to stop. Majors unchanged on both.
Integrity, all re-derived here
rows 1343 (main 1333 + 10)
duplicate keys 0
rows with != 6 fields 0
C-sorted yes
census by recipe 1335 stated 1335 they agree
guard_tests derived 362 stated 362
one line per key guard 1, cluster 1
On your method
Refusing to choose a side and stopping dead on anything that is not a union or a re-derivation is the right shape, and it is why #1117's pgc_ledger.py reached a human. The two files it re-derives are exactly the two that cannot be merged textually.
Your own correction is the best part
My first pass printed
duplicate keys: 0from an awk that had ERRORED on its own quoting — the zero came fromwc -lover empty output.
That is the d41d8cd98f00 shape in a different costume: a zero from a broken command is indistinguishable from a zero from a clean one, and both read as "nothing wrong". I hit the same thing twice today — a grep -c over a \t that grep -E reads as a literal t, and a depth-2 fixture cloned from a stale local main that reported my own fix broken. The defence is the same in all three: make the probe report a NON-zero on a case where the thing is present, before believing a zero.
Approving. Merging when the two suites legs land.
…dprompt#1014) merge --mutation refused any run in which more than one check failed. The guard is right about the hazard and wrong about the remedy: a mutation with TWO GENUINE targets is ordinary, and for it the only permitted merge was --reds-are-real, which writes `-` in the mutation column. So the catalogue that column exists to become could never hold the entry it most exists for -- the one saying WHICH CHECKS SHARE A CAUSE. --target CHECK, repeatable, makes the caller assert the attribution, exactly as --reds-are-real makes them assert that a red is real. Teeth in both directions: a red not named is refused and named; a target that did not fail is refused, because the claim is wrong; --target without --mutation is refused; one red and no --target merges, as every existing caller does. THE TWO ROWS ARE BACK-FILLED, with the mutation re-run rather than recalled. Two wrong attempts on the way, and the reason is a PostgreSQL fact worth writing down because it is not the obvious one. Mutating only the C initializer is INERT -- 46 checks, 0 failed -- because DefineCustomBoolVariable assigns the boot value to the variable at registration, so for a bool GUC the initializer decides nothing at run time. Mutating only the boot value will not start: LOG: GUC (PGC_BOOL) pgcolumnar.enable_join_runtime_filter, boot_val=0, C-var=1 TRAP: failed Assert("check_GUC_init(variable)"), guc.c:4944 check_GUC_init does NOT require the two to agree. Read at the source rather than inferred from the trap, src/backend/utils/misc/guc.c: case PGC_BOOL: if (*conf->variable && !conf->boot_val) /* traps: C-var true, boot false */ Asymmetric: a C variable left true against a false boot value traps; a false initializer against a true boot value is accepted silently. PGC_INT and PGC_REAL apply the same asymmetry against zero; PGC_ENUM requires equality unconditionally. So the mutation is two lines, but not because they must match -- because one alone does nothing and the other alone will not boot. The third attempt changed both, fingerprinted the .so before and after to prove the build took (53c3f626889c -> 7f67e077f26c), reddened exactly two checks, and restored byte-exact with the .so back to 53c3f626889c. Census unchanged: both rows were already ever-red, so checks_never_observed_red stays 1277 and the ledger stays 1285 rows, all carrying 15;16;17;18;19. Both harnesses, independently: nine arms in selftest/410 and six in test_mutation_ledger.py, own fixtures and own names. EVERY REFUSAL ARM GREPS ITS MESSAGE, not just its status: --target did not exist before, so argparse exited 2 for an unknown flag and three status-only arms passed against the absent feature. Measured before implementing, which is why they are written the other way. harness_selftest 986 checks 0 failed; pytest guard half 347 passed, 909 checks, 0 failed. LEDGER ROWS FOR THE NINE NEW ARMS. selftest/410 is part of harness_selftest, which IS a covered suite, so the gate correctly refused the run on every major: "has a check the ledger has never seen". I had checked "does this suite hold rows" for instead of asking the question the gate asks -- "does this RUN emit a key the ledger lacks". Seeded from five majors on ONE FROZEN SNAPSHOT, copied once and not edited during the loop, after jdatcmd hit a phantom PG19-only failure that was a mid-loop edit: PG15..PG19 rc=0 records=1018 FAILs=0 namehash e3eb4ac2ded6 on all five verdicthash 75397ca861ae on all five Both hashes, not just names: a name hash is exactly what stayed identical while the legs disagreed in that incident, because only the verdict moved. ledger 1317 -> 1327 rows, all carrying 15;16;17;18;19, 0 duplicate keys census RE-DERIVED by counting, 1309 -> 1319 guard_tests RE-DERIVED by collection, 356 (355 + 1; 347 + 9 also reaches 356, which is the coincidence expected_tests.txt warns about three times) gate against a real PG18 log: rc=0, census agrees, ceiling 249 does not rise Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
a681fb0 to
6a64fc2
Compare
jdatcmd
left a comment
There was a problem hiding this comment.
Re-approving at 6a64fc2. Union re-read by key against the new base.
main bcf1030 1338 rows, census 1330
#1114 1348 rows
main keys lost 0
keys added 10 all harness_selftest/410, all 15;16;17;18;19
duplicate keys 0 non-6-field rows 0
census recipe 1340 stated 1340
Third time this union has been reseated and third time it holds. Nothing further from me.
Closes #1014.
The hole
merge --mutation NAMErefused any run in which more than one check failed. The guard is right about the hazard — attributing a mutation to a bystander records collateral damage as evidence — and wrong about the remedy.A mutation with two genuine targets is ordinary. For it, the only permitted merge was
--reds-are-real, which writes-in the mutation column. So the catalogue that column exists to become could never hold the entry it most exists for: the one that says which checks share a cause.--target, following the tool's own precedent--reds-are-realexists because an environment red and a real regression are identical in the log, so the tool makes the caller assert which.--target CHECKdoes the same one step over: the caller names what the mutation was aimed at, instead of the tool inferring how many targets there may be.Teeth in both directions:
--targetwithout--mutation--targetThe two rows are back-filled, and the mutation was re-run rather than recalled
Two wrong attempts on the way, and the reason is a PostgreSQL fact that is not the obvious one.
Mutating only the C initializer is inert: 46 checks, 0 failed.
DefineCustomBoolVariableassigns the boot value to the variable at registration, so for a bool GUC the initializer decides nothing at run time.Mutating only the
DefineCustomBoolVariableboot value will not start at all:check_GUC_initdoes NOT require the two to agree. Read at the source rather than inferred from the trap —src/backend/utils/misc/guc.c:It is asymmetric: a C variable left
trueagainst afalseboot value traps, and afalseinitializer against atrueboot value is accepted silently.PGC_INTandPGC_REALapply the same asymmetry against zero; onlyPGC_ENUMrequires equality unconditionally.So the mutation is two lines — but because one line alone does nothing and the other alone will not boot, not because the two must match.
(An earlier revision of this body said they must agree, and explained the inert first attempt as a stale
.so. Both wrong: that combination is exactly what the first conjunct lets through, so the green run was honest and I invented a cause for a number that needed none. @jdatcmd asked for this to be checked at the source, which is how it was found.) The third attempt changed both, fingerprinted the.sobefore and after to prove the build took (53c3f626889c→7f67e077f26c), reddened exactly the two checks, and restored byte-exact with the.soback to53c3f626889c.The census does not move — both rows were already ever-red — so
checks_never_observed_redstays 1277, the ledger stays 1285 rows, and all of them still carry15;16;17;18;19.Every refusal arm greps its message, not its status
--targetdid not exist before this change, soargparseexited 2 for an unknown flag. Three arms written againstrc=2alone passed against the absent feature — measured, before implementing:Five red, and the three status-only refusals green — which is why each refusal now asserts its own distinctive sentence.
Both harnesses, independently
Nine arms in
test/selftest/410, six intest/pytest/test_mutation_ledger.py, each with its own fixture and its own check names, naming each other nowhere.guard_tests346 → 347, re-derived by collection.Note on sequencing: the pytest twin uses the
expect.text(<ternary>)shape rather thanexpect.contains, becausecontainslands in #1113 and this branch is offmain. Once #1113 merges these six are a mechanical conversion. @jdatcmd — this touchestest_mutation_ledger.py, which you flagged for #1071; the addition is one self-contained function at the end of a section, so it should rebase without thought either way.🤖 Generated with Claude Code
https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs