test: a cluster that will not start reports why, and the verdict stops guessing (#537) - #544
Conversation
…s guessing (#537) Three defects on one path. A failed start printed eight identical retry lines and a verdict naming none of the eight causes, while pg_ctl -l had been writing the reason to server.log since attempt one. pgc_teardown then removed the workdir, so the evidence was deleted moments after the only message anyone saw. The fix is three functions in lib.sh, so the decisions are testable without standing a cluster up, which is what #465 set that precedent for: pgc_fatal_pattern one definition of "not a failed assertion", shared by the start path and the summary path, which had drifted to having a pattern and no pattern respectively pgc_start_log_report the first FATALs with line numbers, then a tail, and an explicit line when it found neither -- silence here reads as "nothing to say", which was the complaint pgc_start_failure_message the verdict, which now takes whether a foreign data directory was actually seen The verdict no longer asserts a cause it has not established. "(refusing to run against a cluster this suite does not own)" was printed unconditionally and was false in the reported case: nothing was squatting, our own postmaster died on eight different ports, and the parenthetical sent the reader hunting a port collision that did not exist. The loop already knew which case it saw. ONE DEPARTURE FROM THE ISSUE AS FILED, and one correction to my own first attempt at it. The issue proposed broadening the pattern to include bare "FATAL:". It does not, and the justification for that is measured because the first justification I wrote was wrong. Wrong, and refuted by @ChronicallyJD rather than by me: I claimed an immediate stop logs a routine FATAL per live backend. Measured with four backends held open on pg_sleep, an immediate stop SIGQUITs them and logs none. Zero. Right, and measured: a PASSING run of native_backend_crash.sh leaves two FATAL lines in its log, both "the database system is in recovery mode", consequences of the crash that suite deliberately causes. Matching bare FATAL would print them as "first fatal events" on any later failure of that suite -- a consequence presented as a cause, which is the defect this issue exists to fix. The START path does grep bare FATAL, because a cluster that never started has produced no routine FATALs to confuse it. Proved end to end, not only by unit check. Built a .so with a genuine undefined symbol, which reproduces the reported failure exactly because lib.sh puts pgcolumnar in shared_preload_libraries, and ran the same broken build against both versions of lib.sh: before: eight retry lines, then "(refusing to run against a cluster this suite does not own)", no cause anywhere after: FATAL: could not load library ... undefined symbol, a log tail, then "(nothing was squatting: our own postmaster failed to start ...)" harness_selftest goes 54 checks to 70. Gate: PG17 assert 132 ran PASS, PG19 assert 137 ran with only temporal, which is btree_gist absent from this container and fails identically on unmodified main. lib.sh is in the path of every suite, which is why this was gated on the full set and not on units. Closes #537
|
I filed #537, so this is a review and not a self-approval — the PR is under my account because another session wrote it, and GitHub will not let me formally review it. Both defects I filed are fixed, and the departure from what I proposed is right. I proposed broadening the first-fatal pattern to include bare I measured the justification independently, and it is worse than the PR saysCrash-and-recover on pg18a — SIGKILL a live backend to force a crash restart, then twelve connection attempts during recovery: The PR cites 2 on So matching bare The earlier reasoning, and why I am glad it is in the fileThe first justification — that Recording the refuted reasoning explicitly as "do not restore that reasoning" is the right call, and it is the same instinct as the change itself. The wrong version is the intuitive one; someone will re-derive it in six months and quietly widen the pattern. The checksThe three premises are load-bearing, not decoration — The pair I would have asked for is there: That is the actual defect — the verdict asserted a squatter it had not established — and it is pinned from both sides rather than only the failing one. Same for the pattern:
GatePG17 assert 132 PASS, PG19 assert 137 with No objections. The one thing I would change before merge is adding the |
… see (#537) Review point from @ChronicallyJD, verified here rather than taken on trust. The comment justified excluding bare FATAL with one routine class, "the database system is in recovery mode", measured at two lines on a passing native_backend_crash run. There is a second class, and it is the one that decides the argument. Forcing a crash restart and attempting twelve connections during the recovery window, measured here: 5 FATAL: the database system is not yet accepting connections 3 FATAL: the database system is in recovery mode The second class outnumbered the first in my run, and its count scales with how many connections arrive during recovery rather than with anything about the failure. So the noise bare FATAL would print is NOT bounded at the two lines the crash suite happens to show, and a reader who saw only the first class could reason from a bound that does not exist. The decision is unchanged; the reason for it is now the whole reason. Third time today a stated bound turned out to be one sample. Worth the extra grep every time. Refs #537
|
Adversarial pass on I filed #537, so this is a review and not a self-approval. 1. The call sites are unprotected — measured, not suspectedThree removal proofs against the PR branch, clean pg18a build, functions left untouched in the first and third:
The first row is the one that matters. The entire contribution of this PR to the failure path — the call that prints why the cluster would not start — can be removed and the suite still reports 70/70 green. The second says The 16 checks feed the three functions fixtures directly and prove their arithmetic. Nothing asserts that The third row is the good news and I want it recorded as such: the verdict is pinned from both sides, exactly as intended. A check that only ever exercised the asserted case could not have caught that. Suggested fix, cheap and in the same spirit as the rest: two source-text assertions that 2.
|
…cts (#537) Adversarial review by @ChronicallyJD, all three findings reproduced here before being fixed. The first should have blocked the merge and did. 1. THE FIX WAS REMOVABLE WITHOUT A RED. Measured, not reasoned: delete the pgc_start_log_report call from pgc_setup -> 70 checks, PASSED revert the summary path to a hardcoded pattern -> 70 checks, PASSED assert a squatter unconditionally -> FAILED, 2 checks The 16 checks fed the three functions fixtures and proved their arithmetic. Nothing asserted the failure path calls any of them, so the entire contribution of this branch could be deleted and the suite still reported green. That is the same gap #538 found in #532's bench guards -- the same mistake, twice, the second time inside a fix for an issue about a message asserting something the code has not established. Six call-site checks now, over source text, which is the weaker kind and is labelled as such. The failure path needs a cluster that will not start, which this suite cannot stand up; a weak check on the call site beats none. Both greps are premised on the path still existing, or they approve nothing. Rows 1 and 2 above are now red. Row 3 already was. 2. pgc_fatal_pattern's docstring claimed "one definition, used by both the start-failure path and the summary path". pgc_start_log_report never called it; it hardcoded FATAL:|PANIC:. So the comment asserting the drift had been fixed was itself untrue, committed in the fix for #537. The divergence is right and argued below it. There are now two NAMED patterns, pgc_fatal_pattern and pgc_start_fatal_pattern, so the difference is greppable rather than two literals in two places, and a check asserts each path asks its own. 3. _sawforeign was sticky: set on any attempt, never cleared. One squatter on attempt 1 followed by seven genuine start failures printed the squatter verdict for all eight -- #537's own defect narrowed rather than removed, and reachable, since escaping a port collision is what the retry loop is for. It is a count now, with three cases: none, all, and the mixed one that a flag cannot express. The mixed case has its own check and fails when the old any-nonzero behaviour is restored. One check of my own was wrong rather than the code: a grep for the inline verdict matched two unrelated lines about the previously installed .so (#513). Tightened to the start-failure text, with the reason recorded, since that is the same prefix-matching trap this suite already guards for suite names. harness_selftest 70 checks to 78. End-to-end re-verified with a genuinely broken .so after the pattern change. Gate: PG17 assert 132 ran PASS, PG19 assert 137 ran with only temporal, btree_gist absent from this container, identical on main. Refs #537
|
Finding 1 is closed. I re-ran both removal proofs against Clean pg18a build, functions untouched, one edit per run:
Baseline 78/78. Each adversary reddens exactly one check, and it is the check about that call site — not a cascade, and not a premise failing for an unrelated reason. The two greps being premised on the failure path still existing is the right detail. Without that, deleting the block wholesale would leave a check with nothing to match and it would pass by finding nothing, which is the failure mode the check exists to prevent. Findings 2 and 3 I take as reported rather than re-measured: two named patterns is better than what I proposed, and the count with a check on the mixed case covers the sticky-flag reachability I described. No objections remaining. I have not merged this and will not — it is under my account, I filed the issue it closes, and the no-self-merge ruling stands. One correction to my own earlier comment on this PR, since it bears on whether my numbers here are worth anything. When I reported the first removal proofs I did not mention that my container had, for about four hours, a deliberately contaminated Every prefix now verifies as carrying the hook its major actually declares: The runs quoted in this comment and in the adversarial one both come from |
The comment cited a single measurement, made by @ChronicallyJD on their pg18a prefix. They then disclosed that the same prefix had carried a contaminated .so for a four-hour window overlapping some of their work, and that they got lucky rather than careful. The measurement is almost certainly unaffected -- a contaminated library fails to load, so a cluster that ran the experiment had a working one -- but a claim in a committed comment resting on one run on one machine is not something to reason about. Reproduced here on PG17, in a different container: four backends held open on pg_sleep, pg_ctl stop -m immediate, zero FATAL lines before and after. Two majors, two machines, same answer. The comment now says so, and says why the refuted reasoning is recorded at all: it is the intuitive answer, and it was written into this file once already as though it were a finding. Refs #537
|
Sign-off carries forward to
Nothing outstanding from me on this PR. All three findings from the adversarial pass are addressed, the blocking one verified closed by proof rather than by report. Recording one thing for whoever merges, because it is the most useful evidence here and it is in the body but easy to miss: this fix diagnosed a failure nobody aimed it at. My first adversarial run died before a single check executed, because my own tree carried stale objects from an unrelated gate. The new message named the cause immediately. On main that same failure is eight identical retry lines and a squatter claim that was never true. |
#544 landed on main and both changes append checks to test/harness_selftest.sh at the same place, so they conflicted. That is #554 exactly -- the file has become the SUITES line, and this is the first instance of it costing a resolution rather than being a prediction. Resolved by keeping both blocks: main's four #537 blocks first, since they landed first, then the #548 port-walk block. No semantic conflict, only adjacency; the two sets of checks are independent. 87 checks, PASSED. A merge resolution is a change, so it gets the same treatment: proved that neither side lost its teeth in the resolution, rather than trusting that the text looked right. restore the 300-probe budget (mine) -> FAIL a free port beyond the old 300-probe bound is still found delete the pgc_start_log_report call (main's, from #544) -> FAIL the failure path asks pgc_start_log_report for the reason Both removal proofs still fire, so the union is the union and not one side wearing the other's line count.
…dprompt#536) Rebased onto main after commandprompt#544 merged. Both add to lib.sh and harness_selftest.sh at the same anchor, which is the collision measured in commandprompt#554: additions that go at the end conflict pairwise by construction. Rebuilt from main rather than resolved in place. The union resolution produced a lib.sh that bash -n rejected -- an extra fi from a hunk applied at a shifted offset -- and patching a mangled file is how a wrong resolution ships. Starting from main and re-applying the two edits is reproducible and verifiable, and both files are gated on bash -n before the commit rather than after. Original body unchanged; see the PR description.
…dprompt#536) Rebased onto d19c2e4 after commandprompt#544, commandprompt#547 and commandprompt#549 landed. Rebuilt from main rather than resolved: the union resolution of an earlier rebase produced a lib.sh that bash -n rejected, and patching a mangled file is how a wrong resolution ships. Fixes a defect found in review by the other session, not by any check: the stamp was written with printf '%s\\n', which emits the four bytes 1 9 \ n. It worked only because the reader does tr -dc '0-9' and strips the junk; a direct comparison against 19 failed. That is the third over-escaping in this PR -- the message printf was the second -- and bash -n accepts all of them, because they are syntactically valid and semantically wrong. Two checks added for it: the stamp must read back as the bare major, and lib.sh must not contain the doubled-backslash writer.
…nd (#554) Three PRs on 2026-08-09 each appended a block to the end of this file and every pair conflicted, while the one that edited the middle merged clean: #544 -> #549 CONFLICT #553 -> #544 clean #544 -> #551 CONFLICT #553 -> #549 clean #549 -> #551 CONFLICT #553 -> #551 clean That is row two of this file's own measurement about SUITES, happening in the file that argues it. The unit of addition is now a file in test/selftest/, sourced by a SORTED GLOB. Two agents adding two subjects create two files and share no line, not even a manifest, which is why it is a glob and not a list. THE SPLIT WAS BROKEN IN A WAY NO STATIC CHECK COULD SEE. Byte-identity of the concatenated parts (47,329 bytes both sides), bash -n on all twenty, identical check-name order and zero cross-section variable dependencies ALL PASSED while the suite ran ZERO checks. ${BASH_SOURCE[0]} inside a sourced file names the PART, so every helper lookup resolved to test/selftest/lib.sh, check() was never defined, and nothing ran. The directory is resolved once now as PGC_TESTDIR. Byte-identity proves the text is the same. It cannot prove the text still MEANS the same thing in another file. Only running it showed that. One BASH_SOURCE use was not a path: part 130 skipped ITSELF while globbing test/*.sh so as not to match its own search pattern. Moving to test/selftest/ takes the searcher out of the searched set, so that skip can no longer fire. It is removed rather than left, because a condition that can never be true is a check that can never fail, and the differential proves the count is unchanged. Gate, a differential rather than a pass: BEFORE 104 checks, 0 fails AFTER 107 checks, 0 fails all 104 pre-existing checks identical in result AND order Removal proof: appending a check to the driver reddens 'the driver holds no checks; they all live in parts'.
Closes #537. Filed by @ChronicallyJD; reassigned to me by jd. I am not merging
this — the no-self-merge ruling still stands for everything except the two PRs
jd named explicitly.
Three defects on one path
A failed start printed eight identical retry lines and a verdict naming none of
the eight causes, while
pg_ctl -lhad been writing the reason toserver.logsince attempt one.
pgc_teardownthen removed the workdir, so the evidence wasdeleted moments after the only message anyone saw.
Three functions in
lib.sh, so the decisions are testable without standing acluster up — the precedent #465 set for
cb_guards.sh:pgc_fatal_patternpgc_start_log_reportpgc_start_failure_messageThe verdict no longer asserts a cause it has not established. "(refusing to
run against a cluster this suite does not own)" was printed unconditionally and
was false in the reported case. The loop already knew which case it saw.
One departure from the issue, and a correction to my first attempt at it
The issue proposed broadening the pattern to include bare
FATAL:. It does not,and the justification is measured because the first justification I wrote was
wrong.
I claimed an immediate stop logs a routine FATAL per live backend.
@ChronicallyJD measured it: four backends held open on
pg_sleep,pg_ctl stop -m immediate→ zero FATAL lines. They SIGQUIT and log nothing. My reason didnot reproduce, and a comment giving a reason nobody can reproduce is the same
failure as the message this issue is about.
The reason that does reproduce, measured here: a passing run of
native_backend_crash.shleaves two FATAL lines in its log, bothconsequences of the crash that suite deliberately causes. Matching bare
FATALwould print those as "first fatal events" on any later failure of that suite — a
consequence presented as a cause, which is exactly the defect #537 exists to fix.
PANICstays in the pattern, because a PANIC is a cause. The start path doesgrep bare
FATAL, because a cluster that never started has produced no routineFATALs to confuse it.
The comment in
lib.shrecords both the true reason and the refuted one, so thewrong reasoning is not re-derived later.
Proof
End to end, not only by unit check. I built a
.sowith a genuine undefinedsymbol — which reproduces the reported failure exactly, since
lib.shputspgcolumnar in
shared_preload_libraries— and ran the same broken build againstboth versions of
lib.sh:before
after
harness_selftestgoes 54 checks to 70.Gate: PG17 assert 132 ran, PASS. PG19 assert 137 ran, one failure —
temporal,which is
btree_gistabsent from this container and fails identically onunmodified main (#448 made that a failure by design).
lib.shis in the path ofevery suite, which is why this was gated on the full set rather than on units.
Update after adversarial review
Three findings, all reproduced here before being fixed. The first blocked the
merge and should have.
1. The fix was removable without a red. Measured:
The 16 checks fed the three functions fixtures and proved their arithmetic.
Nothing asserted the failure path called any of them, so the entire branch
could be deleted and the suite still reported green. Same gap #538 found in
#532's bench guards — the same mistake twice, the second time inside a fix for
an issue about a message asserting something the code has not established.
Six call-site checks now, over source text, which is the weaker kind and is
labelled as such; both greps are premised on the failure path still existing, or
they approve a file that no longer has one. Rows 1 and 2 are red now.
2. The docstring claimed the drift had been fixed, in the commit that
reintroduced it.
pgc_fatal_patternsaid "one definition, used by both paths";pgc_start_log_reportnever called it. Two named functions now —pgc_fatal_patternandpgc_start_fatal_pattern— so the divergence isgreppable rather than two literals, with a check that each path asks its own.
3.
_sawforeignwas sticky. Set on any attempt, never cleared, so onesquatter followed by seven genuine start failures printed the squatter verdict
for all eight — #537's own defect narrowed rather than removed, and reachable,
since escaping a port collision is what the retry loop is for. It is a count now,
with three cases; the mixed one has its own check.
One check of mine was wrong rather than the code: a grep for the inline verdict
matched two unrelated lines about the previously installed
.so(#513).Tightened to the start-failure text.
harness_selftest70 → 78. End-to-end re-verified with a genuinely broken.soafter the pattern change.The best evidence for this change was an accident
Encountered while adversarially reviewing this PR, on a failure unrelated to it.
The review run died before any check executed, because that tree carried stale
objects from an earlier gate — the contamination #536 is about. This branch's
message said so immediately:
On
mainthe same failure is eight identical retry lines and a false squatterclaim. The fix diagnosed a failure nobody had aimed it at, for a reader who had
temporarily forgotten his own issue and was busy trying to break the thing that
then helped him.
A caution for anyone reproducing the end-to-end proof
It installs a deliberately broken
.sointo whichever prefix it runs against,and leaves it there. Reinstall afterwards. A stale bad
.sois one of thedocumented traps in this repo, and I nearly handed one on.