fix: a subset run cannot judge what it did not collect (#1204) - #1205
Conversation
Running one pytest file on a major with rows in expected_unrunnable.txt exited
67 while reporting its own file passed, naming six tests it never collected as
proof that a tracked list was stale:
$ pytest test_native_reclaim_cycles.py --pg-config .../pg17
1 passed, 15 checks, 0 fail exit 67
THE CHECK HAD AN UNSTATED PREMISE. `allowed - actual` reads as "listed as
declining, did not decline", which is only answerable for a test the run
COLLECTED. A subset collects none of the others, so the question was not stale,
it was unasked.
AND THE INSTRUCTION WAS WORSE THAN THE NOISE, measured rather than argued.
Running exactly one of the six listed decliners failed too, because the other
five were still uncollected. Deleting the rows, which is what the message says
to do, moved the red to the opposite direction:
list intact exit 67 "expected to be unrunnable ... and RAN"
PG17 rows deleted exit 67 "unrunnable on PG17, and not expected to be"
On a full run that second one fires against rows that were correct. A guard
that refuses correct work gets switched off and takes its rule with it.
THE PREMISE IS NOW STATED PER ROW RATHER THAN PER RUN. Gating the direction on
a full run would switch it off for every subset; intersecting with what the
session collected keeps whatever the subset CAN answer.
Verified on the real corpus, not only in the layer:
a stale row for a test the subset DOES collect exit 67, names it
an unexpected decline on a one-file run exit 67, names it
and its reason
the listed decliner alone, list intact exit 0 (was 67)
A full run is unchanged, because every listed row is collected there, which is
exactly the condition the first line tests.
@OffgridwithJD tried to break it and could not: applied to main with a
deliberately stale row planted, it refuses under both a serial run and `-n2`,
where the concern was that the controller collects nothing under xdist.
guard_tests 402 -> 403, derived by collection over the 24 NO_CLUSTER files.
cluster_tests re-derived in the same run and unmoved at 467.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n
OffgridwithJD
left a comment
There was a problem hiding this comment.
Verified at 887252c, behind_main=0. Approving, with one departure from my usual bar stated openly at the end.
The issue's own repro, fixed
one file on PG 17 rc=0 1 passed stale-msgs=0 (was exit 67)
exactly the declining file rc=0 1 passed stale-msgs=0 (was exit 67)
Both halves of #1204 are closed, including the one neither of us expected — running precisely the file that is supposed to decline used to fail because the other five were not collected.
And it still has its teeth, tested against your code rather than my transcription
I stress-tested this design before it existed, against my own reading of your proposal. That is not the same as testing what you wrote, so I ran it again on 887252c with a deliberately stale row — a test that certainly runs on 18, listed as expected-to-decline — plus this box's ICU row so only the planted row was in play:
SERIAL rc=67 stale-msgs=1 467 passed
XDIST -n2 rc=67 stale-msgs=2 467 passed
Caught in both. So the per-row intersection keeps everything #1192 bought while dropping only the unanswerable half, which is exactly the claim.
My objection to this design was wrong and I want that on the record here rather than only in our messages. I argued the controller's session.items would be empty under xdist, citing your own pytest_xdist_node_collection_finished docstring, and that the stale-list direction would therefore switch itself off under -n. It does not. Your per-ROW premise beats the per-RUN gate I proposed, and it survives the case I raised against it.
The single red is inherited
pytest (cluster tests, PG 18):
got 'differs by 12 (on=218 off=230 over 1000 groups)' want 'within 5'
That is #1203's arm, in test_sorted_pathkeys.py, which this PR does not touch. It is now the fourth occurrence and the second major — I have added it to #1203 with the data. Note the direction flipped: #1201 read on=255 off=216 (39 more), this reads on=218 off=230 (12 fewer). A systematic cost has a sign; this does not.
The departure, stated
My standing rule is to approve only on a fully green gate, and this gate is 14 of 15. I am approving anyway because I can show the fifteenth is not yours: the same arm fails on main, it lives in a file this PR does not modify, and it has now failed on two majors in two directions across three unrelated branches.
If that arm turns out to be a real regression rather than a noisy measurement, this approval is void and I will say so on this PR. Holding a third PR hostage to an arm that fails on main is a worse error than the one the rule exists to prevent.
Merging remains @jdatcmd's call.
Closes #1204.
Running one pytest file on a major with rows in
expected_unrunnable.txtexited 67 while reporting its own file passed, naming six tests it never collected as proof that a tracked list was stale.Green on PG 18 and 19, which have no rows, so it was invisible from the majors CI gates pull requests with. Reported by @OffgridwithJD.
The check had an unstated premise
ran_anywayisallowed - actual: listed as declining, did not decline. That is only answerable for a test the run COLLECTED. A subset collects none of the others, so the question was not stale, it was unasked.unexpectedisactual - allowedand is answerable from any run: a test that declined, declined, whoever else was collected. It is untouched.The instruction was worse than the noise, measured
test_temporal.pyis one of the six. Running exactly the file that is supposed to decline still failed, because the other five were uncollected. And doing what the message said moved the red to the opposite direction, where a full run fires against rows that were correct.A guard that refuses correct work gets switched off and takes its rule with it. A false positive that tells you how to break the thing is a different class from one that cries wolf.
The fix states the premise per ROW, not per run
@OffgridwithJD's diagnosis was the premise; their remedy was to gate the whole direction on a full run, which
--pgc-expect-testsestablishes. Intersecting with what was collected says the same thing per row, so a subset keeps whatever the subset can answer instead of switching the direction off.Verified on the real corpus, not only in the layer
A full run is unchanged, because every listed row is collected there -- which is exactly the condition the first row tests.
TDD, in that order
The control beside it is the existing
test_a_listed_test_that_runs_makes_the_list_stale, which lists a test that IS in the run: it must still fire, and it does.And @OffgridwithJD tried to break it
They had a specific objection worth recording: under xdist the workers collect, not the controller, so
session.itemson the controller might be empty and the stale-list direction would switch itself off under-n-- a silent regression of the half #1192 bought.They applied the diff to
mainwith a deliberately stale row planted and measured:Caught in both. The doubled message under
-nis cosmetic. Their objection did not hold, and the check is verified by someone who is not the author of the remedy.Bookkeeping
guard_tests402 -> 403, derived by collection over the 24NO_CLUSTERfiles, not by adding one.cluster_testsre-derived in the same run and unmoved at 467, which is the expected answer for a change touching only the layer and its own guard.The arm lives in
test_layer.pybecause that is where the vacuity layer's own guards live and it drives pytest inside pytest, which is the only way to observe a subset run's exit status. There is no shell twin:pgc_vacuity.pyhas no shell counterpart, and #1192 set that precedent for the same reason.🤖 Generated with Claude Code
https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n