test: port native_parquet_dict_oob and advisory_lock_class to pytest - #1156
Conversation
5 names, missing: 0. A Parquet dictionary index with the high bit set must not read out of bounds: the RLE_DICTIONARY path bounds-checked a file-controlled index with a SIGNED comparison, so 0x80000000 sign-extends to a negative int, slips past the check, and reads about 16 GB past the dictionary. The gate carries the shell suite's name, which commandprompt#1131 made possible. THE FIXTURES ARE COPIED, NOT READ IN PLACE. The server runs as the postgres OS user while the checkout does not belong to it, and read_parquet opens the file as the server -- a permission failure there would look exactly like the rejection this suite exists to prove. Removal proof, restoring the signed comparison: the crafted file answers "server closed the connection unexpectedly" and the rejection arm reddens. On a real crash the arms after it never run, because the connection is gone; they cover the quieter world where the out-of-bounds read lands on mapped memory, returns garbage, and neither raises nor dies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
8 names, missing: 0, no extras. pgColumnar's internal advisory locks must not be reachable from SQL (commandprompt#430): PostgreSQL's own functions own field4 values 1 and 2, so a lock the extension takes in either shares a space with anything a user can take, and a user lock on the same tag blocks a columnar insert forever. THE PORT ASSERTS A STRONGER PROPERTY BECAUSE THE ORIGINAL'S CANNOT FAIL WHEN IT MATTERS (commandprompt#1154). The shell suite discovers the lock as ORDER BY objsubid DESC LIMIT 1, but an insert takes more than one, and the maximum is the unique-key lock only while the unique-key lock is the highest-numbered one. Regress PGCOLUMNAR_LOCKCLASS_UNIQUE_KEY to 2 and the maximum becomes 102, STORAGE_ROW, still unreachable -- the suite reports the property holding while the lock under test sits in the SQL space, and the contention arm takes the wrong tag too. This collects every advisory lock the insert holds, asserts none is SQL-reachable, and contends for every addressable tag. Under that mutation it reddens naming the offender: got 'reachable [(16572, 77, 2)]' want 'unreachable'. NO SLEEPS AND NO POLLING. The shell suite backgrounds a psql running pg_sleep(30), polls pg_locks sixty times each way, and must pg_terminate_backend rather than kill the client -- its own comment records that killing the client left the server holding the transaction and reported the same result with and without the fix. Real connections leave no window to wait on. Three tests rather than one: cannot_run declares a whole test unrunnable while check_skip skips a single check, so the SQL-form arm is its own test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
|
Read The discovery fix is obvious once stated — assert over every lock rather than the maximum. What I wanted to check was whether the contention arm still consumed a derived tag, since that was the second half of #1154. It does not: for classid, objid in addressable:
cur.execute("SELECT pg_advisory_xact_lock(%s::int, %s::int)", (classid, objid))Every addressable tag, not Three things I checked and found sound:
Verified rather than read: The framing is worth keeping where the next port will meet itYou had it as "the discovery takes the maximum". The reusable form is an arm whose INPUT is derived from the thing under test follows the defect and keeps looking like evidence — which is worse than a vacuous arm, because a vacuous arm is inert while this one moves with the bug and still reads green. Here one wrong premise disabled two arms because the second consumed the first's output. Several of the remaining 66 discover something before asserting on it. That shape is the thing to look for, and it is worth a line in And the byte-identical
|
jdatcmd
left a comment
There was a problem hiding this comment.
Approving 864a9864. I ran both mutations rather than taking them from your comments, and one of them is stronger than you claimed: the shell original is not merely blind to the regression, it reports a clean seven-arm pass on it.
The advisory-lock finding, reproduced
PGCOLUMNAR_LOCKCLASS_UNIQUE_KEY 103 -> 2, clean rebuild, PG 17. .so md5 c67dd162 -> 9649379b, so the mutant is really running.
THE PORT the lock an insert takes is not in a SQL-reachable class:
got 'reachable [(16520, 77, 2)]' want 'unreachable'
a user advisory lock on that tag does not block a columnar insert:
got 'BLOCKED by the user lock' want 'ok'
-- the locks it took: [(2, 1410065408, 102), (16520, 77, 2)]
THE ORIGINAL the lock it took: classid=2 objid=1410065408 field4=102
PASS the lock an insert takes is not in a SQL-reachable class
PASS a user advisory lock on that tag does not block a columnar insert
accounting: 7 passed + 0 failed + 0 unrunnable + 0 skipped = 7
test/advisory_lock_class.sh is green on the exact defect it was written for, and #430 could be reintroduced today without it saying anything. Your docstring says the maximum "is now 102, the STORAGE_ROW lock, which is still unreachable" — that is what I measured, and the consequence is that ORDER BY objsubid DESC LIMIT 1 selects the one lock that cannot fail while the lock under test sits in the SQL space.
TWO arms redden in the port, not one. Your _advisory_tags docstring only claims the first. The contention arm reddens too, because it now contends for every addressable tag rather than the highest — so the blind spot you closed was load-bearing in both arms, and the file understates itself.
Restored: md5 back to c67dd162, port 12 pass + 0 fail, original 7 passed.
The Parquet OOB finding, reproduced
(uint32) dictCount -> (int) idx[i] >= dictCount. md5 c67dd162 -> 5510d7a7.
-- the crafted file answered: consuming input failed: server closed the
connection unexpectedly
attack: the out-of-range dictionary index is rejected with an error:
got 'no (...server terminated abnormally...)' want 'yes'
accounting: 1 pass + 1 fail + 0 unrun = 2
Exactly the transcript in your comment, including that it is the rejection arm that reddens and the two survival arms never run. The control passed in the same run, so the fixture is not simply unreadable. Restored to c67dd162, 4 passed.
One thing I hit that is worth a note for whoever mutates a header next
My first attempt at the lock-class mutation edited src/columnar.h and ran plain make. It reported a successful build and the .so md5 did not move at all — still c67dd162, the clean value. A header-only edit does not rebuild the .c that includes it, so the run would have been against the unmutated object while reporting a normal build. I caught it only because I print the md5 before believing the run; make clean first is what makes a header mutation real. I will file that separately.
The count collision, flagged before it bites
main cluster_tests 436
#1156 cluster_tests 440 (436 -> 437 -> 440)
#1147 cluster_tests 437 (436 -> 437)
Both move from 436 and the values differ, so git will conflict rather than resolve silently — the loud case, which is the lucky one. Whoever merges second re-derives by collection; neither 440 nor 437 survives. I have just had the quiet version of this on #1147: main and my branch both said 436 for different reasons, git took the value without a conflict, and the merged tree collected 437.
Substance
The design calls are right and each is argued from a measurement rather than a preference:
no sleeps, no polling a second real connection makes every wait in the original
an artefact of driving psql from a shell
the skip is per TEST so the SQL-form arm is its own test, and the duplicate-key
arm runs regardless -- the scope stays honest
the fixtures are copied the server runs as postgres and the checkout does not;
an unreadable path would look exactly like the refusal
this suite exists to prove
the log must exist asserted before "no segfault lines" is believed, which is
the same shape as a sum over an empty set
all three attack arms a rejection, a live backend, and a clean log distinguish
"crashed" from "read garbage and returned it quietly"
The duplicate-key test earns its place: removing the collision by removing the lock satisfies every other arm and gives back #5.
CI verified by sha on 864a9864: 14 of 14 success, 0 pending, 0 failing.
🤖 Generated with Claude Code
Two more of the 66 suites #1131 unblocked. 13 names,
missing: 0each.The second port asserts a stronger property, because the original's cannot fail when it matters (#1154)
advisory_lock_class.shguards #430 — pgColumnar's internal advisory locks must not sitin a SQL-reachable class. Regress the class it is about and the suite stays green.
It discovers "the lock the insert took" as
ORDER BY objsubid DESC LIMIT 1. An inserttakes more than one:
[(16572, 77, 103), (2, 1410065408, 102)]UNIQUE_KEYregressed to 2[(2, 1410065408, 102), (16572, 77, 2)]The maximum is the unique-key lock only while the unique-key lock is the
highest-numbered one — a premise nobody wrote down, carried by a line that reads as a
lookup. Regress it and the maximum becomes 102,
STORAGE_ROW, still unreachable: thesuite reports the property holding while the lock under test sits in the SQL-addressable
space. The contention arm then contends for the discovered tag, which is now the wrong
tag, and passes too. Two arms that both move with the bug they are meant to catch,
which is worse than one vacuous arm because both look like evidence.
This port collects every advisory lock the insert holds, asserts none is
SQL-reachable, and contends for every addressable tag. Under the same mutation:
Filed as #1154; the shell suite is unchanged, same policy as #1138, #1146 and #1150.
Reproducing that mutation needs one extra step, and it caught me first. Changing the
#defineincolumnar.hproduced a byte-identical.so— header dependencies arenot tracked, so
columnar_unique.owas stale and the run measured the unmutated binary. Aclean run reads exactly like "not load-bearing".
rm -f src/columnar_unique.oand thehash moves
2241059a6621 → 29a7f55eeb71.No sleeps and no polling, which is structural rather than clever
The shell suite backgrounds a
psqlrunningpg_sleep(30), pollspg_locksup to sixtytimes for the lock to appear and again for it to go, and must
pg_terminate_backendrather than kill the client — its own comment records that killing the client left the
server inside
pg_sleep()holding the transaction, and "reported the same result with andwithout the fix". With real connections a second session's
INSERTreturns when the lockis held, and closing it ends the transaction. Every wait in the original is an artefact of
driving the database through a shell.
The first port: fixtures are copied, not read in place
The server runs as the
postgresOS user while the checkout does not belong to it, andread_parquetopens the file as the server. A fixture read from the source tree can failon permissions — and that failure looks exactly like the rejection the suite exists to
prove, which is the worst possible confusion for an arm whose subject is "malformed input
is refused".
Removal proof, restoring the signed comparison:
server closed the connection unexpectedly, and the rejection arm reddens. On a real crash the arms after it never run,because the connection is gone; they cover the quieter world where the out-of-bounds read
lands on mapped memory, returns garbage, and neither raises nor dies.
Three tests, not one
cannot_rundeclares a whole test unrunnable whilecheck_skipskips a single check, sothe SQL-form arm is its own test and the duplicate-key arm runs regardless. That is why
cluster_testsmoves by 3 for one suite.What I ran
The 1 unrun on 15–17 is
temporaldeclining below PostgreSQL 18, which landed in #1152.Gated before the rebase onto
18821ce; the rebased tree is byte-identical to the gatedone (
24a1285a6be7), checked rather than assumed.cluster_tests437 → 440. No bash suite changes, so no ledger row moves and the censusdoes not.
🤖 Generated with Claude Code
https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs