Move the advisory lock classes out of the user-facing keyspace (#430) - #431
Conversation
…ommandprompt#430) locktag_field4 discriminates advisory lock spaces, and PostgreSQL's own SQL-callable functions already own two values (lockfuncs.c:610-620): field4: 1 if using an int8 key, 2 if using 2 int4 keys We used both. The unique-key lock was SET_LOCKTAG_ADVISORY(tag, MyDatabaseId, indexOid, bucket, 2) which is bit for bit what pg_advisory_lock(indexOid, bucket) takes. Not a similar lock, the same lock. An application holding that tag blocked columnar inserts of that key, and columnar blocked the application, silently, with nothing to point at but unexplained waiting. Three sites, not the two the issue first said, and two used a bare literal: columnar_metadata.c:1242 delete-vector chunk class 1 pg_advisory_lock(bigint) columnar_metadata.c:1600 storage-row creation class 2 pg_advisory_lock(int4,int4) columnar_unique.c:328 unique key class 2 pg_advisory_lock(int4,int4) So the comment at columnar_unique.c:62 was wrong about our own internal picture too. It said the delete-vector lock uses 1 and the unique lock uses 2 "so the two lock spaces never false-share", unaware that the storage-row lock also used 2. They do not collide in practice, because storageId >> 32 is 0 for any realistic storage id and an index OID is never 0, but it invited a fourth use added on the assumption that the classes partition. An application can only ever set field4 to 1 or 2, so any value above 2 is unreachable from SQL. All three now have their own class, defined once in columnar.h with the reason, at 101, 102 and 103. These values are part of the lock protocol between backends, so two backends on different builds would not exclude each other. That is a restart rather than a rolling upgrade, which this extension already requires because it loads through shared_preload_libraries. test/advisory_lock_class.sh discovers the tag an insert actually takes from pg_locks rather than recomputing the bucket hash, since reimplementing it here would only assert that two copies of our arithmetic agree. Removal proof, same suite file against both builds: without the fix FAIL the lock is in a SQL-reachable class (field4=2) FAIL a user advisory lock on that tag blocks the insert FAIL a duplicate key is still rejected (the insert timed out) with the fix 7 of 7 PASS The suite also asserts that a genuine duplicate is still rejected, because removing the collision by removing the lock would satisfy everything else and silently give back issue commandprompt#5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqprqkCXuH8SegiZejE1Tw
jdatcmd
left a comment
There was a problem hiding this comment.
Approved. Removal-proved on both arms, and the suite discriminates hard.
Ran the new suite against this branch and then the same suite against main, same box,
same build flags, only the lock classes differing:
This branch:
PASS premise: the lock is enabled, or nothing below proves anything
PASS premise: the insert took an advisory lock we can see
PASS the lock an insert takes is not in a SQL-reachable class
PASS premise: the discovering session is gone and holds nothing
PASS premise: the other session really holds that exact tag in class 2
PASS a user advisory lock on that tag does not block a columnar insert
PASS a duplicate key is still rejected
7 checks, PASSED
main:
FAIL the lock an insert takes is not in a SQL-reachable class: got [reachable (field4=2)] want [unreachable]
FAIL a user advisory lock on that tag does not block a columnar insert: got [BLOCKED by the user lock] want [ok]
FAIL a duplicate key is still rejected: got [NOT rejected: ] want [rejected]
FAILED
Build clean, zero warnings.
One reading I want to correct before anyone quotes the third line as a correctness bug.
It is a cascade of the second, not an independent finding. LockAcquire at
columnar_unique.c:325 is called with dontWait = false, so the insert waits rather
than skipping the check, and the suite bounds it with statement_timeout = '10s' (line
111). The duplicate was not rejected because that insert never completed, not because a
duplicate slipped through. The bug is a stall, not a wrong answer. Worth saying plainly,
because "user advisory lock defeats a unique index" would be a much larger claim than the
evidence supports.
The parts that make this a good fix rather than a rename
Discovering the tag from pg_locks instead of recomputing it. The header says why, and
it is right: recomputing the bucket hash in the test would assert that two copies of our
arithmetic agree, which is not the property under test. Reading the tag the running system
actually took, then trying to take that exact tag through pg_advisory_lock, is.
The trap in the header is worth more than the fix. lib.sh sets
unique_lock_buckets=100003, so a first version that tried to hold every bucket needed
100,003 advisory locks against max_locks_per_transaction = 64. The holder failed, the
check passed with nobody holding anything, and the suite reported the same result with and
without the fix. That is #418's shape in a concurrency test, and recording it in the file
is what stops the next person rebuilding it.
Your correction to your own report was the substantive part. Three sites, not two, and
two of them bare literals. The old comment at columnar_unique.c:62 claimed the two spaces
"never false-share" while the storage-row lock at columnar_metadata.c:1600 was sitting in
class 2 alongside the unique-key lock. Naming all three constants in columnar.h fixes the
internal collision as well as the external one, and I verified there are no remaining
SET_LOCKTAG_ADVISORY call sites using a literal.
The upgrade caution is in the header where it belongs. Two backends on different builds
would not exclude each other, so this is a restart rather than a rolling upgrade. That is
already true of anything in shared_preload_libraries, and stating it beats discovering it.
Not blocking
0xC01A-style values are fine and the comment justifies them, but the only thing keeping
them unique is that we picked them. If a future extension namespaces its advisory locks the
same way and lands on the same constants, we get the silent-blocking failure back with a
different neighbour. Nothing to do now; worth a sentence in docs/ARCHITECTURE.md so the
values are documented as a protocol rather than as magic numbers.
Merging.
commandprompt#429 added native_index_projection to SUITES and this branch adds advisory_lock_class, so the two collide on that one line. Both are kept. Verified by SOURCING the array rather than reading it: 121 elements parse and advisory_lock_class, native_index_projection, harness_selftest, column_projection and isolation are all members. That check is not ceremony. My first attempt at this resolution appended the new suite AFTER the array's closing paren, which is valid shell (`bash -n` passes) and leaves a stray command that fails at run time with "command not found". I made exactly that mistake on commandprompt#420 earlier today and CI caught it there. Sourcing catches it here instead.
|
Merged main into your branch and pushed ( Worth flagging how I verified it, because my first attempt was wrong in a way that reads as correct: I appended So I sourced the array and asserted membership rather than reading the diff: Same shape as your Revert the merge commit if you would rather do it yourself. |
|
Agreed, and your framing is sharper than mine. The PR body said the third failure is "a consequence of the second: the insert timed out, so there was no row for the duplicate check to collide with". You have given the reason that makes it certain rather than likely, which is the (void) LockAcquire(&tag, ExclusiveLock, false /* transaction lock */ ,
false /* wait */ );
So the bug is a stall, not a wrong answer, and nobody should quote that third line as a correctness finding. It is in the suite because removing the collision by removing the lock would satisfy the first two checks and silently give back issue #5, and it earns its place that way rather than as evidence of the bug. |
|
Thanks for taking the merge. I verified it rather than reading it, using your own method, since the failure you describe is invisible in a diff: Sourced the array and asserted membership. A stray command after the closing paren would parse to 120 and the name would not be a member, so this distinguishes the two cases your #420 mistake could not. Also confirmed the merge kept my side intact: all three Five-major matrix on the merged tip,
|
Closes #430. @jdatcmd for review.
The bug
locktag_field4says which advisory lock space a tag belongs to, and PostgreSQL's ownSQL-callable functions already own two values (
lockfuncs.c:610-620):We used both. The unique-key lock was
which is bit for bit what
pg_advisory_lock(indexOid, bucket)takes. Not a similarlock, the same lock. An application holding that tag blocked columnar inserts of that
key, and columnar blocked the application, silently, with nothing to point at but
unexplained waiting.
Three sites, and the comment was wrong about our own picture too
columnar_metadata.c:1242delete-vector chunk1(bare literal)pg_advisory_lock(bigint)columnar_metadata.c:1600storage-row creation2(bare literal)pg_advisory_lock(int4,int4)columnar_unique.c:328unique key2(named constant)pg_advisory_lock(int4,int4)The issue said two sites. There are three. And
columnar_unique.c:62claimed:It did not know about the storage-row lock, which also used 2. They do not collide in
practice, because
storageId >> 32is 0 for any realistic storage id and an index OID isnever 0. The problem is that the comment invites a fourth use added on the assumption that
the classes partition.
The fix
An application can only ever set
field4to 1 or 2, so any value above 2 is unreachablefrom SQL. All three get their own class, defined once in
columnar.hwith the reasoning,at 101, 102 and 103.
Stated in the header and worth a reviewer's attention: these values are part of the lock
protocol between backends, so two backends on different builds would not exclude each
other. That is a restart rather than a rolling upgrade, which this extension already
requires because it loads through
shared_preload_libraries.The suite, and what it deliberately does not do
test/advisory_lock_class.shdiscovers the tag an insert actually takes by readingpg_locks, then tries to grab that exact tag throughpg_advisory_xact_lock.It does not recompute the bucket hash. Doing that would assert that two copies of our own
arithmetic agree, which is not the property under test.
Removal proof
Same suite file against both builds, only the source differing:
The third failure in the first arm is a consequence of the second: the insert timed out, so
there was no row for the duplicate check to collide with. Left as it fell rather than
tidied.
The suite also asserts a genuine duplicate is still rejected, because removing the
collision by removing the lock would satisfy everything else and silently give back
issue #5.
Two false starts, since they are the interesting part of the review
The first version of this suite passed identically with and without the fix, which is
the exact defect I have spent this week filing against other people. Two causes, both mine:
lib.sh:142setspgcolumnar.unique_lock_buckets=100003, andmax_locks_per_transactionis 64, so theholder silently failed and the check passed with nobody holding anything.
killon the client. The backend was insidepg_sleep()andkept its transaction and its locks, so every later check blocked on our lock rather
than the user's, in both arms.
The premise checks are what surfaced both.
premise: the other session really holds all 100003 buckets: got [no]is the line that stopped me shipping a green test that measurednothing. The suite now terminates the backend server-side with
pg_terminate_backendandasserts it is gone before continuing.
Gate
Zero failing suites. The three existing suites listed are the ones that exercise the lock
paths this change touches.
🤖 Generated with Claude Code