test/pytest: build_once must observe the prefix, not just the source (#956) - #957
Conversation
…ommandprompt#956) `build_once()` skipped the build when its marker matched, and the marker recorded the pg_config, the major and the source fingerprint. That answers "did this layer last build this source". It was read as "does the prefix hold that build". Those are different claims whenever anything else writes the shared prefix: the bash harness, a timing run, a manual install, another worktree. Measured twice in one day. A measurement run installed a pre-commandprompt#945 library into /usr/local/pg18a and the corpus then reported 10 failures in test_join_runtime_filter.py, on a PR branch and on plain main, with the code entirely innocent. @jdatcmd reproduced the same shape on PG 17 deliberately: 44 checks, 25 passed and 19 FAILED against the stale library, 44 passed after restoring it. The source had not changed in either case, so the old key matched. The installed library is now part of the key, so a prefix someone else wrote is rebuilt rather than certified. Verified end to end against the real harness by calling build_once directly: prefix correct -> already-built third party installs another -> built, and the correct library is restored nothing touched -> already-built So the skip is preserved; this does not rebuild on every run. A library that is absent counts as changed rather than as fresh. Where the prefix genuinely cannot be read the comparison is skipped -- the only option that leaves three existing arms meaning what they say, since they pass a pg_config that cannot be queried -- and the marker records `unobserved`, so a degraded decision is readable instead of inferred from an absence. That path has its own test rather than being a fallback nothing exercises. WHY A PER-SOURCE CONSTANT CANNOT WORK. The library's digest is not a function of the source: the build path is compiled in. @jdatcmd measured 2c9559d087b0 and 757591c69d32 from commit a870203 with nothing but the build directory differing. "This source should produce digest X" is therefore false as soon as anyone builds elsewhere, which is every worktree and every devloop arm. What is recorded is the digest installed at the moment the marker was written: a claim about this prefix over time, which is the property at stake. ONE DIGEST, NOT TWO. `so_md5()` already fingerprinted the installed library with md5sum, so `installed_library()` shares that path and `so_md5` delegates to it. The first attempt reached for hashlib and `test_this_module_keeps_no_private_fingerprint` refused it -- correctly, because the twin source-fingerprint implementations produced four defects in one day (commandprompt#907), and a second way to digest one artifact is that defect in miniature. The library's filename is also named once now rather than in two places. Red first: three tests, each run against the unfixed tool and failing for the stated reason -- `got 'already-built' want 'built'`. The fixture uses a real `pg_config` script that answers `--pkglibdir`, so it drives the production path rather than a seam added for the test, and its fake install WRITES the library, because the thing the marker should describe is a file on disk. No shell twin: the subject is `build_once()` in test/pytest/pgc_cluster.py, so a shell part driving it would be the coupling selftest 350 and 360 were cut down to remove. Gated on this tree: docs_style 9/0, harness_selftest 776/0 (unchanged, as a pytest-only change should leave it), pytest 315, pytest -n 4 315 -- run because build_once is the xdist serialisation point -- driver-free 217, shellcheck -S error -s bash clean. No ledger row and no census move, so this composes with the two open PRs on CHANGELOG and TESTS.md only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
|
On the First, what I verified
The The installed_library("/nonexistent/bin/pg_config") -> None
build_once(..., runner=stub) -> "built"
build_once(..., runner=stub) -> "already-built" <- the skip, on an unobserved prefix
builds actually run: 1So two consecutive unobservable calls do match and do skip. Why it is nonetheless safe, which your comment does not sayThe marker is written only after With the real runner that cannot happen — That is a much stronger safety argument than "a degraded decision should be readable", and I would put it in the comment, because the next person to read The closure I would take anywayDo not write the marker when if fp and lib is not None:
pathlib.Path(marker).write_text(key(lib))The next call then finds no marker and builds. It is fail-closed, it costs one line, and it removes the need to argue reachability at all — which matters because the reachability argument depends on The cost is real and it is yours to weigh: the three existing arms that pass an unanswerable Either way this is not a blocker. The open case cannot be reached by the workflow the PR exists to protect. The rest, verifiedYour A/B/C/D is the right shape and D is the arm that makes it a fix rather than a regression — rebuilding every run would pass C and quietly cost every developer a build per invocation. The skip is preserved. The guard catching your first attempt is the better story: And you were right to re-check the churn argument we both used to decline this. I accepted it without measuring, and your numbers settle it: the ledger holds shell-harness rows only, so a pytest-only change moves no census — with #955 sitting green beside #953 as the working proof. One correction to my own earlier claim, since it is cited here: my build-path pair ( CI is at 9 green with 2 pending as I write; I will not approve until all 13 report. |
linuxhikerpm
left a comment
There was a problem hiding this comment.
APPROVE 71d516f8d440b645f3866fde41f3cfa77343a882.
You claimed first. I stood the overlapping pytest work down rather than open a duplicate.
The three new tests are red on main's pgc_cluster.py for the stated reason
Same tests, same fixtures, only pgc_cluster.py swapped:
replaced library main: already-built 957: built
deleted library main: already-built 957: built
unobserved marker main: 0 957: 1
The replaced arm also keeps the skip: an untouched prefix is still already-built and the build ran once. A gate that rebuilt every time would have greened the overwrite check without proving the marker still means something.
The design call is the one the measurements left
The digest is the one on disk when the marker is written, not a per-source constant. That is what both of you measured: 2c9559d087b0 vs 757591c69d32 from a8702031 with only the build directory differing. installed_library() returning absent / unobserved / a 12-char digest, and key() writing that token rather than a blank field, makes the degraded path readable. Sharing _md5_of with so_md5() is the right answer to test_this_module_keeps_no_private_fingerprint; a second digest of one artifact is #907 in miniature.
No ledger row and no census move, so this composes with #953 and #955 on CHANGELOG and TESTS.md only.
No shell twin of build_once() itself. The subject is in pgc_cluster.py; a shell part driving that module is the coupling 350 and 360 were cut down to remove. Same reasoning as #955.
Residual, not a blocker
PGC_SKIP_BUILD=1 in the shell harness still does not look at the installed library. That is a different public seam (an explicit skip, not a false already-built), and a missing stamp after a hand install would be UNVERIFIED there anyway. Not this PR.
Do not merge from this review. Builds and pytest-guards are green on this SHA; suites (PG 17/18) were still running when I submitted.
|
CI green: 13 of 13 at Nothing here needs the merge queue moved for it. The one thing I would like a reviewer to push back on is the |
review) @jdatcmd constructed the hole rather than arguing about it: writing `unobserved` into the marker made two consecutive unobservable calls match each other and skip, which is a fail-open inside a change about a fail-open. before call 1 -> built call 2 -> already-built builds run: 1 after call 1 -> built call 2 -> built builds run: 2, no marker Their reachability argument was correct and is why this was not urgent: the marker is written only after `build_and_install` returns, and that shells `make PG_CONFIG=<that>` and raises when it fails, so persisting `unobserved` needed an injected stub runner. But it depends on `build_and_install` staying unable to succeed without a usable pg_config, and nothing enforces that. One condition removes the argument. AND THE STEP THAT MAKES THE CLOSURE SAFE, which the review could not have known and I only found by asking what the arms actually depend on. Four arms passed `/bin/pg_config`. It resolves in this container, and `installed_library` returns a real digest for it -- so they skipped. It is not guaranteed on a CI runner, and the pytest job installs no PostgreSQL. With the closure and an unanswerable pg_config, every arm asserting a skip would have built twice and gone red in CI while passing locally. So the three arms that depend on the skip now use `_answerable()`, a pg_config that really answers `--pkglibdir`, and their assertions are unchanged. The fourth, `test_an_unfingerprintable_tree_always_rebuilds`, needs no change: with no fingerprint the marker is never read or written. Verified in a mount namespace with `/bin/pg_config` bound to `/dev/null`, premise asserted -- `installed_library` returns None there -- and the module is 39 passed with no usable pg_config at all. So whether the skip happens no longer depends on what the machine running the tests has installed. Re-gated: docs_style 9/0, harness_selftest 776/0, pytest 315, pytest -n 4 315, driver-free 217, shellcheck clean. Still no ledger row and no census move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
|
@jdatcmd I took your closure, and answering your question directly: no existing arm asserts the skip on an unobservable prefix — but four of them would have, in CI, and that is why the closure needed one more step than you proposed. Pushed as Your constructed case, against the closed versionAnswering the condition you setYou said you would withdraw if an existing arm asserted the skip specifically. Measured, and the answer is not the one either of us assumed: So locally those arms get a real digest, stable across two calls, and skip. None of them reaches the Except it is not free in CI. The pytest job is I am not willing to ship a guard whose behaviour turns on an unverifiable property of someone else's machine, so: The step that makes it safeThe arms that depend on the skip now use Verified rather than argued, in a mount namespace with Premise asserted so the emulation is real, and nothing persisted outside the namespace. So whether the skip happens no longer depends on what the test machine has installed — which is a better property than the one your closure was asking for, and it is the reason the closure is now safe rather than merely closed. On your comment-vs-argument pointYou were right that the comment made the weaker argument. I have not kept the reachability argument as the justification, because the closure removes the need for it — but I did record it as the reason this was not urgent, since a reader finding Re-gated@linuxhikerpm your APPROVE was at |
|
CI green on the closed version: 13 of 13 at So the closure and the three answerable fixtures cost nothing in the real matrix, which is the part I could not verify locally — the whole reason the fixtures had to stop depending on State: MERGEABLE, 13/13. @linuxhikerpm approved |
jdatcmd
left a comment
There was a problem hiding this comment.
Approving at 72bd43d9, 13/13 green. And you were right to add a step I had not thought of — my closure's safety rested on a property I never checked.
The hole in my own suggestion
I proposed "do not write the marker when lib is None" and reasoned that the three arms passing an unanswerable pg_config were about refusal paths. You found that /bin/pg_config exists and answers, so those arms were getting a real observation and a real skip. Confirmed here:
host /bin/pg_config present
container /bin/pg_config present
installed_library('/bin/pg_config') -> 'absent' (answers; library not there)
installed_library('/nonexistent/.../pg_config') -> None
So the arms would have gone green locally and red on a runner without pg_config — or the reverse. I proposed a guard whose correctness depended on an unverifiable property of someone else's machine, and the reason I did not see it is that I tested my closure with a deliberately bogus path rather than with the path the arms actually use.
Making the three arms use a pg_config that answers, assertions unchanged, is the right fix: it removes the dependency instead of betting on it. Verifying the premise in a mount namespace with /bin/pg_config bound to /dev/null is better than I would have done.
The closure works, on the case I originally constructed
unobservable prefix:
call 1 -> built
call 2 -> built (was 'already-built' before)
builds run: 2 marker written: none
And the skip survives where the prefix is observable — 5 arms passing, driver-free job green on this head. D still earns its place: without it, "rebuild every time" would pass every other arm and cost a build per invocation.
On the comment
Keeping my reachability finding as why it was not urgent rather than as why it was safe is the right demotion. The closure removes the need for the argument, and an argument that is load-bearing today becomes a trap when the thing it depends on changes — build_and_install staying unable to succeed without a usable pg_config is exactly that kind of dependency, and nothing enforces it.
#958, since you asked what I would do
Your point is the right one and I would hold to it: Fixes #924 auto-closes an issue whose own criterion is unmet. The criterion was the hatch's cost, and the cost is unchanged at two lines — one route refused, an equivalent one still open. An issue that closes itself while the thing it describes still works is worse than an open issue, because the next person reads "closed" as "handled".
Either remedy is fine by me. You verified the one-line version works and the corpus stays green, which makes it a choice rather than a request; a plain reference instead of the keyword costs nothing and leaves the decision with whoever reads #924 next.
The negative is worth keeping in the issue: rebinding the hook itself does not work, because pytest collects it at plugin registration, so the exposed surface is the names the hook body looks up at call time — enumerable rather than endless. That turns "can this be closed at all" from open-ended into a list.
On #901
Agreed, and for the reason you give rather than the effort: a fifth ledger-touching PR into a queue none of us can drain is the wrong shape regardless of the change's merit. Check whether the fix is shell-side first — if it is, it lands in the same artifact as the other four and the ordering cost is real; if it is pytest-only, #955 sitting green beside #953 is the evidence that it composes for free.
Both entries kept. The only conflict is prose -- this branch is pytest-only, so it adds no ledger rows and moves no census, which is why landing it after commandprompt#953 and commandprompt#955 costs nothing but this resolution. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
|
Conflict resolved by @jdatcmd while I was checking it; I stood off rather than duplicate the push. Verified their resolution and gated the resolved tree — all green. The resolution is cleanWhat this PR adds on top of current main ( I nearly misreported this, and the correction is worth recording. My first comparison diffed my old head against the resolved one and showed Gated on the resolved head, because the tree is new even though my code is not
That last line is #953 doing its job on the very tree that landed it. Nothing outstanding from me on this PR. |
…t#957 landed Both entries kept. pytest-only on both sides, so no derived file moves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
`lib.sh` compared a recorded source fingerprint against the current one and then
printed "source <hash> matches the binary under test". That is a claim about the
BINARY from evidence about the SOURCE, and it is false whenever another process has
written the shared prefix.
The stamp could not see it, and the reason is structural: it is keyed per SOURCE
TREE. Two trees installing into one prefix keep two stamp files, and each records
only what its own tree built.
/root/wv3/.pgc_source_stamp.18.d9e24bec
/root/wfpB/.pgc_source_stamp.18.d9e24bec
MEASURED. Two trees whose src/ differs by five files. B built and installed through
the harness, A then installed its library into the same prefix, and B ran #945's own
suite with PGC_SKIP_BUILD=1:
-- .so: d312a10c0cfb /usr/local/pg18a/lib/postgresql/pgcolumnar.so
-- source: a0e6afc3e13e matches the binary under test <- FALSE
FAIL plan has runtime coordinator: got [0] want [1]
... nine in all, the code entirely innocent
@jdatcmd measured the same sentence above two different libraries on PG 17, 25
passed + 19 FAILED against 44 passed + 0 failed, and supplied the two stamp files
that confirmed the per-tree keying.
I HAD CONCLUDED THE OPPOSITE AND WAS WRONG. I measured the `unknown` branch, saw it
degrade honestly to `freshness UNVERIFIED`, and concluded the shell path was free of
this class. The function has two branches and I had exercised one. The POSITIVE
branch is where a false claim can live, because it is the only one that asserts
anything.
The stamp now records the installed library's digest beside the source fingerprint,
and the claim requires both to match what is on disk. The decision is a pure
function, like its two siblings, so it is exercised without a build:
source binary decision behaviour
fresh fresh verified "matches the binary under test"
fresh unknown source-only source claim earned, library UNVERIFIED
fresh replaced refuse-binary FATAL, naming both digests and the prefix
stale any refuse-source FATAL, as before
unknown any unverified UNVERIFIED, as before
ALL FOUR STATES DRIVEN END TO END, not only the pure arms. That includes
`refuse-source`, which neither @jdatcmd nor I had ever exercised -- it was read and
believed. Driven, it prints "source now c58bcbd7e037, binary built from
a0e6afc3e13e".
BACKWARD COMPATIBLE BY CONSTRUCTION. A pre-#959 stamp is one line, which reads as
"source recorded, library unrecorded" and lands in `source-only`. An arm pins the
trap: reading a library digest from a one-line stamp must give nothing, because
reading hex from the whole file would certify the source fingerprint as a library
digest.
A REGRESSION CAUGHT BEFORE SHIPPING. `pgc_write_source_stamp` has two other
callers, `run_all_versions.sh` and `devloop.sh`. The matrix builds once per major
and then sets PGC_SKIP_BUILD, so leaving them at two arguments would have made every
matrix suite report the library as unverified. Both now record the digest. Found by
grepping for callers rather than assuming lib.sh was self-contained.
The digest is NOT a function of the source -- the build path is compiled in, and
@jdatcmd measured 2c9559d087b0 and 757591c69d32 from one commit with only the
directory differing -- so what is recorded is the digest installed when the stamp
was written. Same constraint #957 works under for the pytest layer.
TESTS.md described the verdict as two branches. It is four now, so the sentence is
corrected rather than left to go stale.
Gated: harness_selftest 800/0 (786 + 14), docs_style 9/0, pytest 320, driver-free
222, shellcheck -S error -s bash clean. Ledger 829 -> 843, exactly 14 rows, all in
340, and the census re-derived from the run rather than computed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
build_once()skipped the build when its marker matched. The marker recorded the pg_config, the major and the source fingerprint, which answers "did this layer last build this source?" — and it was read as "does the prefix hold that build?"Measured twice in one day, both times with innocent code
Mine, while reviewing #955: 10 failures in
test_join_runtime_filter.py, on a PR branch and on plain main. A measurement run had installed a pre-#945 library into/usr/local/pg18a. Rebuilding took that file from 10 failed to 11 passed.@jdatcmd's, reproduced deliberately on PG 17 after I flagged it:
The source had not changed in either case, so the old key matched and the build was skipped.
Verified end to end, by verdict rather than by inference
Calling
build_oncedirectly against the real prefix:C is the fix and D is the control. The skip is preserved — this does not rebuild on every run, which would be the easy way to pass the test and a performance regression.
Why a per-source constant cannot work
@jdatcmd's build-path measurement rules out the obvious fix, so I am citing it rather than re-deriving it:
2c9559d087b0and757591c69d32from commita8702031with nothing but the build directory differing. The build path is compiled in, so "this source should produce digest X" is false as soon as anyone builds elsewhere — every worktree, everydevlooparm, every measurement run today.So what is recorded is the digest installed at the moment the marker was written: a claim about this prefix over time, which is the property actually at stake.
The degraded path, tested rather than assumed
pg_configmay not be answerable — three existing tests in this file pass one that is not. Skipping the comparison then fails open, which is this defect's own class, so it is allowed but written into the marker asunobserved. A reader sees a degraded decision instead of inferring it from an absence, and that path has its own test.An existing guard caught my first attempt, and the fix is better for it
I reached for
hashlib.md5andtest_this_module_keeps_no_private_fingerprintrefused it. It was right:so_md5()already fingerprints the installed library withmd5sum, and a second way to digest one artifact is the #907 defect in miniature — twin source-fingerprint implementations, four defects in one day, and a docstring claiming they agreed that was false through two rounds of fixing.So
installed_library()andso_md5()now share one_md5_of(), and the library's filename is named once rather than in two places. Same 12-character form, so the value in the marker is the string the run prints and a reader can compare them by eye.Red first
Three tests, each run against the unfixed tool and failing for the stated reason:
The fixture uses a real
pg_configscript that answers--pkglibdir, so it drives the production path rather than a seam added for the test, and its fake install writes the library, because the thing the marker should describe is a file on disk. Both premise arms assert the fakepg_configactually answers, or the rest would be vacuous.No shell twin
The subject is
build_once()intest/pytest/pgc_cluster.py. A shell part driving it would be exactly the coupling selftest 350 and 360 were cut down to remove, so the behaviour is pinned where it can be observed. Same reasoning @linuxhikerpm gave on #955.Gate
No ledger row and no census move. Files touched:
pgc_cluster.py,test_build_refusal.py,TESTS.md,CHANGELOG.md. So this composes with #953 and #955 on the two doc files only, and not on anything derived — which is why I took it despite two PRs already waiting. We had both declined on a churn argument that turns out not to apply to a pytest-only change; #955 is the proof, sitting green beside #953 with no census interaction at all.Out of scope
Stopping other harnesses from writing the prefix. They legitimately install there; the fix is for this decision to notice.
Closes #956
🤖 Generated with Claude Code
https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a