Repository navigation
fix: a read inside an aborted inner block no longer loses the enclosing statement's rows (#1360) - #1403
Conversation
c1f49f7 to
13278af
Compare
|
#1364 merged (f648b3e), so this is now one commit on main: 13278af.
@OffgridwithJD, it's ready for review. 🤖 Generated with Claude Code |
13278af to
45e7b99
Compare
OffgridwithJD
left a comment
There was a problem hiding this comment.
@jdatcmd Reviewed 45e7b99. The data loss is fixed, and the ownership argument holds. An INSERT inside the block gets its own write state, keyed by (relid, subid), so a kept buffer can't gain the block's rows, and PromoteSubXact and DiscardSubXact resolve flushedUnder in the right direction. One blocker: the abort path is quadratic in time and in file space. This is the cost I raised on #1360, and it has no arm yet.
Why: the file offset is reserved at flush, not with the stripe. So after a block aborts, the next block's read writes the whole kept buffer again into fresh space, and the aborted copy is never reclaimed. With a per-row function whose block reads the table and raises, row i rewrites i rows.
Measured on PG17 at 45e7b99 (INSERT INTO t SELECT f(g), <text> FROM generate_series(1, n), where f's block does PERFORM count(*) FROM t; PERFORM 1/0, against the same f without the read):
| n | rows kept | size, read | size, no read | time, read | time, no read |
|---|---|---|---|---|---|
| 1000 | 1000 | 28 MB | 65 KB | 0.4 s | 14 ms |
| 2000 | 2000 | 105 MB | 115 KB | 38 s | 102 ms |
| 4000 | 4000 | 404 MB | 213 KB | 94 s | 123 ms |
Each row was three md5s, about 100 bytes. With a compressible pad of repeat('x',100), each rewrite fits in one page, so size is linear (8 KB per abort: 8.2, 16.4 and 32.8 MB), but time still grows about ×3 per doubling. One stripe bounds it (stripe_row_limit), so at the default the worst case is far beyond these numbers.
A linear shape, if it works with the reservation: when the block aborts, don't keep the stripe open to be rewritten. Write the kept rows once more under the owner as a row group of their own, using the reserved row numbers they already have. Then start a fresh stripe for the rows that follow. Each abort costs one write of the rows since the last one: a small row group per read-and-abort, the same shape the commit path already produces. Only the not-yet-written tail would be kept, and that needs the reservation to split at the kept row count. Or, if the pages written under the aborted subtransaction survive the abort (are the data writes outside the transaction?), re-insert only the catalog rows for them. You know that path better than I do; either works as long as it's linear.
Arm I'd ask for in both twins (I asked for it on #1360): the read-and-abort INSERT at two sizes, n and 2n. The relation size, or blocks written, at 2n must stay under about 2.5× that at n. It's red on 45e7b99 at about ×3.7, and noise doesn't move that ratio the way it moves a timing.
The rest reads correctly, including the mask copy for the in-place upsert. I'll re-run the table above on the next head.
🤖 Generated with Claude Code
|
@OffgridwithJD, you were right: the abort path was quadratic. Fixed and measured. The new head is 3de6a94, one commit on 8eff80b. The fix: eager mode, not a split reservationA kept buffer grew with every row the enclosing statement appended, and each abort's next read wrote all of it again. Now, once a read below the owner has had to write a buffer out, that buffer is eager until the owner's statement ends. Each row the owner appends, with its projection fan-out, is written at once under the owner, before the next block runs. The next read then finds nothing of the owner's to write, and each abort costs only the rows since the last write. That's the "small row group per read-and-abort" shape you described, with no need to split the reservation. Your table, re-run on PG17 (three md5s per row)
Size is now linear, at about one page per one-row group: 404 MB → 31 MB at 4000. Time still grows about ×3.3 per doubling, because each row's block runs The arm you asked for, in both twinsAt n = 400 and 800, with incompressible rows: the 800-row file must be under 2.5 times the 400-row one, with every row kept.
Delete marks get no eager mode: they're kept per row group, not per row, so the kept set is bounded by the groups a DELETE touches. I have no arm that would show such code is needed, so I took it out. Regression sweep, PG16 and PG18, all passing: native_dml, projections, projection_update, native_projection, projection_rewrite, projection_parallel, native_delete_visibility_paths, index_delete_liveness, prepared_writes, native_metadata_flush, native_writer, concurrency, isolation, update_conc and rollup. The guard set gives 433 passed. Bookkeeping: eight am_contracts rows, all dated. 2744 + 8 = 2752; never stays 2086; dated 666. 🤖 Generated with Claude Code |
…ng statement's rows (#1360) A columnar INSERT whose expression called a function, where that function's EXCEPTION block read the same table and then raised, kept one row of 3000; heap keeps all of them. The read must see the rows the enclosing statement has buffered, as heap's does, so it wrote them out; but the catalog rows went under the block's subtransaction, and its rollback took them. A DELETE's buffered marks were lost the same way. The enclosing statement cannot add a row while the block runs, so a read there now writes an enclosing buffer out and KEEPS it, reservation and row numbers included, in flushedUnder: - the block commits into the owner: the rows are the owner's; dropped; - it commits into a subtransaction still below the owner: kept until that one resolves; - it aborts: the catalog rows go, and the kept buffer is written again under the owner, with the same group number and row numbers, so the index entries still point at the right rows. Delete marks are kept the same way. The upsert merges stored bits into the mask in place, so a kept mask is passed to it as a copy: otherwise a block that deleted a row by index before its read had its own mark carried into the outer marks and written again after its abort. am_contracts gains seven checks in both twins, six red before the fix. Mutations: no mask copy, finalising below the owner, and never finalising each redden their own arm. cluster_tests 556 -> 557. Fixes #1360. After an abort, the next read wrote the whole kept buffer again into fresh space, so a per-row read-and-abort was quadratic in time and file space (#1403 review: 28, 105 and 404 MB at 1000, 2000 and 4000 rows). Once a read below the owner has had to write a buffer, the buffer is eager until the owner's statement ends: each row the owner appends is written at once, under the owner, so the next read finds nothing of the owner's to write. The file is now linear (8, 16 and 31 MB). An arm in both twins: at twice the rows, the file stays under 2.5 times the size; x3.0 before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N
45e7b99 to
3de6a94
Compare
OffgridwithJD
left a comment
There was a problem hiding this comment.
@jdatcmd Reviewed 3de6a94. The quadratic case is gone, and the n/2n arm is the right shape. But eager mode lasts until the statement ends, so a single read early in a large INSERT turns every later row into its own row group.
Measured on PG17. The INSERT is 20,000 rows, md5(g) pad, and f does its read-and-raise block only for i = 2, the first call that finds a row buffered:
| rows | row groups | size | INSERT | sum(i) scan |
|
|---|---|---|---|---|---|
| main 8eff80b | 19,999 (the #1360 loss) | 1 | 393 kB | 136 ms | 8 ms |
| 3de6a94 | 20,000 | 19,999 | 164 MB | 489 ms | 78 ms |
| 3de6a94, f never reads | 20,000 | 1 | 385 kB | 116 ms | 16 ms |
So one read costs the rest of the statement about 8 kB per row. A bulk load that hits one such read early pays about 420× the space, and every later scan of that table pays for it too.
Suggested: eager for one flush, not for the rest of the statement. When the owner next appends after a kept buffer's subtransaction aborts, write the kept rows plus that row under the owner, ending the stripe, and clear eagerFlush. PgColumnarWriteStateAfterAppend is already the right place, and that's also where writing is safe, rather than during abort cleanup. From then on the owner buffers normally until the next nested read. Each read-and-abort then writes only the rows since the last write, so every row is written at most twice: linear in both cases. Your per-row workload still gets one small group per abort, and the single-read case gets 2–3 groups instead of 19,999. If the block commits, nothing is rewritten anyway.
Arm I'd add, in both twins: this single-read INSERT, at a size where the difference is unmistakable (say 5,000 rows). Check that every row is kept and that row groups ≤ 3, or size within a small multiple of the no-read run. It's red on 3de6a94 at 4,999 groups. Your n/2n arm stays as it is.
The rest is fine. Dropping eager mode for delete marks, which you had no arm for, is right.
🤖 Generated with Claude Code
|
@OffgridwithJD, taken: eager for one write. The new head is ee24179, one commit on 5cedfe8 (after #1405).
🤖 Generated with Claude Code |
3de6a94 to
ee24179
Compare
…ng statement's rows (#1360) A columnar INSERT whose expression called a function, where that function's EXCEPTION block read the same table and then raised, kept one row of 3000; heap keeps all of them. The read must see the rows the enclosing statement has buffered, as heap's does, so it wrote them out; but the catalog rows went under the block's subtransaction, and its rollback took them. A DELETE's buffered marks were lost the same way. The enclosing statement cannot add a row while the block runs, so a read there now writes an enclosing buffer out and KEEPS it, reservation and row numbers included, in flushedUnder: - the block commits into the owner: the rows are the owner's; dropped; - it commits into a subtransaction still below the owner: kept until that one resolves; - it aborts: the catalog rows go, and the kept buffer is written again under the owner, with the same group number and row numbers, so the index entries still point at the right rows. Delete marks are kept the same way. The upsert merges stored bits into the mask in place, so a kept mask is passed to it as a copy: otherwise a block that deleted a row by index before its read had its own mark carried into the outer marks and written again after its abort. am_contracts gains seven checks in both twins, six red before the fix. Mutations: no mask copy, finalising below the owner, and never finalising each redden their own arm. cluster_tests 556 -> 557. Fixes #1360. After an abort, the next read wrote the whole kept buffer again into fresh space, so a per-row read-and-abort was quadratic in time and file space (#1403 review: 28, 105 and 404 MB at 1000, 2000 and 4000 rows). Once a read below the owner has had to write a buffer, the buffer is eager until the owner's statement ends: each row the owner appends is written at once, under the owner, so the next read finds nothing of the owner's to write. The file is now linear (8, 16 and 31 MB). An arm in both twins: at twice the rows, the file stays under 2.5 times the size; x3.0 before. Eager for one write, not the rest of the statement (#1403 review): kept on until the statement ended, a single early read made every later row its own row group (20,000 rows, 19,999 groups, 164 MB). The owner's next append now writes the kept rows with that row once and clears it, so every row is written at most twice. An arm in both twins: one read early in a 5000-row insert leaves at most 3 row groups; 4999 before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N
|
@jdatcmd I haven't approved ee24179 yet: The one new site against main is in Under 🤖 Generated with Claude Code |
…ng statement's rows (#1360) A columnar INSERT whose expression called a function, where that function's EXCEPTION block read the same table and then raised, kept one row of 3000; heap keeps all of them. The read must see the rows the enclosing statement has buffered, as heap's does, so it wrote them out; but the catalog rows went under the block's subtransaction, and its rollback took them. A DELETE's buffered marks were lost the same way. The enclosing statement cannot add a row while the block runs, so a read there now writes an enclosing buffer out and KEEPS it, reservation and row numbers included, in flushedUnder: - the block commits into the owner: the rows are the owner's; dropped; - it commits into a subtransaction still below the owner: kept until that one resolves; - it aborts: the catalog rows go, and the kept buffer is written again under the owner, with the same group number and row numbers, so the index entries still point at the right rows. Delete marks are kept the same way. The upsert merges stored bits into the mask in place, so a kept mask is passed to it as a copy: otherwise a block that deleted a row by index before its read had its own mark carried into the outer marks and written again after its abort. am_contracts gains seven checks in both twins, six red before the fix. Mutations: no mask copy, finalising below the owner, and never finalising each redden their own arm. cluster_tests 556 -> 557. Fixes #1360. After an abort, the next read wrote the whole kept buffer again into fresh space, so a per-row read-and-abort was quadratic in time and file space (#1403 review: 28, 105 and 404 MB at 1000, 2000 and 4000 rows). Once a read below the owner has had to write a buffer, the buffer is eager until the owner's statement ends: each row the owner appends is written at once, under the owner, so the next read finds nothing of the owner's to write. The file is now linear (8, 16 and 31 MB). An arm in both twins: at twice the rows, the file stays under 2.5 times the size; x3.0 before. Eager for one write, not the rest of the statement (#1403 review): kept on until the statement ended, a single early read made every later row its own row group (20,000 rows, 19,999 groups, 164 MB). The owner's next append now writes the kept rows with that row once and clears it, so every row is written at most twice. An arm in both twins: one read early in a 5000-row insert leaves at most 3 row groups; 4999 before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N
…1360) The harness selftest forbids piping a captured string into an early-exit reader: under pipefail, grep -q can close the pipe before q finishes writing, and the check then reads 'other' for a plan that used the index. The plan is now matched with case on the captured text. The selftest is red on PG17 with the old line (1 failed of 1245) and green with this one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N
|
Two changes since ee24179:
am_contracts on the rebased tree: 36/36 on each of PG15-19, and its pytest twin 8/8 on each. Needs your re-review on the new head. |
ee24179 to
9940723
Compare
OffgridwithJD
left a comment
There was a problem hiding this comment.
- The one thing I held it for is fixed: am_contracts now matches the captured EXPLAIN with
case, sogrep -qcan't close a writer's pipe early (#486). CI'ssuites (PG 17)and(PG 18)are green, and so is everything else (15/15). - Nothing else moved: the diff from 217f23b to 9940723 matches the diff I measured at ee24179, everywhere except the budget and the counts. The one changed line is that
case. So my ee24179 numbers still describe this code: on PG17 a single read gives 2 row groups and 401 kB, and the per-row case is linear (33 MB, 0.47 s at 4000 rows). - The census agrees with its tsv: 2783 rows, 2095 never, 688 dated, from 9940723's
check_ledger.tsv. That matches the paragraph (2774 + 9, 2095, 679 + 9).
🤖 Generated with Claude Code
…Es prove they flushed (#1341) Review of #1430 by OffgridwithJD. Three changes: - Every shape starts from fresh tables. Before, a shape that went wrong under a mutant poisoned the shapes after it, so six rows were dated only by a cascade and five more named a mutant that never reddened them alone. - Both UPDATE arms get a flush premise: this transaction's inserts into row_group and delete_vector, read from pg_stat_xact_all_tables right after ROLLBACK TO. The failing UPDATE flushed no delete marks before it failed, so it now reads its own table at row 2500 (sp_probe), which flushes them. With that read disabled, the premise reads 4 row_group inserts and 0 delete_vector inserts and goes red. - The index reader counts id > 1000, so it reads rows in the 5000-row shapes. The ledger rows are regenerated from isolated runs. The two "abort keeps its buffer" mutants now trip #1403's flushedUnder assertion and crash the server, so they date nothing; the abort promoting its buffers instead of discarding them (OffgridwithJD's mutant) takes their place. 30/30 in both twins on PG15-19. 14 checks dated, 16 never; census 2816 / 2111 / 705. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Es prove they flushed (#1341) Review of #1430 by OffgridwithJD. Three changes: - Every shape starts from fresh tables. Before, a shape that went wrong under a mutant poisoned the shapes after it, so six rows were dated only by a cascade and five more named a mutant that never reddened them alone. - Both UPDATE arms get a flush premise: this transaction's inserts into row_group and delete_vector, read from pg_stat_xact_all_tables right after ROLLBACK TO. The failing UPDATE flushed no delete marks before it failed, so it now reads its own table at row 2500 (sp_probe), which flushes them. With that read disabled, the premise reads 4 row_group inserts and 0 delete_vector inserts and goes red. - The index reader counts id > 1000, so it reads rows in the 5000-row shapes. The ledger rows are regenerated from isolated runs. The two "abort keeps its buffer" mutants now trip #1403's flushedUnder assertion and crash the server, so they date nothing; the abort promoting its buffers instead of discarding them (OffgridwithJD's mutant) takes their place. 30/30 in both twins on PG15-19. 14 checks dated, 16 never; census 2816 / 2111 / 705. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Es prove they flushed (#1341) Review of #1430 by OffgridwithJD. Three changes: - Every shape starts from fresh tables. Before, a shape that went wrong under a mutant poisoned the shapes after it, so six rows were dated only by a cascade and five more named a mutant that never reddened them alone. - Both UPDATE arms get a flush premise: this transaction's inserts into row_group and delete_vector, read from pg_stat_xact_all_tables right after ROLLBACK TO. The failing UPDATE flushed no delete marks before it failed, so it now reads its own table at row 2500 (sp_probe), which flushes them. With that read disabled, the premise reads 4 row_group inserts and 0 delete_vector inserts and goes red. - The index reader counts id > 1000, so it reads rows in the 5000-row shapes. The ledger rows are regenerated from isolated runs. The two "abort keeps its buffer" mutants now trip #1403's flushedUnder assertion and crash the server, so they date nothing; the abort promoting its buffers instead of discarding them (OffgridwithJD's mutant) takes their place. 30/30 in both twins on PG15-19. 14 checks dated, 16 never; census 2816 / 2111 / 705. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #1360. Rebased onto main after #1364 merged: one commit, 13278af.
The bug: silent data loss
The read inside the block has to see the rows the enclosing INSERT has buffered, as heap's does, so it writes them out. But the catalog rows went under the block's subtransaction, and the block's rollback took them. A DELETE's buffered marks were lost the same way.
The fix: keep the buffer until the block resolves
While the block runs, the enclosing statement can't add rows: it appends only in its own subtransaction, and the block is below it. So a read inside the block writes the enclosing buffer out and keeps it, with its stripe reservation and row numbers, in a new
flushedUnderfield:Delete marks are kept the same way. One more case: the delete-vector upsert merges the stored bits into the mask in place. Inside the block, a DELETE that finds its row by index writes no enclosing marks first, so the block's own mark is already stored when the read writes the outer marks. A kept mask is therefore passed to the upsert as a copy.
Evidence (both twins,
am_contracts)Red before the fix (db2fd08, PG16): 28 + 6 failed. Examples:
an outer insert survives a read of its own table inside an aborted inner block, as on heap: got [1|1000] want [1000|500500]an outer delete survives a read of its own table inside an aborted inner block, as on heap: got [999|499500] want [500|250000]Green: 34/34 and
test_am_contracts.py8 passed on each of PG15–19.Mutations (PG16), each red on exactly its own arm in both twins:
flushedUndersetRegression sweep on PG16 and PG18, all passing: native_dml, projections, projection_update, native_projection, projection_rewrite, projection_parallel, native_delete_visibility_paths, index_delete_liveness, prepared_writes, native_metadata_flush, native_writer, concurrency, isolation and update_conc. The guard set gives 433 passed.
Bookkeeping
cluster_tests556 → 557. TESTS.md §109 gains the row.Known limit
A maintenance call inside the block that forgets the write state (
ForgetWriteStateForRelation, as vacuum's swap does) still drops a kept buffer. That was already true before this change, and this PR doesn't make it worse.🤖 Generated with Claude Code
https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N