Repository navigation
test: SERIALIZABLE refuses a write skew on a columnar table, and the docs say so (#1341 item 2) - #1424
Conversation
OffgridwithJD
left a comment
There was a problem hiding this comment.
- What it holds: four write skews (doctors in one row group and in two, and an empty ward by full scan and by index) are refused with 40001 on columnar as on heap. One arm pins the SIRead locks a serializable read takes on
row_group/delete_vector. The limitations.md bullet matches what the suite holds. - Reproduced red first (PG17): with
PgColumnarCatalogSnapshotreturning aSNAPSHOT_SELFcopy (.so md5 766fad3c → a616b1a1), 4 passed + 3 failed:a serializable read of a columnar table holds SIRead locks ...: got [0/0] want [held], and both doctor arms givegot [||0 |40001|1] want [|40001|1 |40001|1]. The twin goes red on the lock arm,got '0/0' want 'held'. Restored: 7/7, md5 back to 766fad3c. - Why the ward arms stay green under that mutant (your open question): a serializable ward read still takes SIRead on
storage(tuple),storage_pkey,zone_mapandprojection, all of which the insert path changes. They are protected through the storage row, not throughrow_group. - Green: 7/7 on PG17 and PG18, and the twin on each. The census, 2781/2099/682, matches the tsv,
cluster_tests566 matches collection, and the gate passes. - Blocker: the four skew checks claim the second COMMIT fails, and on columnar it doesn't. I drove the same interleaving by hand on PG17 and PG18. On columnar, s2's write waits on a lock until s1 commits, then raises
40001: could not serialize access due to read/write dependencies among transactions, and s2's COMMIT then returns cleanly. On heap the write returns at once and the COMMIT fails.|40001|1doesn't record which statement failed, so the names ("the second commit fails with 40001") and the description ("exactly as on heap") claim more than the checks hold. The checks also accept any 40001, so a first-updater-winscould not serialize access due to concurrent updateon a catalog row would pass them too. Measured today, it is SSI: at REPEATABLE READ the same interleaving commits both transactions on columnar (doctors invariant 0, ward 2). Please tag each error with its step (w2 or c2) and pin the measured difference (columnar at w2, heap at c2), or rename the checks. A REPEATABLE READ control that must commit both would also stop a non-SSI 40001 from passing.
🤖 Generated with Claude Code
bedec3b to
5a4214d
Compare
|
@OffgridwithJD, addressed in 5a4214d. It's now on top of #1427 (fba6b45) in my merge chain.
🤖 Generated with Claude Code |
… REPEATABLE READ is the control (#1341) Review of #1424 by OffgridwithJD: the checks said the second COMMIT fails, but on columnar s2's write waits for s1 and then raises, and its COMMIT returns cleanly; on heap the COMMIT raises. And they accepted any 40001. Each error is now tagged with its step and classed by SSI's 'read/write dependencies' message; a check passes when s2 is refused once by SSI at either step. Measured: columnar at w2, heap at c2, in all four shapes. At REPEATABLE READ the same interleavings must commit both and break the invariant, on both tables. Both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OffgridwithJD
left a comment
There was a problem hiding this comment.
@jdatcmd Reviewed 5a4214d (its own commits, fba6b45..5a4214d). The serializable rework does everything you describe, and every mutant I tried behaves as it should. One ledger fix is left.
- Green at head: 9/9 in both twins on PG17 (.so md5 009408c7) and PG18 (ef2e4697).
- The tags match the header: with the normalisation turned off, every SERIALIZABLE arm gives
|w2:40001:ssi|1 |c2:40001:ssi|1, so columnar refuses at w2 and heap at c2 in all four shapes. s2 waits on the storage-row advisory lock (class 102). - The SNAPSHOT_SELF mutant (md5 009408c7 → 3d14c5fe) gives 6 passed + 3 failed, the 3 stated arms:
got [0/0] want [held], and both doctor armsgot [||0 |40001/ssi|1]. The ward arms and the REPEATABLE READ controls stay green. The twin fails ongot '0/0' want 'held'. Restored: 009408c7. - Each wrong outcome fails the checks:
- A non-SSI 40001: with the storage-row lock made to raise
could not serialize access due to concurrent updateafter waiting, at SERIALIZABLE, 5 passed + 4 failed, eachgot [|w2:40001:could not serialize access due to concurrent update|1 |40001/ssi|1]. Same in the twin. - A 40001 at REPEATABLE READ: the same raise gives 7 + 2, both controls red,
got [|w2:40001:...|1 ||0 / ...]. - s1 refused: s1 alone gives
got [w1:40001:ssi||1 |40001/ssi|1], and both givesgot [w1:40001:ssi|40001/ssi|2 ...]. 4 failed each time, in both twins. - s2 refused twice: the server can't do it, since COMMIT in an aborted transaction doesn't raise. Fed to the tagger directly,
w2:40001:ssi c2:40001:ssistays unfolded. - No refusal: the SERIALIZABLE arms run at REPEATABLE READ give
got [||0 ||0]andgot [||2 ||2], 4 failed, in both twins.
- A non-SSI 40001: with the storage-row lock made to raise
- Classification: every "read/write dependencies" message in the backend comes from
predicate.c, and any other text is printed in full, e.g.c2:40001:columnar: could not serialize access due to concurrent update or delete of the same row. - Counts: the census is 3016 / 2288 / 728, matching the budget, and
cluster_tests577 matches collection. - Please fix: 5a4214d rewrote #1427's 8
recovery_minimal_walrows. Their mutation text is one sentence with a "; " in it.pgc_ledger.py mergetreats ";" as the set separator, so it split the sentence and sorted the pieces. Each row now names two "mutants", the first being the fragment" this assert-enabled build then fails relcache.c's RelFileLocatorSkippingWAL assertion during the load (src/columnar_tableam.c)", leading space included. Please take the ";" out of that text in #1427 (", and this ..." reads fine) and carry the restored rows here.test_mutation_ledger.pystill passes with the split rows, and main already has rows split the same way, inharness_selftest(e.g.control: the carve-outs hold and the innocent arm above the helper is not named). So a merge-time refusal of a ";" piece with a leading space would stop this coming back. - Nit: the names say "SSI refuses one of the two", but the checks require it to be the second, which is what we want. "Refuses the second" would say what they hold.
🤖 Generated with Claude Code
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approved at 5a4214d. My one blocker here, the 8 split recovery_minimal_wal rows, comes from main's text, and #1434 fixes it at the source: merge refuses a --mutation holding ";", the reader refuses a split row by line, and main's rows are reworded. On rebase onto #1434, take main's text for those rows; a branch that keeps the split one will fail the ledger reader. Everything else from my review of 5a4214d stands: SSI's refusal is held, and a non-SSI 40001, an s1 refusal, a double refusal and no refusal each turn the arms red. The "refuses the second" wording nit is yours to take or leave.
🤖 Generated with Claude Code
…ws are reworded The mutation column is a ';'-separated set, written back sorted. A --mutation whose text held "; " was stored as two mutations, one a fragment starting with a space, and every later merge re-sorted it. commandprompt#1427's recovery_minimal_wal text did this to 8 rows, and commandprompt#1423, commandprompt#1424 and commandprompt#1426 each carried them as a change of their own. Four older harness_selftest rows were split the same way. - merge refuses a --mutation holding ';' (rc 2, ledger untouched). - the reader refuses a ';'-piece with whitespace at an end, by line: the writer joins with a bare ';', so such a piece is a fragment, never a mutation. - the 12 rows are reworded without ';', from their original text in history. Only field 6 changes; the census stays 3007 / 2282 / 725. Red first: the two refusal arms got 0 want 2 on the unchanged tool, and the committed-ledger arm was red at line 1783 with the refusal in and the rows not yet reworded. guard_tests 435 -> 438 by collection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CREATE UNLOGGED TABLE and ALTER TABLE ... SET UNLOGGED are refused (0A000) and leave nothing changed; ALTER TABLE ... SET TABLESPACE is refused and the table stays where it was with its rows; CREATE INDEX CONCURRENTLY is refused and leaves its index marked invalid, as core does for any failed concurrent build. Nothing tested any of them. Arms in both twins of am_contracts, each red under its own mutation. limitations.md now names SET UNLOGGED and SET TABLESPACE, and says to drop the invalid index a refused concurrent build leaves. cluster_tests +1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N
…by hand, and the row_lock_buckets bound (#1341 items 55, 56, 16) Squashed for the rebase onto main; the commits it replaces were: - test: advisory_lock_class's first writer commits on a flag row, not after a sleep (#1341) - test: a bulk UPDATE holds row_lock_buckets row locks, not one per row (#1341 item 16) - test: the row-identity lock is out of SQL's reach, and bulk_parallel_writer set by hand is safe (#1341 items 55, 56) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as (#1341) A new suite, maintenance_rollback, and its pytest twin. compact_rewrite, recluster, compact, vacuum and vacuum_sorted each run inside a transaction on a table with row groups of 1000 rows, two rows in five deleted, and one group deleted whole. Each one's effect on the row groups is read inside the transaction, which then rolls back. The table must hold the same rows by content and the same row groups, and an index scan must agree. The same then runs under a savepoint, later writes commit, and every function runs for real. 13/13 on each of PG15-19, and the twin on each. Nine checks are dated on PG17 by one mutant: free space reused whatever its freeing transaction. compact_rewrite then wrote its new groups over the groups it had just retired, and the rollback brought those groups back pointing at overwritten bytes ('corrupt encoded chunk'). cluster_tests 565 -> 566. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N Squashed for the rebase onto main; it replaces: - test: maintenance_rollback's rows re-dated on fresh tables, restated on main 1f7b5da (#1341) - test: maintenance_rollback gives each function a fresh table (#1341) - test: a maintenance function that rolls back leaves the table as it was (#1341)
…ith a unique index (#1421), with #1428 stacked on it Squashed for the rebase onto main; it replaces: - docs: the CHANGELOG states the keyed UPDATE's cost as both of us measured it (#1421) - test: the two prepared premises enter the ledger as never, on #1422's new head (#1341) - test: prepared_across_sessions asserts each ROLLBACK PREPARED arm's transaction was prepared, in both twins (#1341) - test: a prepared transaction, seen from other sessions and across a crash (#1341) - test: the memory arms enter the ledger, two dated, and the counts follow (#1421) - fix: the per-row serialization check frees its catalog reads (#1421) - test: a keyed UPDATE over groups with deletes keeps its memory bounded, in both twins (#1421) - docs: split a limitations.md sentence the plain-language gate measured as too long - fix: a concurrent same-row UPDATE gets 40001, not 23505, on a table with a unique index (#1421) - test: a concurrent same-row UPDATE must get 40001 with a unique index too (#1421, red) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new suite, maintenance_crash, and its pytest twin on a private cluster.
compact_rewrite, recluster, compact and vacuum each run twice on a fresh
table with row groups of 1000 rows, two rows in five deleted, one group
deleted whole, and an index:
- in an open transaction, another session checkpoints, and the server
crashes. After recovery the table is as it was;
- committed, and the server crashes before any checkpoint. After
recovery the table is as the function left it.
Each arm checks that a crash recovery was logged, and the table by
content, row groups and index.
16/16 on each of PG15-19, and the twin on each. Four checks are dated on
PG17 by two mutants: data pages written without WAL ('zstd decompression
failed' after a committed rewrite), and free space reused whatever its
freeing transaction ('corrupt encoded chunk' after an uncommitted
compact_rewrite). This completes the maintenance item with #1419.
cluster_tests 565 -> 566.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N
Squashed for the rebase onto main; it replaces:
- test: maintenance_crash's rows re-dated with the work arms, restated on main (#1341)
- test: maintenance_crash asserts each function did work before the crash, in both twins (#1341)
- test: a crash part-way through maintenance loses nothing (#1341)
…docs say so (#1341 item 2) The audit expected a write skew on a columnar table to commit both transactions, because the access method takes no predicate locks of its own. Measured, it does not: the second COMMIT fails with 40001, as on heap. The protection is heap SSI on pgColumnar's own catalog tables: a serializable read of a columnar table takes SIRead locks on row_group and delete_vector, and a write changes those rows. A new suite, serializable, and its pytest twin. Against heap: two doctors taken off call, in one row group and in two; and someone put on call in an empty ward, after a scan of the whole table and after an index scan. An arm pins the catalog SIRead locks, so a change that stopped reading the catalog that way turns it red rather than dropping the protection silently. limitations.md gains a SERIALIZABLE paragraph. 7/7 on each of PG15-19, and the twin on each. Three checks are dated on PG17 by a mutant whose catalog snapshot is non-MVCC: no SIRead locks (0/0), and both doctors go off call. cluster_tests 565 -> 566. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N Squashed for the rebase onto main; it replaces: - test: serializable's rows re-dated on the chain tip, and TESTS.md says what the checks now hold (#1341) - test: serializable holds SSI's refusal, not the step it comes at, and REPEATABLE READ is the control (#1341) - docs: split a limitations.md sentence the plain-language gate measured as too long - test: SERIALIZABLE refuses a write skew on a columnar table, and the docs say so (#1341 item 2)
5a4214d to
7a81002
Compare
|
@OffgridwithJD, restated as 7a81002, in a chain on main 670a58c: #1413 → #1415 → #1419 → #1422 → #1423 → #1424, this one on top of #1423. The patch is identical to 5a4214d, the head you approved, apart from re-derived bookkeeping: the budget paragraph, re-anchored on the previous link's census and matching the tsv; 🤖 Generated with Claude Code |
…ws are reworded The mutation column is a ';'-separated set, written back sorted. A --mutation whose text held "; " was stored as two mutations, one a fragment starting with a space, and every later merge re-sorted it. commandprompt#1427's recovery_minimal_wal text did this to 8 rows, and commandprompt#1423, commandprompt#1424 and commandprompt#1426 each carried them as a change of their own. Four older harness_selftest rows were split the same way. - merge refuses a --mutation holding ';' (rc 2, ledger untouched). - the reader refuses a ';'-piece with whitespace at an end, by line: the writer joins with a bare ';', so such a piece is a fragment, never a mutation. - the 12 rows are reworded without ';', from their original text in history. Only field 6 changes; the census stays 3007 / 2282 / 725. Red first: the two refusal arms got 0 want 2 on the unchanged tool, and the committed-ledger arm was red at line 1783 with the refusal in and the rows not yet reworded. guard_tests 435 -> 438 by collection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ws are reworded The mutation column is a ';'-separated set, written back sorted. A --mutation whose text held "; " was stored as two mutations, one a fragment starting with a space, and every later merge re-sorted it. commandprompt#1427's recovery_minimal_wal text did this to 8 rows, and commandprompt#1423, commandprompt#1424 and commandprompt#1426 each carried them as a change of their own. Four older harness_selftest rows were split the same way. - merge refuses a --mutation holding ';' (rc 2, ledger untouched). - the reader refuses a ';'-piece with whitespace at an end, by line: the writer joins with a bare ';', so such a piece is a fragment, never a mutation. - the 12 rows are reworded without ';', from their original text in history. Only field 6 changes; the census stays 3007 / 2282 / 725. Red first: the two refusal arms got 0 want 2 on the unchanged tool, and the committed-ledger arm was red at line 1783 with the refusal in and the rows not yet reworded. guard_tests 435 -> 438 by collection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers #1341 item 2 (SSI). Test plus docs: no source change.
What the audit expected, and what happens
The audit noted that the access method takes no SSI predicate locks: no
PredicateLockRelation,PredicateLockTIDorCheckForSerializableConflictOut. It expected a SERIALIZABLE write skew on a columnar table to commit both transactions. Measured, it does not. The second COMMIT fails with 40001, exactly as on heap, in every shape I tried.Why. A serializable read of a columnar table reads pgColumnar's catalog (
row_group,delete_vector,column_chunk,storage), which are ordinary heap tables, and heap SSI takes SIRead locks there. The write later changes those catalog rows, and heap SSI finds the rw-conflict.pg_locksafter a serializable read shows SIReadLocks onrow_group,row_group_pkey,delete_vector_pkey,column_chunkandstorage, and none on the columnar table itself.What it adds
A new suite,
test/serializable.sh, and its pytest twin,test_serializable.py. Each case runs on a columnar table and on heap:One arm pins the mechanism: a serializable read holds SIRead locks on
row_groupanddelete_vector. A future change that stopped reading the catalog per statement (a cache), or read it with a non-MVCC snapshot, would turn that arm red instead of silently dropping the protection.limitations.md gains a SERIALIZABLE bullet that states this and names the tested shapes.
Evidence
PgColumnarCatalogSnapshotreturning aSNAPSHOT_SELFcopy, the lock arm reads0/0, and both doctor cases commit both transactions (on_call0) where heap gives 40001. The two empty-ward cases did not redden under that mutant; I haven't established what else catches them, and the ledger records them as never red.cluster_testsgoes 565 → 566.🤖 Generated with Claude Code
https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N