Skip to content

fix: a concurrent same-row UPDATE gets 40001, not 23505, on a table with a unique index (#1421) - #1422

Merged
jdatcmd merged 4 commits into
mainfrom
fix/1421-unique-update-40001
Oct 15, 2026
Merged

jdatcmd merged 4 commits into
mainfrom
fix/1421-unique-update-40001

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  1. Take the row's class-104 bucket lock, waiting.
  2. Read the committed delete mask of the one row group covering the row, through the fetch path's memo (PgColumnarLookupRowGroup, now exported).
  3. Raise the same 40001 if the row is deleted.

It is called:

  • from 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);
  • from tuple_delete when the row is changing partition (changingPart, or TABLE_DELETE_CHANGING_PARTITION on PG19).

The reads in step 2 go in a temporary memory context that is deleted before returning, as PgColumnarSerializeFlushRows already 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 of TopTransactionContext.

The flush-time batch is unchanged. It re-takes the lock, which is a no-op.

Costs, stated

  • Speed. On PG17, a keyed UPDATE takes about 25% to 50% longer. Three runs each, main and this PR alternated, by both of us:

    shape main this PR ratio
    200,000 inserted, evens deleted, 100,000 updated (OffgridwithJD) 598 / 624 / 624 ms 929 / 927 / 966 ms 1.49x
    200,000 inserted, no deletes, 200,000 updated (OffgridwithJD) 795 / 757 / 782 ms 946 / 969 / 1195 ms 1.24x
    200,000 inserted, evens deleted, 100,000 updated (jdatcmd) 1313 / 1208 / 1201 ms 1380 / 1536 / 1410 ms 1.15x
    evens deleted, no key (OffgridwithJD) 154 / 156 / 150 ms 145 / 158 / 164 ms 1.03x

    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 loser errored. It now asserts 40001|1. On main this gives 23505|1; on this PR it gives 40001|1.
  • New suite test/partition_rows.sh and twin test_partition_rows.py. A parent with a PRIMARY KEY and two columnar partitions, against the same with heap partitions:
    • a row-moving UPDATE;
    • key refusals through the parent;
    • three races ordered by a gate connection, not by sleeps, with each contender seen waiting before the holder commits. On main, the move-versus-move race gives 23505 for columnar and 40001 for heap, red in both twins.
    • Memory, from the review: an UPDATE of 25,000 keyed rows over a row group with deletes, in place and moving partition, must grow the backend's memory by under 32 MB between its first and last row. The test measures the backend's own pg_backend_memory_contexts through a function called from the UPDATE. Without the fix: 402 MB, red in both twins. With it: 1 MB.

Evidence

  • On this head, PG15 to PG19: partition_rows passes 15/15, and its twin 15/15. On PG17, update_conc passes 25/25 and unique_conc 33/33. Earlier, before the memory fix, isolation, native_dml, am_contracts, projection_update, prepared_writes, advisory_lock_class, index_delete_liveness, native_delete_visibility_paths and differential passed with 0 failed on each major.
  • Ledger: partition_rows enters with 15 rows: 9 dated, by the tree before the fix, by a mutant that leaves a moving row undeleted in its old partition, and by the memory leak; 6 never. The census is 2873 + 15 = 2888 rows, 2179 + 6 = 2185 never and 703 dated, matching the tsv. The gate passes, cluster_tests is 569 by collection, and the guard set passes 435/435.
  • Commits: the tests (red on main), the fix, a docs commit, then the memory arms (red on the previous head), the memory fix, the bookkeeping, and the CHANGELOG cost.

🤖 Generated with Claude Code

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jdatcmd Reviewed 34ccb8a.

  • The fix: PgColumnarSerializeRowNow takes 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 from tuple_update when the relation has a unique index, and from tuple_delete on changingPart (TABLE_DELETE_CHANGING_PARTITION on 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.sh gives 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.sh gives 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.py 1 passed, on PG17 and PG18. The census, 2785 / 2099 / 686, matches the tsv, and cluster_tests 566 matches collection.

  • Blocker: the per-row check leaks the delete vector into the statement. PgColumnarSerializeRowNow calls PgColumnarReadDeleteVectorList once 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 running UPDATE t SET v = v + 1 after INSERT 1..N and DELETE 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 PgColumnarSerializeRowNow in a temporary context deleted on return PRIMARY 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 PgColumnarSerializeFlushRows already 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

@jdatcmd

jdatcmd commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

@OffgridwithJD, both points are addressed in 4323c77, rebased onto main 7da848d.

The leak. PgColumnarSerializeRowNow now does its snapshot push, row-group lookup and mask read in a temporary context, which it deletes before returning, as you suggested and as PgColumnarSerializeFlushRows does. The memo is unaffected: its context is a child of TopTransactionContext.

  • New arm, in both twins (partition_rows.sh, test_a_keyed_update_keeps_its_memory_bounded). A keyed parent's partition holds 50,000 rows in one row group, with every even row deleted. A function called from the UPDATE records sum(total_bytes) from the backend's own pg_backend_memory_contexts at the first and the last row. The arm runs an UPDATE in place on one such table, and an UPDATE moving every row to the other partition on another. Each must grow by under 32 MB.
  • Red without the fix (PG17): both twins fail, by the same names. Growth is 402 MB for each UPDATE in the shell and 401 MB in pytest.
  • Green with it: growth is 1 MB. Both twins pass 15/15 on each of PG15 to PG19. update_conc passes 25/25 and unique_conc 33/33 on PG17.

The cost. I couldn't reproduce 3.4x. On PG17, three runs each, 100,000 rows updated (200,000 inserted, your shape):

main 7da848d this PR
no deletes 1231 / 1321 / 1849 ms 1634 / 1605 / 1619 ms
evens deleted 1313 / 1208 / 1201 ms 1380 / 1536 / 1410 ms

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, cluster_tests is 569 by collection, and the guard set passes 435/435.

🤖 Generated with Claude Code

jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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>
@jdatcmd
jdatcmd force-pushed the fix/1421-unique-update-40001 branch from 34ccb8a to 4323c77 Compare October 15, 2026 16:50

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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 + 1 on 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.sh gives 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_tests 569 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

jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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
jdatcmd added a commit that referenced this pull request Oct 15, 2026
… new head (#1341)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jdatcmd

jdatcmd commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

@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 OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

jdatcmd added a commit that referenced this pull request Oct 15, 2026
test: a prepared transaction, seen from other sessions and across a crash (#1341; stacked on #1422)
jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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>
@jdatcmd
jdatcmd force-pushed the fix/1421-unique-update-40001 branch from 1d6b236 to abdfadc Compare October 15, 2026 18:57
@jdatcmd

jdatcmd commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

@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 OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

jdatcmd and others added 4 commits October 15, 2026 19:56
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>
@jdatcmd

jdatcmd commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

@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; cluster_tests, matching collection; and the TESTS.md section number. The gate gives rc=0 and the guard set passes 435/435. I'll merge the chain in order as CI goes green.

🤖 Generated with Claude Code

This was referenced Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrent UPDATE of the same row fails with 23505, not 40001, when the table has a unique index

2 participants