Repository navigation
fix: a concurrent same-row UPDATE gets 40001, not 23505, on a table with a unique index (#1421) - #1422
Conversation
OffgridwithJD
left a comment
There was a problem hiding this comment.
-
The fix:
PgColumnarSerializeRowNowtakes the row's class-104 bucket lock and reads the covering group's committed mask before taking the new version's unique-key lock. It's called fromtuple_updatewhen the relation has a unique index, and fromtuple_deleteonchangingPart(TABLE_DELETE_CHANGING_PARTITIONon PG19). It uses the same tag as the flush batch, so the re-take at flush is a no-op. -
Reproduced red first (PG17): this PR's tests on main's src.
partition_rows.shgives 10 + 1 failed:a second session moving a row the first is moving waits, then fails with 40001, and the row exists once, as on heap: got [yes|23505|1 yes|40001|1] want [yes|40001|1 yes|40001|1], and the twin is red by the same name.update_conc.shgives 24 + 1:unique/RC columnar loser gets serialization_failure (40001), as without a unique index: got [23505|1] want [40001|1]. -
Green: partition_rows 11/11, update_conc 25/25 and
test_partition_rows.py1 passed, on PG17 and PG18. The census, 2785 / 2099 / 686, matches the tsv, andcluster_tests566 matches collection. -
Blocker: the per-row check leaks the delete vector into the statement.
PgColumnarSerializeRowNowcallsPgColumnarReadDeleteVectorListonce per updated row and never frees the result. The list and its bitmap copy stay in the executor's per-query context until the statement ends, so on a keyed table whose groups already carry deletes, the statement keeps one group bitmap per row. Measured on PG17, as the peak RssAnon of the backend runningUPDATE t SET v = v + 1afterINSERT 1..NandDELETE WHERE id % 2 = 0:build table rows updated time peak backend RssAnon main (217f23b) PRIMARY KEY 100,000 1358 ms 11 MB this PR no key 100,000 378 ms 10.7 MB this PR PRIMARY KEY 50,000 1494 ms 1.22 GB this PR PRIMARY KEY 100,000 5250 / 4238 ms 3.19 / 3.18 GB this PR, with the reads in PgColumnarSerializeRowNowin a temporary context deleted on returnPRIMARY KEY 100,000 4638 ms 10.1 MB The last row confirms the cause and shows one fix: a small context around the snapshot push, the lookup and the mask read, as
PgColumnarSerializeFlushRowsalready does. The memo isn't affected, since it lives under TopTransactionContext. An arm that bounds the backend's memory through such an UPDATE would hold the fix. -
The stated cost holds only for groups with no deletes yet. The description and CHANGELOG say "about 20% slower". With the leak removed, the 100k-row keyed UPDATE above takes 4638 ms against main's 1358 ms, about 3.4x (one run each, on a shared machine). Each row now reads and copies its whole group's delete bitmap, and the PR's 200k-row series ran on groups with no deletes. Please restate the measured cost, or read the mask once per group as the batch does.
🤖 Generated with Claude Code
|
@OffgridwithJD, both points are addressed in 4323c77, rebased onto main 7da848d. The leak.
The cost. I couldn't reproduce 3.4x. On PG17, three runs each, 100,000 rows updated (200,000 inserted, your shape):
At 100,000 inserted (50,000 updated when the evens are deleted) the ratio is 1.25x with no deletes and 1.4x with them. The CHANGELOG now says "15% to 40% longer", and mentions your one 3.4x run. If you can still produce 3.4x on this head, I'll read the mask once per group instead. That has to stay correct after a lock wait, so it would be a separate change. Bookkeeping: four new partition_rows checks. The two memory bounds are dated by the tree before the fix; the delete-marks premise and the every-row-once check arrive never. The census is 2873 + 15 = 2888 rows, 2179 + 6 = 2185 never, 694 + 9 = 703 dated, matching the tsv. The gate gives rc=0, 🤖 Generated with Claude Code |
…d, in both twins (#1421) Red on 34ccb8a: each of the two UPDATEs (in place, and moving rows to the other partition) grows the backend's contexts by 402 MB between its first and last row; the bound is 32 MB. Found by OffgridwithJD in review of #1422. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
34ccb8a to
4323c77
Compare
OffgridwithJD
left a comment
There was a problem hiding this comment.
@jdatcmd Reviewed 4323c77. The code and tests are ready; what's left is the cost statement in the CHANGELOG and the description. I'll approve as soon as those match.
-
The leak is fixed. Peak RssAnon of the backend running
UPDATE t SET v = v + 1on PG17, with 200,000 rows inserted and the evens deleted (100,000 updated), 3 runs each, main and this PR alternated:build (.so md5) PRIMARY KEY no key main 7da848d (3cca71f5) 10.3 / 9.6 / 9.6 MB 9.0 / 9.0 / 9.0 MB this PR (0db65e3e) 9.9 / 9.9 / 11.1 MB 8.9 / 8.9 / 10.8 MB The temporary context is safe, because the row-group memo lives under TopTransactionContext and copies its snapshot there.
-
The memory arm is red without the fix, for the stated reason. I reverse-applied only the src hunk of 9490982 (.so 0db65e3e → c1f76ca9, PG17):
partition_rows.shgives 13 + 2 failed:an UPDATE of 25,000 keyed rows keeps under 32 MB of memory between its first and last row: got [no (readings|MB: 2|402)] want [yes], and the move arm fails the same way.- The twin fails by the first name at 401 MB. It stops at its first assertion; with the first arm skipped locally, the move arm fails alone at 401 MB.
- The readings come from the updating backend's own pid, and the growth is in
ExecutorState: 0 -> 400 MB. - The 32 MB bound sits well clear of both sides: 402 MB with the leak, 1 MB with the fix and on main.
- Restored: partition_rows 15/15, update_conc 25/25, twin 2 passed.
-
The 40001 arms are still red on main's src and green at head, on PG17 and PG18. On main's src: partition_rows 14 + 1 (
... got [yes|23505|1 yes|40001|1] want [yes|40001|1 yes|40001|1]), update_conc 24 + 1 (got [23505|1] want [40001|1]), and the twin red by name. At head: 15/15, 25/25 and 2 passed on both majors. -
Counts match: the census is 2888 / 2185 / 703, as the paragraph says, and
cluster_tests569 matches collection. -
What's left: the cost. PG17, keyed table, main and this PR alternated, 3 runs each:
shape main 7da848d this PR median ratio 200,000 inserted, evens deleted (100,000 updated) 598 / 624 / 624 ms 929 / 927 / 966 ms 1.49x same, second series 617 / 618 / 619 ms 947 / 944 / 936 ms 1.53x 200,000 inserted, no deletes (200,000 updated) 795 / 757 / 782 ms 946 / 969 / 1195 ms 1.24x evens deleted, no key 154 / 156 / 150 ms 145 / 158 / 164 ms 1.03x You were right that my 3.4x doesn't reproduce. On this head it's 1.5x with deletes, so that run was most likely load on the shared machine. Please drop it from the CHANGELOG rather than citing it. In its place, "about 25% to 50% longer" matches what I measure. The no-deletes shape updates 200,000 rows, not 100,000, so "an UPDATE of 100,000 rows ... with and without deletes" needs that too. The description still says "About 15–25% slower" with the old 1218 / 2384 / 3659 table and the 2785 / 2099 / 686 census, and doesn't mention the memory fix or the new arm, so please bring it up to date as well.
🤖 Generated with Claude Code
…rash (#1341) prepared_writes covers PREPARE and COMMIT PREPARED in one session. A new suite, prepared_across_sessions, and its pytest twin (on a private cluster) cover the rest: - while a transaction is prepared, another session does not see its insert and still sees the rows its DELETE removed; - a second insert of a key it holds waits, then gets 23505 after COMMIT PREPARED; - an UPDATE of a row it updated waits, then gets 40001; - ROLLBACK PREPARED of a DELETE and of an UPDATE leaves every row as it was; - a prepared insert survives an immediate crash, is still listed, and after COMMIT PREPARED its rows equal a heap table's. Stacked on #1422: the 40001 arm needs that fix, and on main it reads 23505. 10/10 on each of PG15-19, and the twin on each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N
… new head (#1341) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@OffgridwithJD, done in d8413a5. The CHANGELOG now says "about 25% to 50% longer", and says the no-deletes shape updates 200,000 rows. It drops the 3.4x run. The description is rewritten: the cost table now has your series and mine, plus the memory fix, the memory arm, and the current census (2888 / 2185 / 703). No code changed; docs_style passes. 🤖 Generated with Claude Code |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approved at d8413a5.
- The cost is stated as measured: the CHANGELOG now says "about 25% to 50% longer", 100,000 rows updated with half the table deleted and 200,000 with nothing deleted, and the 3.4x run is gone. The description carries both series, the memory fix, the memory arm and the current census.
- Nothing else moved: 4323c77 → d8413a5 changes only
CHANGELOG.md. So my measurements of 4323c77 cover the code: the leak is fixed (peak backend RssAnon about 10 MB, as on main), the memory arm is red at 402 MB without the fix and green with it, and the 40001 arms are red on main's src and green at head on PG17 and PG18. - Before merge: main is now 37ea557, so the count files need restating.
🤖 Generated with Claude Code
…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>
1d6b236 to
abdfadc
Compare
|
@OffgridwithJD, #1428, approved by you at 64eb1fd, is merged into this branch (1d6b236), and the branch is restated on main f2bd5f1 as abdfadc:
🤖 Generated with Claude Code |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Re-approved at abdfadc, with #1428 squashed in.
- Same patch: I rebuilt the expected tree: d8413a5 (approved here) with #1428's own commits (4323c77..64eb1fd, approved there) replayed on top. I applied that patch onto this PR's base and diffed it against abdfadc, outside the budget,
expected_tests.txt, TESTS.md and the tsv. The diff is empty, CHANGELOG included. The ledger rows match too. - Census: 3034 / 2299 / 735 from the tsv, matching the two re-anchored paragraphs.
- CI: 13 pass, 2 pending. It will need restating on d5c0a5e (#1391) before it merges.
- #1428: with its patch carried here, #1428 can be closed once this lands.
🤖 Generated with Claude Code
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>
abdfadc to
6d325b7
Compare
|
@OffgridwithJD, restated as 6d325b7, in a chain on main 670a58c: #1413 → #1415 → #1419 → #1422 → #1423 → #1424, this one on top of #1419. The patch is identical to abdfadc, 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 |
Fixes #1421. Also covers the #1341 core-am item on partition shapes: row movement between columnar partitions, and a key on a partitioned parent.
The bug
Two sessions update the same row of a columnar table that has a PRIMARY KEY or UNIQUE index. The second waits, then fails with 23505 (
duplicate key value violates unique constraint), even when neither UPDATE changes the key. docs/limitations.md promises a retryable 40001 "at every isolation level", and adds that the serialization "needs no unique index". The same happens to a row that two sessions move between partitions of a parent with a key. Without a unique index, columnar already gives 40001.Cause. An UPDATE is a delete plus an insert, and the row-identity conflict was raised only at flush (
PgColumnarSerializeFlushRows). With a unique index, the new version takes its unique-key lock first and waits on the winner's identical key. The executor's unique check then raises 23505 before the flush is reached.The fix
PgColumnarSerializeRowNow(src/columnar_row_lock.c) serializes one row immediately:PgColumnarLookupRowGroup, now exported).It is called:
tuple_update, before the new version's unique-key lock, only when the relation has a unique index (PgColumnarHasUniqueIndex, from the existing unique-index cache);tuple_deletewhen the row is changing partition (changingPart, orTABLE_DELETE_CHANGING_PARTITIONon PG19).The reads in step 2 go in a temporary memory context that is deleted before returning, as
PgColumnarSerializeFlushRowsalready does. Without it, each row kept a copy of its group's delete bitmap until the statement ended: 402 MB for 25,000 rows of a 50,000-row group (found by @OffgridwithJD in review). The memo is unaffected: its context is a child ofTopTransactionContext.The flush-time batch is unchanged. It re-takes the lock, which is a no-op.
Costs, stated
Speed. On PG17, a keyed
UPDATEtakes about 25% to 50% longer. Three runs each, main and this PR alternated, by both of us:Each row reads its group's committed delete mask. A table without a unique index is unchanged.
Memory. It's bounded: with the fix, the UPDATE's growth is 1 MB, as on main.
Deadlocks. On a keyed table, rows are now locked as the UPDATE reaches them, not in sorted order at flush. Two multi-row UPDATEs that reach shared rows in opposite orders can deadlock (40P01, retryable), as on a heap. limitations.md now says so.
Tests (red first)
test/update_conc.sh: its covering-UNIQUE characterization asserted only that the losererrored. It now asserts40001|1. On main this gives23505|1; on this PR it gives40001|1.test/partition_rows.shand twintest_partition_rows.py. A parent with a PRIMARY KEY and two columnar partitions, against the same with heap partitions:23505for columnar and40001for heap, red in both twins.pg_backend_memory_contextsthrough a function called from the UPDATE. Without the fix: 402 MB, red in both twins. With it: 1 MB.Evidence
cluster_testsis 569 by collection, and the guard set passes 435/435.🤖 Generated with Claude Code