Thread
Commits
GET /api/v1/messages/:b64id/commits
the thread's linked commits as JSON, with link sources.
API reference →
-
Stabilize btree_gist test against on-access VM setting
- 62407d26b7c9 19 (unreleased) landed
-
Stabilize plancache test against on-access VM setting
- 85ae8ab05334 19 (unreleased) landed
-
Make local buffers pin limit more conservative
- da6874635db2 19 (unreleased) landed
-
Update FSM during prune/freeze replay even if freespace is zero
- b4c1b2be300e 19 (unreleased) landed
-
Pass down information on table modification to scan nodes
- 50eb5faea295 19 (unreleased) landed
-
Thread flags through begin-scan APIs
- dcd8cc1c852c 19 (unreleased) landed
-
Remove PlannedStmt->resultRelations in favor of resultRelationRelids
- 39dcd10a2c49 19 (unreleased) landed
-
Make it cheap to check if a relation is modified by a query
- 0f4c170cf3b8 19 (unreleased) landed
-
Keep newest live XID up-to-date even if page not all-visible
- 9ba3ec076a68 19 (unreleased) landed
-
Add pruning fast path for all-visible and all-frozen pages
- 01b7e4a46d0f 19 (unreleased) landed
-
Detect and fix visibility map corruption in more cases
- 4f7ecca84dda 19 (unreleased) landed
-
Save vmbuffer in heap-specific scan descriptors for on-access pruning
- 99bf1f8aa6cd 19 (unreleased) landed
-
Avoid BufferGetPage() calls in heap_update()
- 8d2c1df4f4c5 19 (unreleased) landed
-
Initialize missing fields in CreateExecutorState()
- a3511443e5e1 19 (unreleased) landed
-
Prefix PruneState->all_{visible,frozen} with set_
- 34cb4254bdb6 19 (unreleased) landed
-
Add PageGetPruneXid() helper
- 68c2dcb9130e 19 (unreleased) landed
-
Move commonly used context into PruneState and simplify helpers
- 59663e4207fd 19 (unreleased) landed
-
Remove table_scan_analyze_next_tuple unneeded parameter OldestXmin
- 284925508ae6 19 (unreleased) landed
-
Simplify visibility check in heap_page_would_be_all_visible()
- 3efe58febc3c 19 (unreleased) landed
-
Rename LVRelState VM-related logging counters
- 5aea60839ba3 19 (unreleased) landed
-
Fix flakiness in the pg_visibility VM-only vacuum test by using a temporary table.
- 4a99ef1a0d11 19 (unreleased) landed
-
Refactor lazy_scan_prune() VM clear logic into helper
- 19af794b660e 19 (unreleased) landed
-
Eliminate use of cached VM value in lazy_scan_prune()
- 648a7e28d7c2 19 (unreleased) landed
-
Combine visibilitymap_set() cases in lazy_scan_prune()
- 21796c267d0a 19 (unreleased) landed
-
Fix const qualification in prune_freeze_setup()
- 4877391ce894 19 (unreleased) landed
-
Simplify vacuum visibility assertion
- bd298f54a0d6 19 (unreleased) landed
-
Split heap_page_prune_and_freeze() into helpers
- e135e044572e 19 (unreleased) landed
-
Assert that cutoffs are provided if freezing will be attempted
- cd38b7e77315 19 (unreleased) landed
-
Split PruneFreezeParams initializers to one field per line
- 1e14edcea5e1 19 (unreleased) landed
-
Refactor heap_page_prune_and_freeze() parameters into a struct
- 1937ed70621e 19 (unreleased) landed
-
Make heap_page_is_all_visible independent of LVRelState
- 3e4705484e0c 19 (unreleased) landed
-
Inline TransactionIdFollows/Precedes[OrEquals]()
- 43b05b38ea4d 19 (unreleased) landed
-
Add helper for freeze determination to heap_page_prune_and_freeze
- c8dd6542bae4 19 (unreleased) landed
-
Bump XLOG_PAGE_MAGIC after xl_heap_prune change
- 4a8fb58671d3 19 (unreleased) landed
-
Correct prune WAL record opcode name in comment
- ae8ea7278c16 19 (unreleased) landed
-
Add error codes when vacuum discovers VM corruption
- 8ec97e78a771 19 (unreleased) landed
-
Remove unused xl_heap_prune member, reason
- 4b5f206de2bb 19 (unreleased) landed
-
Remove unneeded VM pin from VM replay
- 3399c265543e 19 (unreleased) landed
-
Add assert and log message to visibilitymap_set
- e3d5ddb7ca91 19 (unreleased) landed
-
Stamp HEAD as 19devel.
- 2652835d3efa 19 (unreleased) cited
-
Add error codes to some corruption log messages
- fd6ec93bf890 13.0 cited
-
eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-06-23T20:25:16Z
Hi, The attached patch set eliminates xl_heap_visible, the WAL record emitted when a block of the heap is set all-visible/frozen in the visibility map. Instead, it includes the information needed to update the VM in the WAL record already emitted by the operation modifying the heap page. Currently COPY FREEZE and vacuum are the only operations that set the VM. So, this patch modifies the xl_heap_multi_insert and xl_heap_prune records. The result is a dramatic reduction in WAL volume for these operations. I've included numbers below. I also think that it makes more sense to include changes to the VM in the same WAL record as the changes that rendered the page all-visible. In some cases, we will only set the page all-visible, but that is in the context of the operation on the heap page which discovered that it was all-visible. Therefore, I find this to be a clarity as well as a performance improvement. This project is also the first step toward setting the VM on-access for queries which do not modify the page. There are a few design issues that must be sorted out for that project which I will detail separately. Note that this patch set currently does not implement setting the VM on-access. The attached patch set isn't 100% polished. I think some of the variable names and comments could use work, but I'd like to validate the idea of doing this before doing a full polish. This is a summary of what is in the set: Patches: 0001 - 0002: cleanup 0003 - 0004: refactoring 0005: COPY FREEZE changes 0006: refactoring 0007: vacuum phase III changes 0008: vacuum phase I empty page changes 0009 - 0012: refactoring 0013: vacuum phase I normal page changes 0014: cleanup Performance benefits of eliminating xl_heap_visible: vacuum of table with index (DDL at bottom of email) -- master -> patch WAL bytes: 405346 -> 303088 = 25% reduction WAL records: 6682 -> 4459 = 33% reduction vacuum of table without index -- master -> patch WAL records: 4452 -> 2231 = 50% reduction WAL bytes: 289016 -> 177978 = 38% reduction COPY FREEZE of table without index -- master -> patch WAL records: 3672777 -> 1854589 = 50% reduction WAL bytes: 841340339 -> 748545732 = 11% reduction (new pages need a copy of the whole page) table for vacuum example: -- create table foo(a int, b numeric, c numeric) with (autovacuum_enabled= false); insert into foo select i % 18, repeat('1', 400)::numeric, repeat('2', 400)::numeric from generate_series(1,40000)i; -- don't make index for no-index case create index on foo(a); delete from foo where a = 1; vacuum (verbose, process_toast false) foo; copy freeze example: -- -- create a data file create table large(a int, b int) with (autovacuum_enabled = false, fillfactor = 10); insert into large SELECT generate_series(1,40000000)i, 1; copy large to 'large.data'; -- example BEGIN; create table large(a int, b int) with (autovacuum_enabled = false, fillfactor = 10); COPY large FROM 'large.data' WITH (FREEZE); COMMIT; - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-06-26T22:04:34Z
On Mon, Jun 23, 2025 at 4:25 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > The attached patch set eliminates xl_heap_visible, the WAL record > emitted when a block of the heap is set all-visible/frozen in the > visibility map. Instead, it includes the information needed to update > the VM in the WAL record already emitted by the operation modifying > the heap page. Rebased in light of recent changes on master: 0001: cleanup 0002: preparatory work 0003: eliminate xl_heap_visible for COPY FREEZE 0004 - 0005: eliminate xl_heap_visible for vacuum's phase III 0006: eliminate xl_heap_visible for vacuum phase I empty pages 0007 - 0010: preparatory refactoring 0011: eliminate xl_heap_visible from vacuum phase I prune/freeze 0012: remove xl_heap_visible - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-07-09T21:59:26Z
On Thu, Jun 26, 2025 at 6:04 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > Rebased in light of recent changes on master: This needed another rebase, and, in light of the discussion in [1], I've also removed the patch to add heap wrappers for setting pages all-visible. More notably, the final patch (0012) in attached v3 allows on-access pruning to set the VM. To do this, it plumbs some information down from the executor to the table scan about whether or not the table is modified by the query. We don't want to set the VM only to clear it while scanning pages for an UPDATE or while locking rows in a SELECT FOR UPDATE. Because we only do on-access pruning when pd_prune_xid is valid, we shouldn't need much of a heuristic for deciding when to set the VM on-access -- but I've included one anyway: we only do it if we are actually pruning or if the page is already dirty and no FPI would be emitted. You can see it in action with the following: create extension pg_visibility; create table foo (a int, b int) with (autovacuum_enabled=false, fillfactor=90); insert into foo select generate_series(1,300), generate_series(1,300); create index on foo (a); update foo set b = 51 where b = 50; select * from foo where a = 50; select * from pg_visibility_map_summary('foo'); The SELECT will set a page all-visible in the VM. In this patch set, on-access pruning is enabled for sequential scans and the underlying heap relation in index scans and bitmap heap scans. This example can exercise any of the three if you toggle enable_indexscan and enable_bitmapscan appropriately. From a performance perspective, If you run a trivial pgbench, you can see far more all-visible pages set in the pgbench_[x] relations with no noticeable overhead. But, I'm planning to do some performance experiments to show how this affects our ability to choose index only scan plans in realistic workloads. - Melanie [1] https://www.postgresql.org/message-id/CAAKRu_Yj%3DyrL%2BgGGsqfYVQcYn7rDp6hDeoF1vN453JDp8dEY%2Bw%40mail.gmail.com -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-07-11T22:19:15Z
On Wed, Jul 9, 2025 at 5:59 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Thu, Jun 26, 2025 at 6:04 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > > Rebased in light of recent changes on master: > > This needed another rebase, and, in light of the discussion in [1], > I've also removed the patch to add heap wrappers for setting pages > all-visible. Andrey Borodin made the excellent point off-list that I forgot to remove the xl_heap_visible struct itself -- which is rather important to a patch set purporting to eliminate xl_heap_visible! New version attached. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andrey Borodin <x4mmm@yandex-team.ru> — 2025-07-13T18:34:26Z
> On 12 Jul 2025, at 03:19, Melanie Plageman <melanieplageman@gmail.com> wrote: > > remove the xl_heap_visible struct Same goes for VISIBILITYMAP_XLOG_CATALOG_REL and XLOG_HEAP2_VISIBLE. But please do not rush to remove it, perhaps I will have a more exhaustive list later. Currently the patch set is expected to be unpolished. I just need to absorb all effects to have a high-level evaluation of the patch set effect. I'm still trying to grasp connection of first patch with Assert(prstate->cutoffs) to other patches; Also, I'd prefer "page is not marked all-visible but visibility map bit is set in relation" to emit XX001 for monitoring reasons, but again, this is small note, while I need a broader picture. So far I do not see any general problems in delegating redo work from xl_heap_visible to other record. FWIW I observed several cases of VM corruptions that might be connected to the fact that we log VM changes independently of data changes that caused VM to change. But I have no real evidence or understanding what happened. Best regards, Andrey Borodin.
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-07-13T19:15:22Z
On Sun, Jul 13, 2025 at 2:34 PM Andrey Borodin <x4mmm@yandex-team.ru> wrote: > > > On 12 Jul 2025, at 03:19, Melanie Plageman <melanieplageman@gmail.com> wrote: > > > > remove the xl_heap_visible struct > > Same goes for VISIBILITYMAP_XLOG_CATALOG_REL and XLOG_HEAP2_VISIBLE. But please do not rush to remove it, perhaps I will have a more exhaustive list later. Currently the patch set is expected to be unpolished. > I just need to absorb all effects to have a high-level evaluation of the patch set effect. I actually did remove those if you check the last version posted. I did notice there is one remaining comment referring to XLOG_HEAP2_VISIBLE I missed somehow, but the actual enums/macros were removed already. > I'm still trying to grasp connection of first patch with Assert(prstate->cutoffs) to other patches; I added this because I noticed that it was used without validating it was provided in that location. The last patch in the set which sets the VM on access changes where cutoffs are used, so I noticed what I felt was a missing assert in master while developing that page. > Also, I'd prefer "page is not marked all-visible but visibility map bit is set in relation" to emit XX001 for monitoring reasons, but again, this is small note, while I need a broader picture. Could you clarify what you mean by this? Are you talking about the string representation of the visibility map bits in the WAL record representations in heapdesc.c? - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andrey Borodin <x4mmm@yandex-team.ru> — 2025-07-14T06:37:47Z
> On 14 Jul 2025, at 00:15, Melanie Plageman <melanieplageman@gmail.com> wrote: > >> >> Also, I'd prefer "page is not marked all-visible but visibility map bit is set in relation" to emit XX001 for monitoring reasons, but again, this is small note, while I need a broader picture. > > Could you clarify what you mean by this? Are you talking about the > string representation of the visibility map bits in the WAL record > representations in heapdesc.c? This might be a bit off-topic for this thread, but as long as the patch touches that code we can look into this too. If VM bit all-visible is set while page is not all-visible IndexOnlyScan will show incorrect results. I observed this inconsistency few times on production. Two persistent subsystems (VM and heap) contradict each other, that's why I think this is a data corruption. Yes, we can repair the VM by assuming heap to be the source of truth in this case. But we must also emit ERRCODE_DATA_CORRUPTED XX001 code into the logs. In many cases this will alert on-call SRE. To do so I propose to replace elog(WARNING,...) with ereport(WARNING,(errcode(ERRCODE_DATA_CORRUPTED),..). Best regards, Andrey Borodin.
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-07-31T22:58:11Z
Thanks for continuing to take a look, Andrey. On Mon, Jul 14, 2025 at 2:37 AM Andrey Borodin <x4mmm@yandex-team.ru> wrote: > > This might be a bit off-topic for this thread, but as long as the patch touches that code we can look into this too. > > If VM bit all-visible is set while page is not all-visible IndexOnlyScan will show incorrect results. I observed this inconsistency few times on production. That's very unfortunate. I wonder what could be causing this. Do you suspect a bug in Postgres? Or something wrong with the disk, etc? > Two persistent subsystems (VM and heap) contradict each other, that's why I think this is a data corruption. Yes, we can repair the VM by assuming heap to be the source of truth in this case. But we must also emit ERRCODE_DATA_CORRUPTED XX001 code into the logs. In many cases this will alert on-call SRE. > > To do so I propose to replace elog(WARNING,...) with ereport(WARNING,(errcode(ERRCODE_DATA_CORRUPTED),..). Ah, you mean the warnings currently in lazy_scan_prune(). To me this suggestion makes sense. I see at least one other example with ERRCODE_DATA_CORRUPTED that is an error level below ERROR. I have attached a cleaned up and updated version of the patch set (it doesn't yet include your suggested error message change). What's new in this version ----- In addition to general code, comment, and commit message improvements, notable changes are as follows: - I have used the GlobalVisState for determining if the whole page is visible in a more natural way. - I micro-benchmarked and identified some sources of regression in the additional code SELECT queries would do to set the VM. So, there are several new commits addressing these (for example inlining several functions and unsetting all-visible when we see a dead tuple if we won't attempt freezing). - Because heap_page_prune_and_freeze() was getting long, I added some helper functions. Performance impact of setting the VM on-access ------- I found that with the patch set applied, we set many pages all-visible in the VM on access, resulting in a higher overall number of pages set all-visible, reducing load for vacuum, and dramatically decreasing heap fetches by index-only scans. I devised a simple benchmark -- with 8 workers inserting 20 rows at a time into a table with a few columns and updating a single row that they just inserted. Another worker queries the table 1x second using an index. After running the benchmark for a few minutes, though the table was autovacuumed several times in both cases, with the patchset applied, 15% more blocks were all-visible at the end of the benchmark. And with my patch applied, index-only scans did far fewer heap fetches. A SELECT count(*) of the table at the same point in the benchmark did 10,000 heap fetches on master and 500 with the patch applied (I used auto_explain to determine this). With my patch applied, autovacuum workers write half as much WAL as on master. Some of this is courtesy of other patches in the set which eliminate separate WAL records for setting the page all-visible. But, vacuum is also scanning fewer pages and dirtying fewer buffers because they are being set all-visible on-access. There are more details about the benchmark at the end of the email. Setting pd_prune_xid on insert ------ The patch "Set-pd_prune_xid-on-insert.txt" can be applied as the last patch in the set. It sets pd_prune_xid on insert (so pages filled by COPY or insert can also be set all-visible in the VM before they are vacuumed). I gave it a .txt extension because it currently fails 035_standby_logical_decoding due to a recovery conflict. I need to investigate more to see if this is a bug in my patch set or elsewhere in Postgres. Besides the failing test, I have a feeling that my current heuristic for whether or not to set the VM on-access is not quite right for pages that have only been inserted to -- and if we get it wrong, we've wasted those CPU cycles because we didn't otherwise need to prune the page. - Melanie Benchmark ------- psql -c " DROP TABLE IF EXISTS simple_table; CREATE TABLE simple_table ( id SERIAL PRIMARY KEY, group_id INT NOT NULL, data TEXT, created_at TIMESTAMPTZ DEFAULT now() ); create index on simple_table(group_id); " pgbench \ --no-vacuum \ --random-seed=0 \ -c 8 \ -j 8 \ -M prepared \ -T 200 \ > "pgbench_run_summary_update_${version}" \ -f- <<EOF & \set gid random(1,1000) INSERT INTO simple_table (group_id, data) SELECT :gid, 'inserted' RETURNING id \gset update simple_table set data = 'updated' where id = :id; insert into simple_table (group_id, data) select :gid, 'inserted' from generate_series(1,20); EOF insert_pid=$! pgbench \ --no-vacuum \ --random-seed=0 \ -c 1 \ -j 1 \ --rate=1 \ -M prepared \ -T 200 \ > "pgbench_run_summary_select_${version}" \ -f- <<EOF \set gid random(1, 1000) select max(created_at) from simple_table where group_id = :gid; select count(*) from simple_table where group_id = :gid; EOF wait $insert_pid -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-08-01T21:36:19Z
On Thu, Jul 31, 2025 at 6:58 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > The patch "Set-pd_prune_xid-on-insert.txt" can be applied as the last > patch in the set. It sets pd_prune_xid on insert (so pages filled by > COPY or insert can also be set all-visible in the VM before they are > vacuumed). I gave it a .txt extension because it currently fails > 035_standby_logical_decoding due to a recovery conflict. I need to > investigate more to see if this is a bug in my patch set or elsewhere > in Postgres. I figured out that if we set the VM on-access, we need to enable hot_standby_feedback in more places in 035_standby_logical_decoding.pl to avoid recovery conflicts. I've done that in the attached updated version 6. There are a few other issues in 035_standby_logical_decoding.pl that I reported here [1]. With these changes, setting pd_prune_xid on insert passes tests. Whether or not we want to do it (and what the heuristic should be for deciding when to do it) is another question. - Melanie [1] https://www.postgresql.org/message-id/flat/CAAKRu_YO2mEm%3DZWZKPjTMU%3DgW5Y83_KMi_1cr51JwavH0ctd7w%40mail.gmail.com
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-08-26T09:58:28Z
On Sat, 2 Aug 2025 at 02:36, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Thu, Jul 31, 2025 at 6:58 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > > The patch "Set-pd_prune_xid-on-insert.txt" can be applied as the last > > patch in the set. It sets pd_prune_xid on insert (so pages filled by > > COPY or insert can also be set all-visible in the VM before they are > > vacuumed). I gave it a .txt extension because it currently fails > > 035_standby_logical_decoding due to a recovery conflict. I need to > > investigate more to see if this is a bug in my patch set or elsewhere > > in Postgres. > > I figured out that if we set the VM on-access, we need to enable > hot_standby_feedback in more places in 035_standby_logical_decoding.pl > to avoid recovery conflicts. I've done that in the attached updated > version 6. There are a few other issues in > 035_standby_logical_decoding.pl that I reported here [1]. With these > changes, setting pd_prune_xid on insert passes tests. Whether or not > we want to do it (and what the heuristic should be for deciding when > to do it) is another question. > > - Melanie > > [1] https://www.postgresql.org/message-id/flat/CAAKRu_YO2mEm%3DZWZKPjTMU%3DgW5Y83_KMi_1cr51JwavH0ctd7w%40mail.gmail.com Hi! Andrey told me off-list about this thread and I decided to take a look. I tried to play with each patch in this patchset and find a corruption, but I was unsuccessful. I will conduct further tests later. I am not implying that I suspect this patchset causes any corruption; I am merely attempting to verify it. I also have few comments and questions. Here is my (very limited) review of 0001, because I believe that removing xl_heap_visible from COPY FREEZE is pure win, so this patch can be very beneficial by itself. visibilitymap_set_vmbyte is introduced in 0001 and removed in 0012. This is strange to me, maybe we can avoid visibilitymap_set_vmbyte in first place? In 0001: 1) should we add "Assert(LWLockHeldByMeInMode(BufferDescriptorGetContentLock(bufHdr), LW_EXCLUSIVE));" in visibilitymap_set_vmbyte? Also here `Assert(visibilitymap_pin_ok(BufferGetBlockNumber(buffer), vmbuffer));` can be beneficial: >/* >+ * If we're only adding already frozen rows to a previously empty >+ * page, mark it as all-frozen and update the visibility map. We're >+ * already holding a pin on the vmbuffer. >+ */ > else if (all_frozen_set) >+ { > PageSetAllVisible(page); >+ LockBuffer(vmbuffer, BUFFER_LOCK_EXCLUSIVE); >+ visibilitymap_set_vmbyte(relation, >+ BufferGetBlockNumber(buffer), >+ vmbuffer, >+ VISIBILITYMAP_ALL_VISIBLE | >+ VISIBILITYMAP_ALL_FROZEN); >+ } 2) in heap_xlog_multi_insert: + + visibilitymap_pin(reln, blkno, &vmbuffer); + visibilitymap_set_vmbyte(....) Do we need to pin vmbuffer here? Looks like XLogReadBufferForRedoExtended already pins vmbuffer. I verified this with CheckBufferIsPinnedOnce(vmbuffer) just before visibilitymap_pin and COPY ... WITH (FREEZE true) test. 3) >+ > +#ifdef TRACE_VISIBILITYMAP > + elog(DEBUG1, "vm_set %s %d", RelationGetRelationName(rel), heapBlk); > +#endif I can see this merely copy-pasted from visibilitymap_set, but maybe display "flags" also? 4) visibilitymap_set receives XLogRecPtr recptr parameters, which is set to WAL record lsn during recovery and to InvalidXLogRecPtr otherwise. visibilitymap_set manages VM page LSN bases on this recptr value (inside function logic). visibilitymap_set_vmbyte behaves vise-versa and makes its caller responsible for page LSN management. Maybe we should keep these two functions akin to each other? -- Best regards, Kirill Reshke -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-08-26T20:01:24Z
On Sat, 2 Aug 2025 at 02:36, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Thu, Jul 31, 2025 at 6:58 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > > The patch "Set-pd_prune_xid-on-insert.txt" can be applied as the last > > patch in the set. It sets pd_prune_xid on insert (so pages filled by > > COPY or insert can also be set all-visible in the VM before they are > > vacuumed). I gave it a .txt extension because it currently fails > > 035_standby_logical_decoding due to a recovery conflict. I need to > > investigate more to see if this is a bug in my patch set or elsewhere > > in Postgres. > > I figured out that if we set the VM on-access, we need to enable > hot_standby_feedback in more places in 035_standby_logical_decoding.pl > to avoid recovery conflicts. I've done that in the attached updated > version 6. There are a few other issues in > 035_standby_logical_decoding.pl that I reported here [1]. With these > changes, setting pd_prune_xid on insert passes tests. Whether or not > we want to do it (and what the heuristic should be for deciding when > to do it) is another question. > > - Melanie > > [1] https://www.postgresql.org/message-id/flat/CAAKRu_YO2mEm%3DZWZKPjTMU%3DgW5Y83_KMi_1cr51JwavH0ctd7w%40mail.gmail.com 0002 No comments from me. Looks straightforward. Few comments on 0003. 1) This patch introduces XLHP_HAS_VMFLAGS. However it lacks some helpful comments about this new status bit. a) In heapam_xlog.h, in xl_heap_prune struct definition: /* * If XLHP_HAS_CONFLICT_HORIZON is set, the conflict horizon XID follows, * unaligned */ + /* If XLHP_HAS_VMFLAGS is set, newly set visibility map bits comes, unaligned */ b) we can add here comment why we use memcpy assignment, akin to /* memcpy() because snapshot_conflict_horizon is stored unaligned */ + /* Next are the optionally included vmflags. Copy them out for later use. */ + if ((xlrec.flags & XLHP_HAS_VMFLAGS) != 0) + { + memcpy(&vmflags, maindataptr, sizeof(uint8)); + maindataptr += sizeof(uint8); 2) Should we move conflict_xid = visibility_cutoff_xid; assignment just after heap_page_is_all_visible_except_lpdead call in lazy_vacuum_heap_page? 3) Looking at this diff, do not comprehend one bit: how are we protected from passing an all-visible page to lazy_vacuum_heap_page. I did not manage to reproduce such behaviour though. + if ((vmflags & VISIBILITYMAP_VALID_BITS) != 0) + { + Assert(!PageIsAllVisible(page)); + set_pd_all_vis = true; + LockBuffer(vmbuffer, BUFFER_LOCK_EXCLUSIVE); + PageSetAllVisible(page); + visibilitymap_set_vmbyte(vacrel->rel, + blkno, + -- Best regards, Kirill Reshke -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-08-27T12:55:27Z
On Sat, 2 Aug 2025 at 02:36, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Thu, Jul 31, 2025 at 6:58 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > > The patch "Set-pd_prune_xid-on-insert.txt" can be applied as the last > > patch in the set. It sets pd_prune_xid on insert (so pages filled by > > COPY or insert can also be set all-visible in the VM before they are > > vacuumed). I gave it a .txt extension because it currently fails > > 035_standby_logical_decoding due to a recovery conflict. I need to > > investigate more to see if this is a bug in my patch set or elsewhere > > in Postgres. > > I figured out that if we set the VM on-access, we need to enable > hot_standby_feedback in more places in 035_standby_logical_decoding.pl > to avoid recovery conflicts. I've done that in the attached updated > version 6. There are a few other issues in > 035_standby_logical_decoding.pl that I reported here [1]. With these > changes, setting pd_prune_xid on insert passes tests. Whether or not > we want to do it (and what the heuristic should be for deciding when > to do it) is another question. > > - Melanie > > [1] https://www.postgresql.org/message-id/flat/CAAKRu_YO2mEm%3DZWZKPjTMU%3DgW5Y83_KMi_1cr51JwavH0ctd7w%40mail.gmail.com v6-0015: I chose to verify whether this single modification would be beneficial on the HEAD. Benchmark I did: ``` \timing CREATE TABLE zz(i int); alter table zz set (autovacuum_enabled = false); TRUNCATE zz; copy zz from program 'yes 2 | head -n 180000000'; copy zz from program 'yes 2 | head -n 180000000'; delete from zz where (REPLACE(REPLACE(ctid::text, '(', '{'), ')', '}')::int[])[2] = 7 ; VACUUM FREEZE zz; ``` And I checked perf top footprint for last statement (vacuum). My detailed results are attached. It is a HEAD vs HEAD+v6-0015 benchmark. TLDR: function inlining is indeed beneficial, TransactionIdPrecedes function disappears from perf top footprint, though query runtime is not changed much. So, while not resulting in query speedup, this can save CPU. Maybe we can derive an artificial benchmark, which will show query speed up, but for now I dont have one. -- Best regards, Kirill Reshke -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-08-27T13:08:28Z
Thanks for all the reviews. I'm working on responding to your previous mails with a new version. On Wed, Aug 27, 2025 at 8:55 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > v6-0015: > I chose to verify whether this single modification would be beneficial > on the HEAD. > > Benchmark I did: > > ``` > > \timing > CREATE TABLE zz(i int); > alter table zz set (autovacuum_enabled = false); > TRUNCATE zz; > copy zz from program 'yes 2 | head -n 180000000'; > copy zz from program 'yes 2 | head -n 180000000'; > > delete from zz where (REPLACE(REPLACE(ctid::text, '(', '{'), ')', > '}')::int[])[2] = 7 ; > > VACUUM FREEZE zz; > ``` > > And I checked perf top footprint for last statement (vacuum). My > detailed results are attached. It is a HEAD vs HEAD+v6-0015 benchmark. > > TLDR: function inlining is indeed beneficial, TransactionIdPrecedes > function disappears from perf top footprint, though query runtime is > not changed much. So, while not resulting in query speedup, this can > save CPU. > Maybe we can derive an artificial benchmark, which will show query > speed up, but for now I dont have one. I'm not surprised that vacuum freeze does not show a speed up from the function inlining. This patch was key for avoiding a regression in the most contrived worst case scenario example of setting the VM on-access. That is, if you are pruning only a single tuple on the page as part of a SELECT query that returns no tuples (think SELECT * FROM foo OFFSET N where N is greater than the number of rows in the table), and I add determining if the page is all visible, then the overhead of these extra function calls in heap_prune_record_unchanged_lp_normal() is noticeable. We might be able to come up with a similar example in vacuum without freeze since it will try to determine if the page is all-visible. Your example is still running on my machine, though, so I haven't verified this yet :) - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-08-27T19:02:01Z
Thanks for the review! Updates are in attached v7. One note on 0022 in the set, which sets pd_prune_xid on insert: the recently added index-killtuples isolation test was failing with this patch applied. With the patch, the "access" step reports more heap page hits than before. After some analysis, it seems one of the heap pages in kill_prior_tuples table is now being pruned in an earlier step. Somehow this leads to 4 hits counted instead of 3 (even though there are only 4 blocks in the relation). I recall Bertrand mentioning something in some other thread about hits being double counted with AIO reads, so I'm going to try and go dig that up. But, for now, I've modified the test -- I believe the patch is only revealing an issue with instrumentation, not causing a bug. On Tue, Aug 26, 2025 at 5:58 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > visibilitymap_set_vmbyte is introduced in 0001 and removed in 0012. > This is strange to me, maybe we can avoid visibilitymap_set_vmbyte in > first place? The reason for this is that in the earlier patch I introduce visibilitymap_set_vmbyte() for one user while other users still use visibilitymap_set(). But, by the end of the set, all users use visibillitymap_set_vmbyte(). So I think it makes most sense for it to be named visibilitymap_set() at that point. Until all users are committed, the two functions both have to exist and need different names. > In 0001: > should we add "Assert(LWLockHeldByMeInMode(BufferDescriptorGetContentLock(bufHdr), > LW_EXCLUSIVE));" in visibilitymap_set_vmbyte? I don't want any operations on the heap buffer (including asserts) in visibilitymap_set_vmbyte(). The heap block is only provided to look up the VM bits. I think your idea is a good one for the existing visibilitymap_set(), though, so I've added a new patch to the set (0002) which does this. I also added a similar assertion for the vmbuffer to visibilitymap_set_vmbyte(). > Also here `Assert(visibilitymap_pin_ok(BufferGetBlockNumber(buffer), > vmbuffer));` can be beneficial: I had omitted this because the same logic is checked inside of visiblitymap_set_vmbyte() with an error occurring if conditions are not met. However, since the same is true in visibilitymap_set() and heap_multi_insert() still asserted visiblitymap_pin_ok(), I've added it back to my patch set. > in heap_xlog_multi_insert: > + > + visibilitymap_pin(reln, blkno, &vmbuffer); > + visibilitymap_set_vmbyte(....) > > Do we need to pin vmbuffer here? Looks like > XLogReadBufferForRedoExtended already pins vmbuffer. I verified this > with CheckBufferIsPinnedOnce(vmbuffer) just before visibilitymap_pin > and COPY ... WITH (FREEZE true) test. I thought the reason visibilitymap_set() did it was that it was possible for the block of the VM corresponding to the block of the heap to be different during recovery than it was when emitting the record, and thus we needed the part of visiblitymap_pin() that released the old vmbuffer and got the new one corresponding to the heap block. I can't quite think of how this could happen though. Assuming it can't happen, then we can get rid of visiblitymap_pin() (and add visibilitymap_pin_ok()) in both visiblitymap_set_vmbyte() and visibilitymap_set(). I've done this to visibilitymap_set() in a separate patch 0001. I would like other opinions/confirmation that the block of the VM corresponding to the heap block cannot differ during recovery from that what it was when the record was emitted during normal operation, though. > > +#ifdef TRACE_VISIBILITYMAP > > + elog(DEBUG1, "vm_set %s %d", RelationGetRelationName(rel), heapBlk); > > +#endif > > I can see this merely copy-pasted from visibilitymap_set, but maybe > display "flags" also? Done in attached. > 4) visibilitymap_set receives XLogRecPtr recptr parameters, which is > set to WAL record lsn during recovery and to InvalidXLogRecPtr > otherwise. visibilitymap_set manages VM page LSN bases on this recptr > value (inside function logic). visibilitymap_set_vmbyte behaves > vise-versa and makes its caller responsible for page LSN management. > Maybe we should keep these two functions akin to each other? So, with visibilitymap_set_vmbyte(), the whole idea is to just update the VM and then leave the WAL logging and other changes to the caller (like marking the buffer dirty, setting the page LSN, etc). The series of operations needed to make a persistent change are up to the caller. visibilitymap_set() is meant to just make sure that the correct bits in the VM are set for the given heap block. I looked at ways of making the current visibilitymap_set() API cleaner -- like setting the heap page LSN with the VM recptr in the caller of visibilitymap_set() instead. There wasn't a way of doing it that seemed like enough of an improvement to merit the change. Not to mention, the goal of the patchset is to remove the current visibilitymap_set(), so I'm not too worried about parity between the two functions. They may coexist for awhile, but hopefully today's visibilitymap_set() will eventually be removed. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-08-27T19:08:41Z
On Tue, Aug 26, 2025 at 4:01 PM Kirill Reshke <reshkekirill@gmail.com> wrote: > > Few comments on 0003. > > 1) This patch introduces XLHP_HAS_VMFLAGS. However it lacks some > helpful comments about this new status bit. I added the ones you suggested in my v7 posted here [1]. > 2) Should we move conflict_xid = visibility_cutoff_xid; assignment > just after heap_page_is_all_visible_except_lpdead call in > lazy_vacuum_heap_page? Why would we want to do that? We only want to set it if the page is all visible, so we would have to guard it similarly. > 3) Looking at this diff, do not comprehend one bit: how are we > protected from passing an all-visible page to lazy_vacuum_heap_page. I > did not manage to reproduce such behaviour though. > > + if ((vmflags & VISIBILITYMAP_VALID_BITS) != 0) > + { > + Assert(!PageIsAllVisible(page)); > + set_pd_all_vis = true; > + LockBuffer(vmbuffer, BUFFER_LOCK_EXCLUSIVE); > + PageSetAllVisible(page); > + visibilitymap_set_vmbyte(vacrel->rel, > + blkno, So, for one, there is an assert just above this code in lazy_vacuum_heap_page() that nunused > 0 -- so we know that the page couldn't have been all-visible already because it had unused line pointers. Otherwise, if it was possible for an already all-visible page to get here, the same thing would happen that happens on master -- heap_page_is_all_visible[_except_lpdead()] would return true and we would try to set the VM which would end up being a no-op. - Melanie [1] https://www.postgresql.org/message-id/CAAKRu_YD0ecXeAh%2BDmJpzQOJwcRzmMyGdcc5W_0pEF78rYSJkQ%40mail.gmail.com -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-08-28T09:11:48Z
On Thu, 28 Aug 2025 at 00:02, Melanie Plageman <melanieplageman@gmail.com> wrote: > > Do we need to pin vmbuffer here? Looks like > > XLogReadBufferForRedoExtended already pins vmbuffer. I verified this > > with CheckBufferIsPinnedOnce(vmbuffer) just before visibilitymap_pin > > and COPY ... WITH (FREEZE true) test. > > I thought the reason visibilitymap_set() did it was that it was > possible for the block of the VM corresponding to the block of the > heap to be different during recovery than it was when emitting the > record, and thus we needed the part of visiblitymap_pin() that > released the old vmbuffer and got the new one corresponding to the > heap block. > > I can't quite think of how this could happen though. > > Assuming it can't happen, then we can get rid of visiblitymap_pin() > (and add visibilitymap_pin_ok()) in both visiblitymap_set_vmbyte() and > visibilitymap_set(). I've done this to visibilitymap_set() in a > separate patch 0001. I would like other opinions/confirmation that the > block of the VM corresponding to the heap block cannot differ during > recovery from that what it was when the record was emitted during > normal operation, though. I did micro git-blame research here. I spotted only one related change [0]. Looks like before this change pin was indeed needed. But not after this change, so this visibilitymap_pin is just an oversight? Related thread is [1]. I quickly checked the discussion in this thread, and it looks like no one was bothered about these lines or VM logging changes (in this exact pin buffer aspect). The discussion was of other aspects of this commit. [0] https://github.com/postgres/postgres/commit/2c03216d8311 [1] https://www.postgresql.org/message-id/533D6CBF.6080203%40vmware.com -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-02T21:52:37Z
On Thu, Aug 28, 2025 at 5:12 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > I did micro git-blame research here. I spotted only one related change > [0]. Looks like before this change pin was indeed needed. > But not after this change, so this visibilitymap_pin is just an oversight? > Related thread is [1]. I quickly checked the discussion in this > thread, and it looks like no one was bothered about these lines or VM > logging changes (in this exact pin buffer aspect). The discussion was > of other aspects of this commit. Wow, thanks so much for doing that research. Looking at it myself, it does indeed seem like just an oversight. It isn't harmful since it won't take another pin, but it is confusing, so I think we should at least remove it in master. I'm not as sure about back branches. I would like someone to confirm that there is no way we could end up with a different block of the VM containing the vm bits for a heap block during recovery than during normal operation. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-02T23:11:01Z
On Tue, Sep 2, 2025 at 5:52 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Thu, Aug 28, 2025 at 5:12 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > > > I did micro git-blame research here. I spotted only one related change > > [0]. Looks like before this change pin was indeed needed. > > But not after this change, so this visibilitymap_pin is just an oversight? > > Related thread is [1]. I quickly checked the discussion in this > > thread, and it looks like no one was bothered about these lines or VM > > logging changes (in this exact pin buffer aspect). The discussion was > > of other aspects of this commit. > > Wow, thanks so much for doing that research. Looking at it myself, it > does indeed seem like just an oversight. It isn't harmful since it > won't take another pin, but it is confusing, so I think we should at > least remove it in master. I'm not as sure about back branches. I've updated the commit message in the patch set to reflect the research you did in attached v8. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2025-09-02T23:54:07Z
Hi, On 2025-09-02 19:11:01 -0400, Melanie Plageman wrote: > From dd98177294011ee93cac122405516abd89f4e393 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 27 Aug 2025 08:50:15 -0400 > Subject: [PATCH v8 01/22] Remove unneeded VM pin from VM replay LGTM. > From 7c5cb3edf89735eaa8bee9ca46111bd6c554720b Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 27 Aug 2025 10:07:29 -0400 > Subject: [PATCH v8 02/22] Add assert and log message to visibilitymap_set LGTM. > From 07f31099754636ec9dabf6cca06c33c4b19c230c Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 17 Jun 2025 17:22:10 -0400 > Subject: [PATCH v8 03/22] Eliminate xl_heap_visible in COPY FREEZE > > Instead of emitting a separate WAL record for setting the VM bits in > xl_heap_visible, specify the changes to make to the VM block in the > xl_heap_multi_insert record instead. > > Author: Melanie Plageman <melanieplageman@gmail.com> > Reviewed-by: Kirill Reshke <reshkekirill@gmail.com> > Discussion: https://postgr.es/m/flat/CAAKRu_ZMw6Npd_qm2KM%2BFwQ3cMOMx1Dh3VMhp8-V7SOLxdK9-g%40mail.gmail.com > + /* > + * If we're only adding already frozen rows to a previously empty > + * page, mark it as all-frozen and update the visibility map. We're > + * already holding a pin on the vmbuffer. > + */ > else if (all_frozen_set) > + { > + Assert(visibilitymap_pin_ok(BufferGetBlockNumber(buffer), vmbuffer)); > PageSetAllVisible(page); > + LockBuffer(vmbuffer, BUFFER_LOCK_EXCLUSIVE); > + visibilitymap_set_vmbyte(relation, > + BufferGetBlockNumber(buffer), > + vmbuffer, > + VISIBILITYMAP_ALL_VISIBLE | > + VISIBILITYMAP_ALL_FROZEN); > + } From an abstraction POV I don't love that heapam now is responsible for acquiring and releasing the lock. But that ship already kind of has sailed, as heapam.c is already responsible for releasing the vm buffer etc... I've wondered about splitting the responsibilities up into multiple visibilitymap_set_* functions, so that heapam.c wouldn't need to acquire the lock and set the LSN. But it's probably not worth it. > + /* > + * Now read and update the VM block. Even if we skipped updating the heap > + * page due to the file being dropped or truncated later in recovery, it's > + * still safe to update the visibility map. Any WAL record that clears > + * the visibility map bit does so before checking the page LSN, so any > + * bits that need to be cleared will still be cleared. > + * > + * It is only okay to set the VM bits without holding the heap page lock > + * because we can expect no other writers of this page. > + */ > + if (xlrec->flags & XLH_INSERT_ALL_FROZEN_SET && > + XLogReadBufferForRedoExtended(record, 1, RBM_ZERO_ON_ERROR, false, > + &vmbuffer) == BLK_NEEDS_REDO) > + { > + Relation reln = CreateFakeRelcacheEntry(rlocator); > + > + Assert(visibilitymap_pin_ok(blkno, vmbuffer)); > + visibilitymap_set_vmbyte(reln, blkno, > + vmbuffer, > + VISIBILITYMAP_ALL_VISIBLE | > + VISIBILITYMAP_ALL_FROZEN); > + > + /* > + * It is not possible that the VM was already set for this heap page, > + * so the vmbuffer must have been modified and marked dirty. > + */ > + Assert(BufferIsDirty(vmbuffer)); How about making visibilitymap_set_vmbyte() return whether it needed to do something? This seems somewhat indirect... I think it might be good to encapsulate this code into a helper in visibilitymap.c, there will be more callers in the subsequent patches. > +/* > + * Set flags in the VM block contained in the passed in vmBuf. > + * > + * This function is for callers which include the VM changes in the same WAL > + * record as the modifications of the heap page which rendered it all-visible. > + * Callers separately logging the VM changes should invoke visibilitymap_set() > + * instead. > + * > + * Caller must have pinned and exclusive locked the correct block of the VM in > + * vmBuf. This block should contain the VM bits for the given heapBlk. > + * > + * During normal operation (i.e. not recovery), this should be called in a > + * critical section which also makes any necessary changes to the heap page > + * and, if relevant, emits WAL. > + * > + * Caller is responsible for WAL logging the changes to the VM buffer and for > + * making any changes needed to the associated heap page. This includes > + * maintaining any invariants such as ensuring the buffer containing heapBlk > + * is pinned and exclusive locked. > + */ > +uint8 > +visibilitymap_set_vmbyte(Relation rel, BlockNumber heapBlk, > + Buffer vmBuf, uint8 flags) Why is it named vmbyte? This actually just sets the two bits corresponding to the buffer, not the entire byte. So it seems somewhat misleading to reference byte. > From dc318358572f61efbd0e05aae2b9a077b422bcf5 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 18 Jun 2025 12:42:13 -0400 > Subject: [PATCH v8 05/22] Eliminate xl_heap_visible from vacuum phase III > > Instead of emitting a separate xl_heap_visible record for each page that > is rendered all-visible by vacuum's third phase, include the updates to > the VM in the already emitted xl_heap_prune record. Reading through the change I didn't particularly like that there's another optional field in xl_heap_prune, as it seemed liked something that should be encoded in flags. Of course there aren't enough flag bits available. But that made me look at the rest of the record: Uh, what do we use the reason field for? As far as I can tell f83d709760d8 added it without introducing any users? It doesn't even seem to be set. > @@ -51,10 +52,15 @@ heap_xlog_prune_freeze(XLogReaderState *record) > (xlrec.flags & (XLHP_HAS_REDIRECTIONS | XLHP_HAS_DEAD_ITEMS)) == 0); > > /* > - * We are about to remove and/or freeze tuples. In Hot Standby mode, > - * ensure that there are no queries running for which the removed tuples > - * are still visible or which still consider the frozen xids as running. > - * The conflict horizon XID comes after xl_heap_prune. > + * After xl_heap_prune is the optional snapshot conflict horizon. > + * > + * In Hot Standby mode, we must ensure that there are no running queries > + * which would conflict with the changes in this record. If pruning, that > + * means we cannot remove tuples still visible to transactions on the > + * standby. If freezing, that means we cannot freeze tuples with xids that > + * are still considered running on the standby. And for setting the VM, we > + * cannot do so if the page isn't all-visible to all transactions on the > + * standby. > */ I'm a bit confused by this new comment - it sounds like we're deciding whether to remove tuple versions, but that decision has long been made, no? > @@ -2846,8 +2848,11 @@ lazy_vacuum_heap_page(LVRelState *vacrel, BlockNumber blkno, Buffer buffer, > OffsetNumber unused[MaxHeapTuplesPerPage]; > int nunused = 0; > TransactionId visibility_cutoff_xid; > + TransactionId conflict_xid = InvalidTransactionId; > bool all_frozen; > LVSavedErrInfo saved_err_info; > + uint8 vmflags = 0; > + bool set_pd_all_vis = false; > > Assert(vacrel->do_index_vacuuming); > > @@ -2858,6 +2863,20 @@ lazy_vacuum_heap_page(LVRelState *vacrel, BlockNumber blkno, Buffer buffer, > VACUUM_ERRCB_PHASE_VACUUM_HEAP, blkno, > InvalidOffsetNumber); > > + if (heap_page_is_all_visible_except_lpdead(vacrel->rel, buffer, > + vacrel->cutoffs.OldestXmin, > + deadoffsets, num_offsets, > + &all_frozen, &visibility_cutoff_xid, > + &vacrel->offnum)) > + { > + vmflags |= VISIBILITYMAP_ALL_VISIBLE; > + if (all_frozen) > + { > + vmflags |= VISIBILITYMAP_ALL_FROZEN; > + Assert(!TransactionIdIsValid(visibility_cutoff_xid)); > + } > + } > + > START_CRIT_SECTION(); I am rather confused - we never can set all-visible if there are any LP_DEAD items left. If the idea is that we are removing the LP_DEAD items in lazy_vacuum_heap_page() - what guarantees that all LP_DEAD items are being removed? Couldn't some tuples get marked LP_DEAD by on-access pruning, after vacuum visited the page and collected dead items? Ugh, I see - it works because we pass in the set of dead items. I think that makes the name *really* misleading, it's not except LP_DEAD, it's except the offsets passed in, no? But then you actually check that the set of dead items didn't change - what guarantees that? I didn't look at the later patches, except that I did notice this: > @@ -268,7 +264,7 @@ heap_xlog_prune_freeze(XLogReaderState *record) > Relation reln = CreateFakeRelcacheEntry(rlocator); > > visibilitymap_pin(reln, blkno, &vmbuffer); > - old_vmbits = visibilitymap_set_vmbyte(reln, blkno, vmbuffer, vmflags); > + old_vmbits = visibilitymap_set(reln, blkno, vmbuffer, vmflags); > /* Only set VM page LSN if we modified the page */ > if (old_vmbits != vmflags) > PageSetLSN(BufferGetPage(vmbuffer), lsn); > @@ -279,143 +275,6 @@ heap_xlog_prune_freeze(XLogReaderState *record) > UnlockReleaseBuffer(vmbuffer); > } Why are we manually pinning the vm buffer here? Shouldn't the xlog machinery have done so, as you noticed in one of the early on patches? Greetings, Andres Freund -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-09-03T09:06:40Z
On Wed, 3 Sept 2025 at 04:11, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Tue, Sep 2, 2025 at 5:52 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > > On Thu, Aug 28, 2025 at 5:12 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > > > > > I did micro git-blame research here. I spotted only one related change > > > [0]. Looks like before this change pin was indeed needed. > > > But not after this change, so this visibilitymap_pin is just an oversight? > > > Related thread is [1]. I quickly checked the discussion in this > > > thread, and it looks like no one was bothered about these lines or VM > > > logging changes (in this exact pin buffer aspect). The discussion was > > > of other aspects of this commit. > > > > Wow, thanks so much for doing that research. Looking at it myself, it > > does indeed seem like just an oversight. It isn't harmful since it > > won't take another pin, but it is confusing, so I think we should at > > least remove it in master. I'm not as sure about back branches. > > I've updated the commit message in the patch set to reflect the > research you did in attached v8. > > - Melanie Hi! small comments regarding new series 0001, 0002, 0017 LGTM In 0015: ``` reshke@yezzey-cbdb-bench:~/postgres$ git diff src/backend/access/heap/pruneheap.c diff --git a/src/backend/access/heap/pruneheap.c b/src/backend/access/heap/pruneheap.c index 05b51bd8d25..0794af9ae89 100644 --- a/src/backend/access/heap/pruneheap.c +++ b/src/backend/access/heap/pruneheap.c @@ -1398,7 +1398,7 @@ heap_prune_record_unchanged_lp_normal(Page page, PruneState *prstate, OffsetNumb /* * For now always use prstate->cutoffs for this test, because * we only update 'all_visible' when freezing is requested. We - * could use GlobalVisTestIsRemovableXid instead, if a + * could use GlobalVisXidVisibleToAll instead, if a * non-freezing caller wanted to set the VM bit. */ Assert(prstate->cutoffs); ``` Also, maybe GlobalVisXidTestAllVisible is a slightly better name? (The term 'all-visible' is one that we occasionally utilize) -- Best regards, Kirill Reshke -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-05T22:20:21Z
Thanks for the review! On Tue, Sep 2, 2025 at 7:54 PM Andres Freund <andres@anarazel.de> wrote: > > On 2025-09-02 19:11:01 -0400, Melanie Plageman wrote: > > From dd98177294011ee93cac122405516abd89f4e393 Mon Sep 17 00:00:00 2001 > > From: Melanie Plageman <melanieplageman@gmail.com> > > Date: Wed, 27 Aug 2025 08:50:15 -0400 > > Subject: [PATCH v8 01/22] Remove unneeded VM pin from VM replay I didn't push it yet because I did a new version that actually eliminates the asserts in heap_multi_insert() before calling visibilitymap_set() -- since they are redundant with checks inside visibilitymap_set(). 0001 of attached v9 is what I plan to push, barring any objections. > > From 7c5cb3edf89735eaa8bee9ca46111bd6c554720b Mon Sep 17 00:00:00 2001 > > From: Melanie Plageman <melanieplageman@gmail.com> > > Date: Wed, 27 Aug 2025 10:07:29 -0400 > > Subject: [PATCH v8 02/22] Add assert and log message to visibilitymap_set I pushed this. > From an abstraction POV I don't love that heapam now is responsible for > acquiring and releasing the lock. But that ship already kind of has sailed, as > heapam.c is already responsible for releasing the vm buffer etc... > > I've wondered about splitting the responsibilities up into multiple > visibilitymap_set_* functions, so that heapam.c wouldn't need to acquire the > lock and set the LSN. But it's probably not worth it. Yea, I explored heap wrappers coupling heap operations related to setting the VM along with the VM updates [1], but the results weren't appealing. Setting the heap LSN and marking the heap buffer dirty and such happens in a different place in different callers because it is happening as part of the operations that actually end up rendering the page all-visible. And a VM-only helper would literally just acquire and release the lock and set the LSN on the vm page -- which I don't think is worth it. > > + /* > > + * Now read and update the VM block. Even if we skipped updating the heap > > + * page due to the file being dropped or truncated later in recovery, it's > > + * still safe to update the visibility map. Any WAL record that clears > > + * the visibility map bit does so before checking the page LSN, so any > > + * bits that need to be cleared will still be cleared. > > + * > > + * It is only okay to set the VM bits without holding the heap page lock > > + * because we can expect no other writers of this page. > > + */ > > + if (xlrec->flags & XLH_INSERT_ALL_FROZEN_SET && > > + XLogReadBufferForRedoExtended(record, 1, RBM_ZERO_ON_ERROR, false, > > + &vmbuffer) == BLK_NEEDS_REDO) > > + { > > + Relation reln = CreateFakeRelcacheEntry(rlocator); > > + > > + Assert(visibilitymap_pin_ok(blkno, vmbuffer)); > > + visibilitymap_set_vmbyte(reln, blkno, > > + vmbuffer, > > + VISIBILITYMAP_ALL_VISIBLE | > > + VISIBILITYMAP_ALL_FROZEN); > > + > > + /* > > + * It is not possible that the VM was already set for this heap page, > > + * so the vmbuffer must have been modified and marked dirty. > > + */ > > + Assert(BufferIsDirty(vmbuffer)); > > How about making visibilitymap_set_vmbyte() return whether it needed to do > something? This seems somewhat indirect... It does return the state of the previous bits. But, I am specifically asserting that the buffer is dirty because I am about to set the page LSN. So I don't just care that changes were made, I care that we remembered to mark the buffer dirty. > I think it might be good to encapsulate this code into a helper in > visibilitymap.c, there will be more callers in the subsequent patches. By the end of the set, the different callers have different expectations (some don't expect the buffer to have been dirtied necessarily) and where they do the various related operations is spread out depending on the caller. I just couldn't come up with a helper solution I liked. That being said, I definitely don't think it's needed for this patch (logging setting the VM in xl_heap_multi_insert()). > > +uint8 > > +visibilitymap_set_vmbyte(Relation rel, BlockNumber heapBlk, > > + Buffer vmBuf, uint8 flags) > > Why is it named vmbyte? This actually just sets the two bits corresponding to > the buffer, not the entire byte. So it seems somewhat misleading to reference > byte. Renamed it to visibilitymap_set_vmbits. > > Instead of emitting a separate xl_heap_visible record for each page that > > is rendered all-visible by vacuum's third phase, include the updates to > > the VM in the already emitted xl_heap_prune record. > > Reading through the change I didn't particularly like that there's another > optional field in xl_heap_prune, as it seemed liked something that should be > encoded in flags. Of course there aren't enough flag bits available. But > that made me look at the rest of the record: Uh, what do we use the reason > field for? As far as I can tell f83d709760d8 added it without introducing any > users? It doesn't even seem to be set. yikes, you are right about the "reason" member. Attached 0002 removes it, and I'll go ahead and fix it in the back branches too. I can't fathom how that slipped through the cracks. We do pass the PruneReason for setting the rmgr info about what type of record it is (i.e. if it is one emitted by vacuum phase I, phase III, or on-access pruning). But we don't need or use a separate member.. I went back and tried to figure out what the rationale was, but I couldn't find anything. As for the VM flags being an optional unaligned member -- in v9, I've expanded the flags member to a uint16 to make room for the extra flags. Seems we've been surviving with using up 2 bytes this long. > > @@ -51,10 +52,15 @@ heap_xlog_prune_freeze(XLogReaderState *record) > > (xlrec.flags & (XLHP_HAS_REDIRECTIONS | XLHP_HAS_DEAD_ITEMS)) == 0); > > > > /* > > - * We are about to remove and/or freeze tuples. In Hot Standby mode, > > - * ensure that there are no queries running for which the removed tuples > > - * are still visible or which still consider the frozen xids as running. > > - * The conflict horizon XID comes after xl_heap_prune. > > + * After xl_heap_prune is the optional snapshot conflict horizon. > > + * > > + * In Hot Standby mode, we must ensure that there are no running queries > > + * which would conflict with the changes in this record. If pruning, that > > + * means we cannot remove tuples still visible to transactions on the > > + * standby. If freezing, that means we cannot freeze tuples with xids that > > + * are still considered running on the standby. And for setting the VM, we > > + * cannot do so if the page isn't all-visible to all transactions on the > > + * standby. > > */ > > I'm a bit confused by this new comment - it sounds like we're deciding whether > to remove tuple versions, but that decision has long been made, no? Well, the comment is a revision of a comment that was already there on essentially why replaying this record could cause recovery conflicts. It mentioned pruning and freezing, so I expanded it to mention setting the VM. Taking into account your confusion, I tried rewording it in attached v9. > > + if (heap_page_is_all_visible_except_lpdead(vacrel->rel, buffer, > > + vacrel->cutoffs.OldestXmin, > > + deadoffsets, num_offsets, > > + &all_frozen, &visibility_cutoff_xid, > > + &vacrel->offnum)) > > I am rather confused - we never can set all-visible if there are any LP_DEAD > items left. If the idea is that we are removing the LP_DEAD items in > lazy_vacuum_heap_page() - what guarantees that all LP_DEAD items are being > removed? Couldn't some tuples get marked LP_DEAD by on-access pruning, after > vacuum visited the page and collected dead items? > > Ugh, I see - it works because we pass in the set of dead items. I think that > makes the name *really* misleading, it's not except LP_DEAD, it's except the > offsets passed in, no? > > But then you actually check that the set of dead items didn't change - what > guarantees that? So, I pass in the deadoffsets we got from the TIDStore. If the only dead items on the page are exactly those dead items, then the page will be all-visible as soon as we set those LP_UNUSED -- which we do unconditionally. And we have the lock on the page, so no one can on-access prune and make new dead items while we are in lazy_vacuum_heap_page(). Given your confusion, I've refactored this and used a different approach -- I explicitly check the passed-in deadoffsets array when I encounter a dead item and see if it is there. That should hopefully make it more clear. > I didn't look at the later patches, except that I did notice this: <--snip--> > Why are we manually pinning the vm buffer here? Shouldn't the xlog machinery > have done so, as you noticed in one of the early on patches? Fixed. Thanks! - Melanie [1] [1] https://www.postgresql.org/message-id/flat/CAAKRu_Yj%3DyrL%2BgGGsqfYVQcYn7rDp6hDeoF1vN453JDp8dEY%2Bw%40mail.gmail.com#94602c599abdc8dfc5e438bd24bd8d50 -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-05T22:27:05Z
On Wed, Sep 3, 2025 at 5:06 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > small comments regarding new series > > 0001, 0002, 0017 LGTM Thanks for continuing to review! > In 0015: > > Also, maybe GlobalVisXidTestAllVisible is a slightly better name? (The > term 'all-visible' is one that we occasionally utilize) Actually, I was trying to distinguish it from all-visible because I interpret that to mean every thing is visible -- as in, every tuple on a page is visible to everyone. And here we are referring to one xid and want to know if it is visible to everyone as no longer running. I don't think my name ("visible-to-all") is good, but I'm hesitant to co-opt "all-visible" here. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-08T15:44:24Z
On Fri, Sep 5, 2025 at 6:20 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > > On 2025-09-02 19:11:01 -0400, Melanie Plageman wrote: > > > From dd98177294011ee93cac122405516abd89f4e393 Mon Sep 17 00:00:00 2001 > > > From: Melanie Plageman <melanieplageman@gmail.com> > > > Date: Wed, 27 Aug 2025 08:50:15 -0400 > > > Subject: [PATCH v8 01/22] Remove unneeded VM pin from VM replay > > I didn't push it yet because I did a new version that actually > eliminates the asserts in heap_multi_insert() before calling > visibilitymap_set() -- since they are redundant with checks inside > visibilitymap_set(). 0001 of attached v9 is what I plan to push, > barring any objections. I pushed this, so rebased v10 is attached. I've added one new patch: 0002 adds ERRCODE_DATA_CORRUPTED to the existing log messages about VM/data corruption in vacuum. Andrey Borodin earlier suggested this, and I had neglected to include it. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Robert Haas <robertmhaas@gmail.com> — 2025-09-08T16:41:00Z
On Fri, Sep 5, 2025 at 6:20 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > yikes, you are right about the "reason" member. Attached 0002 removes > it, and I'll go ahead and fix it in the back branches too. I think changing this in the back-branches is a super-bad idea. If you want, you can add a comment in the back-branches saying "oops, we shipped a field that isn't used for anything", but changing the struct definition is very likely to make 0 people happy and >0 people unhappy. On the other hand, changing this in master is a good idea and you should go ahead and do that before this creates any more confusion. -- Robert Haas EDB: http://www.enterprisedb.com
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-08T18:32:29Z
On Mon, Sep 8, 2025 at 12:41 PM Robert Haas <robertmhaas@gmail.com> wrote: > > On Fri, Sep 5, 2025 at 6:20 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > yikes, you are right about the "reason" member. Attached 0002 removes > > it, and I'll go ahead and fix it in the back branches too. > > I think changing this in the back-branches is a super-bad idea. If you > want, you can add a comment in the back-branches saying "oops, we > shipped a field that isn't used for anything", but changing the struct > definition is very likely to make 0 people happy and >0 people > unhappy. On the other hand, changing this in master is a good idea and > you should go ahead and do that before this creates any more > confusion. Yes, that makes 100% sense. It should have occurred to me. I've pushed the commit to master. I didn't put an updated set of patches here in case someone was already reviewing them, as nothing else has changed. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Robert Haas <robertmhaas@gmail.com> — 2025-09-08T18:54:34Z
On Mon, Sep 8, 2025 at 11:44 AM Melanie Plageman <melanieplageman@gmail.com> wrote: > I pushed this, so rebased v10 is attached. I've added one new patch: > 0002 adds ERRCODE_DATA_CORRUPTED to the existing log messages about > VM/data corruption in vacuum. Andrey Borodin earlier suggested this, > and I had neglected to include it. Writing "ereport(WARNING, (errcode(ERRCODE_DATA_CORRUPTED)" is very much a minority position. Generally the call to errcode() is on the following line. I think the commit message could use a bit of work, too. The first sentence heavily duplicates the second and the fourth, and the third sentence isn't sufficiently well-connected to the rest to make it clear why you're restating this general principle in this commit message. Perhaps something like: Add error codes when VACUUM discovers VM corruption Commit fd6ec93bf890314ac694dc8a7f3c45702ecc1bbd and other previous work has established the principle that when an error is potentially reachable in case of on-disk corruption, but is not expected to be reached otherwise, ERRCODE_DATA_CORRUPTED should be used. This allows log monitoring software to search for evidence of corruption by filtering on the error code. That kibitzing aside, I think this is pretty clearly the right thing to do. -- Robert Haas EDB: http://www.enterprisedb.com
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-08T19:14:01Z
On Mon, Sep 8, 2025 at 2:54 PM Robert Haas <robertmhaas@gmail.com> wrote: > > Commit fd6ec93bf890314ac694dc8a7f3c45702ecc1bbd and other previous > work has established the principle that when an error is potentially > reachable in case of on-disk corruption, but is not expected to be > reached otherwise, ERRCODE_DATA_CORRUPTED should be used. This allows > log monitoring software to search for evidence of corruption by > filtering on the error code. > > That kibitzing aside, I think this is pretty clearly the right thing to do. Thanks for the suggested wording and the pointer to that thread. I noticed that in that thread they decided to use errmsg_internal() instead of errmsg() for a few different reasons -- one of which was that the situation is not supposed to happen/cannot happen -- which I don't really understand. It is a reachable code path. Another is that it is extra work for translators, which I'm also not sure how to apply to my situation. Are the VM corruption cases worth extra work to the translators? I think the most compelling reason is that people will want to search for the error message in English online. So, for that reason, my instinct is to use errmsg_internal() in my case as well. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Robert Haas <robertmhaas@gmail.com> — 2025-09-08T19:53:35Z
On Mon, Sep 8, 2025 at 3:14 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > I noticed that in that thread they decided to use errmsg_internal() > instead of errmsg() for a few different reasons -- one of which was > that the situation is not supposed to happen/cannot happen -- which I > don't really understand. It is a reachable code path. Another is that > it is extra work for translators, which I'm also not sure how to apply > to my situation. Are the VM corruption cases worth extra work to the > translators? > > I think the most compelling reason is that people will want to search > for the error message in English online. So, for that reason, my > instinct is to use errmsg_internal() in my case as well. I don't find that reason particularly compelling -- people could want to search for any error message, or they could equally want to be able to read it without Google translate. Guessing which messages are obscure enough that we need not translate them exceeds my powers. If I were doing it, I'd make it errmsg() rather than errmsg_internal() and let the translations team change it if they don't think it's worth bothering with, because if you make it errmsg_internal() then they won't see it until somebody complains about it not getting translated. However, I suspect different committers would pursue different strategies here. -- Robert Haas EDB: http://www.enterprisedb.com
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Robert Haas <robertmhaas@gmail.com> — 2025-09-08T20:14:47Z
Reviewing 0003: + /* + * If we're only adding already frozen rows to a previously empty + * page, mark it as all-frozen and update the visibility map. We're + * already holding a pin on the vmbuffer. + */ else if (all_frozen_set) + { PageSetAllVisible(page); + LockBuffer(vmbuffer, BUFFER_LOCK_EXCLUSIVE); + visibilitymap_set_vmbits(relation, + BufferGetBlockNumber(buffer), + vmbuffer, + VISIBILITYMAP_ALL_VISIBLE | + VISIBILITYMAP_ALL_FROZEN); Locking a buffer in a critical section violates the order of operations proposed in the 'Write-Ahead Log Coding' section of src/backend/access/transam/README. + * Now read and update the VM block. Even if we skipped updating the heap + * page due to the file being dropped or truncated later in recovery, it's + * still safe to update the visibility map. Any WAL record that clears + * the visibility map bit does so before checking the page LSN, so any + * bits that need to be cleared will still be cleared. + * + * It is only okay to set the VM bits without holding the heap page lock + * because we can expect no other writers of this page. The first paragraph of this paraphrases a similar content in xlog_heap_visible(), but I don't see the variation in phrasing as an improvement. The second paragraph does not convince me at all. I see no reason to believe that this is safe, or that it is a good idea. The code in xlog_heap_visible() thinks its OK to unlock and relock the page to make visibilitymap_set() happy, which is cringy but probably safe for lack of concurrent writers, but skipping locking altogether seems deeply unwise. - * visibilitymap_set - set a bit in a previously pinned page + * visibilitymap_set - set bit(s) in a previously pinned page and log + * visibilitymap_set_vmbits - set bit(s) in a pinned page I suspect the indentation was done with a different mix of spaces and tabs here, because this doesn't align for me. In general, this idea makes some sense to me -- there doesn't seem to be any particularly good reason why the visibility-map update should be handled by a different WAL record than the all-visible flag on the page itself. It's a little hard for me to make that statement too conclusively without studying more of the patches than I've had time to do today, but off the top of my head it seems to make sense. However, I'm not sure you've taken enough care with the details here. -- Robert Haas EDB: http://www.enterprisedb.com -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-08T22:28:46Z
On Mon, Sep 8, 2025 at 4:15 PM Robert Haas <robertmhaas@gmail.com> wrote: > > Reviewing 0003: > > Locking a buffer in a critical section violates the order of > operations proposed in the 'Write-Ahead Log Coding' section of > src/backend/access/transam/README. Right, I noticed some other callers of visibiltymap_set() (like lazy_scan_new_or_empty()) did call it in a critical section (and it exclusive locks the VM page), so I thought perhaps it was better to keep this operation as close as possible to where we update the VM (similar to how it is in master in visibilitymap_set()). But, I think you're right that maintaining the order of operations proposed in transam/README is more important. As such, in attached v11, I've modified this patch and the other patches where I replace visibilitymap_set() with visibilitymap_set_vmbits() to exclusively lock the vmbuffer before the critical section. visibilitymap_set_vmbits() asserts that we have the vmbuffer exclusively locked, so we should be good. > + * Now read and update the VM block. Even if we skipped > updating the heap > + * page due to the file being dropped or truncated later in > recovery, it's > + * still safe to update the visibility map. Any WAL record that clears > + * the visibility map bit does so before checking the page LSN, so any > + * bits that need to be cleared will still be cleared. > + * > + * It is only okay to set the VM bits without holding the heap page lock > + * because we can expect no other writers of this page. > > The first paragraph of this paraphrases a similar content in > xlog_heap_visible(), but I don't see the variation in phrasing as an > improvement. The only difference is I replaced the phrase "LSN interlock" with "being dropped or truncated later in recovery" -- which is more specific and, I thought, more clear. Without this comment, it took me some time to understand the scenarios that might lead us to skip updating the heap block. heap_xlog_visible() has cause to describe this situation in an earlier comment -- which is why I think the LSN interlock comment is less confusing there. Anyway, I'm open to changing the comment. I could: 1) copy-paste the same comment as heap_xlog_visible() 2) refer to the comment in heap_xlog_visible() (comment seemed a bit short for that) 3) diverge the comments further by improving the new comment in heap_xlog_multi_insert() in some way 4) something else? > The second paragraph does not convince me at all. I see no reason to > believe that this is safe, or that it is a good idea. The code in > xlog_heap_visible() thinks its OK to unlock and relock the page to > make visibilitymap_set() happy, which is cringy but probably safe for > lack of concurrent writers, but skipping locking altogether seems > deeply unwise. Actually in master, heap_xlog_visible() has no lock on the heap page when it calls visibiltymap_set(). It releases that lock before recording the freespace in the FSM and doesn't take it again. It does unlock and relock the VM page -- because visibilitymap_set() expects to take the lock on the VM. I agree that not holding the heap lock while updating the VM is unsatisfying. We can't hold it while doing the IO to read in the VM block in XLogReadBufferForRedoExtended(). So, we could take it again before calling visibilitymap_set(). But we don't always have the heap buffer, though. I suspect this is partially why heap_xlog_visible() unconditionally passes InvalidBuffer to visibilitymap_set() as the heap buffer and has special case handling for recovery when we don't have the heap buffer. In any case, it isn't an active bug, and I don't think future-proofing VM replay (i.e. against parallel recovery) is a prerequisite for committing this patch since it is also that way on master. > - * visibilitymap_set - set a bit in a previously pinned page > + * visibilitymap_set - set bit(s) in a previously > pinned page and log > + * visibilitymap_set_vmbits - set bit(s) in a pinned page > > I suspect the indentation was done with a different mix of spaces and > tabs here, because this doesn't align for me. oops, fixed. I pushed the ERRCODE_DATA_CORRUPTED patch, so attached v11 is rebased and also has the changes mentioned above. Since you've started reviewing the set, I'll note that patches 0005-0011 are split up for ease of review and it may not necessarily make sense to keep that separation for eventual commit. They are a series of steps to move VM updates from lazy_scan_prune() into pruneheap.c. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Robert Haas <robertmhaas@gmail.com> — 2025-09-09T14:00:04Z
On Mon, Sep 8, 2025 at 6:29 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > But, I think you're right that maintaining the order of operations > proposed in transam/README is more important. As such, in attached > v11, I've modified this patch and the other patches where I replace > visibilitymap_set() with visibilitymap_set_vmbits() to exclusively > lock the vmbuffer before the critical section. > visibilitymap_set_vmbits() asserts that we have the vmbuffer > exclusively locked, so we should be good. That sounds good. I think it is OK to keep some of the odd things that we're currently doing if they're hard to eliminate, but if they're not really needed then I'd rather see us standardize the code. I feel (and I think you may agree, based on other conversations that we've had) that the visibility map code is somewhat oddly structured, and I'd like to see us push the amount of oddness down rather than up, if we can reasonably do so without breaking everything. > The only difference is I replaced the phrase "LSN interlock" with > "being dropped or truncated later in recovery" -- which is more > specific and, I thought, more clear. Without this comment, it took me > some time to understand the scenarios that might lead us to skip > updating the heap block. heap_xlog_visible() has cause to describe > this situation in an earlier comment -- which is why I think the LSN > interlock comment is less confusing there. > > Anyway, I'm open to changing the comment. I could: > 1) copy-paste the same comment as heap_xlog_visible() > 2) refer to the comment in heap_xlog_visible() (comment seemed a bit > short for that) > 3) diverge the comments further by improving the new comment in > heap_xlog_multi_insert() in some way > 4) something else? IMHO, copying and pasting comments is not great, and comments with identical intent and divergent wording are also not great. The former is not great because having a whole bunch of copies of the same comment, especially if it's a block comment rather than a 1-liner, uses up a bunch of space and creates a maintenance hazard in the sense that future updates might not get propagated to all copies. The latter is not great because it makes it hard to grep for other instances that should be adjusted when you adjust one, and also because if one version really is better than the other than ideally we'd like to have the good version everywhere. Of course, there's some tension between these two goals. In this particular case, thinking a little harder about your proposed change, it seems to me that "LSN interlock" is more clear about what the immediate test is that would cause us to skip updating the heap page, and "being dropped or truncated later in recovery" is more clear about what the larger state of the world that would lead to that situation is. But whatever preference anyone might have about which way to go with that choice, it is hard to see why the preference should go one way in one case and the other way in another case. Therefore, I favor an approach that leads either to an identical comment in both places, or to one comment referring to the other. > > The second paragraph does not convince me at all. I see no reason to > > believe that this is safe, or that it is a good idea. The code in > > xlog_heap_visible() thinks its OK to unlock and relock the page to > > make visibilitymap_set() happy, which is cringy but probably safe for > > lack of concurrent writers, but skipping locking altogether seems > > deeply unwise. > > Actually in master, heap_xlog_visible() has no lock on the heap page > when it calls visibiltymap_set(). It releases that lock before > recording the freespace in the FSM and doesn't take it again. > > It does unlock and relock the VM page -- because visibilitymap_set() > expects to take the lock on the VM. > > I agree that not holding the heap lock while updating the VM is > unsatisfying. We can't hold it while doing the IO to read in the VM > block in XLogReadBufferForRedoExtended(). So, we could take it again > before calling visibilitymap_set(). But we don't always have the heap > buffer, though. I suspect this is partially why heap_xlog_visible() > unconditionally passes InvalidBuffer to visibilitymap_set() as the > heap buffer and has special case handling for recovery when we don't > have the heap buffer. You know, I wasn't thinking carefully enough about the distinction between the heap page and the visibility map page here. I thought you were saying that you were modifying a page without a lock on that page, but you aren't: you're saying you're modifying a page without a lock on another page to which it is related. The former seems disastrous, but the latter might be OK. However, I'm sort of confused about what the comment is trying to say to justify that: + * It is only okay to set the VM bits without holding the heap page lock + * because we can expect no other writers of this page. It is not exactly clear to me whether "this page" here refers to the heap page or the VM page. If it means the heap page, why should that be so if we haven't got any kind of lock? If it means the VM page, then why is the heap page even relevant? -- Robert Haas EDB: http://www.enterprisedb.com
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-09T16:24:20Z
On Tue, Sep 9, 2025 at 10:00 AM Robert Haas <robertmhaas@gmail.com> wrote: > > On Mon, Sep 8, 2025 at 6:29 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > The only difference is I replaced the phrase "LSN interlock" with > > "being dropped or truncated later in recovery" -- which is more > > specific and, I thought, more clear. Without this comment, it took me > > some time to understand the scenarios that might lead us to skip > > updating the heap block. heap_xlog_visible() has cause to describe > > this situation in an earlier comment -- which is why I think the LSN > > interlock comment is less confusing there. > > > > Anyway, I'm open to changing the comment. I could: > > 1) copy-paste the same comment as heap_xlog_visible() > > 2) refer to the comment in heap_xlog_visible() (comment seemed a bit > > short for that) > > 3) diverge the comments further by improving the new comment in > > heap_xlog_multi_insert() in some way > > 4) something else? > > IMHO, copying and pasting comments is not great, and comments with > identical intent and divergent wording are also not great. The former > is not great because having a whole bunch of copies of the same > comment, especially if it's a block comment rather than a 1-liner, > uses up a bunch of space and creates a maintenance hazard in the sense > that future updates might not get propagated to all copies. The latter > is not great because it makes it hard to grep for other instances that > should be adjusted when you adjust one, and also because if one > version really is better than the other than ideally we'd like to have > the good version everywhere. Of course, there's some tension between > these two goals. In this particular case, thinking a little harder > about your proposed change, it seems to me that "LSN interlock" is > more clear about what the immediate test is that would cause us to > skip updating the heap page, and "being dropped or truncated later in > recovery" is more clear about what the larger state of the world that > would lead to that situation is. But whatever preference anyone might > have about which way to go with that choice, it is hard to see why the > preference should go one way in one case and the other way in another > case. Therefore, I favor an approach that leads either to an identical > comment in both places, or to one comment referring to the other. I see what you are saying. For heap_xlog_visible() the LSN interlock comment is easier to parse because of an earlier comment before reading the heap page: /* * Read the heap page, if it still exists. If the heap file has dropped or * truncated later in recovery, we don't need to update the page, but we'd * better still update the visibility map. */ I've gone with the direct copy-paste of the LSN interlock paragraph in attached v12. I think referring to the other comment is too confusing in context here. However, I also added a line about what could cause the LSN interlock -- but above it, so as to retain grep-ability of the other comment. > > > The second paragraph does not convince me at all. I see no reason to > > > believe that this is safe, or that it is a good idea. The code in > > > xlog_heap_visible() thinks its OK to unlock and relock the page to > > > make visibilitymap_set() happy, which is cringy but probably safe for > > > lack of concurrent writers, but skipping locking altogether seems > > > deeply unwise. > > > > Actually in master, heap_xlog_visible() has no lock on the heap page > > when it calls visibiltymap_set(). It releases that lock before > > recording the freespace in the FSM and doesn't take it again. > > > > It does unlock and relock the VM page -- because visibilitymap_set() > > expects to take the lock on the VM. > > > > I agree that not holding the heap lock while updating the VM is > > unsatisfying. We can't hold it while doing the IO to read in the VM > > block in XLogReadBufferForRedoExtended(). So, we could take it again > > before calling visibilitymap_set(). But we don't always have the heap > > buffer, though. I suspect this is partially why heap_xlog_visible() > > unconditionally passes InvalidBuffer to visibilitymap_set() as the > > heap buffer and has special case handling for recovery when we don't > > have the heap buffer. > > You know, I wasn't thinking carefully enough about the distinction > between the heap page and the visibility map page here. I thought you > were saying that you were modifying a page without a lock on that > page, but you aren't: you're saying you're modifying a page without a > lock on another page to which it is related. The former seems > disastrous, but the latter might be OK. However, I'm sort of confused > about what the comment is trying to say to justify that: > > + * It is only okay to set the VM bits without holding the heap page lock > + * because we can expect no other writers of this page. > > It is not exactly clear to me whether "this page" here refers to the > heap page or the VM page. If it means the heap page, why should that > be so if we haven't got any kind of lock? If it means the VM page, > then why is the heap page even relevant? I've expanded the comment in v12. In normal operation we must have the lock on the heap page when setting the VM bits because if another backend cleared PD_ALL_VISIBLE, we could have the forbidden scenario where PD_ALL_VISIBLE is clear and the VM is set. This is not allowed because then someone else may read the VM, conclude the page is all-visible, and then an index-only scan can return wrong results. In recovery, there are no concurrent writers, so it can't happen. It is worth discussing how to fix it in heap_xlog_visible() so that future scenarios like parallel recovery could not break this. However, this patch is not a deviation from the behavior on master, and, technically the behavior on master works. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Robert Haas <robertmhaas@gmail.com> — 2025-09-09T19:26:08Z
On Tue, Sep 9, 2025 at 12:24 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > For heap_xlog_visible() the LSN interlock comment is easier to parse > because of an earlier comment before reading the heap page: > > /* > * Read the heap page, if it still exists. If the heap file has dropped or > * truncated later in recovery, we don't need to update the page, but we'd > * better still update the visibility map. > */ > > I've gone with the direct copy-paste of the LSN interlock paragraph in > attached v12. I think referring to the other comment is too confusing > in context here. However, I also added a line about what could cause > the LSN interlock -- but above it, so as to retain grep-ability of the > other comment. I think that reads a little strangely. I would consolidate: Note that the heap relation may have been dropped or truncated, leading us to skip updating the heap block due to the LSN interlock. However, even in that case, it's still safe to update the visibility map, etc. The rest of the comment is perhaps a tad more explicit than our usual practice, but that might be a good thing, because sometimes we're a little too terse about these critical details. I just realized that I don't like this: + /* + * If we're only adding already frozen rows to a previously empty + * page, mark it as all-frozen and update the visibility map. We're + * already holding a pin on the vmbuffer. + */ The thing is, we rarely position a block comment just before an "else if". There are probably instances, but it's not typical. That's why the existing comment contains two "if blah then blah" statements of which you deleted the second -- because it needed to cover both the "if" and the "else if". An alternative style is to move the comment down a nesting level and rephrase without the conditional, ie. "We're only adding frozen rows to a previously empty page, so mark it as all-frozen etc." But I don't know that I like doing that for one branch of the "if" and not the other. The rest of what's now 0001 looks OK to me now, although you might want to wait for a review from somebody more knowledgeable about this area. Some very quick comments on the next few patches -- far from a full review: 0002. Looks boring, probably unobjectionable provided the payoff patch is OK. 0003. What you've done here with xl_heap_prune.flags is kind of horrifying. The problem is that, while you've added code explaining that VISIBILITYMAP_ALL_{VISIBLE,FROZEN} are honorary XLHP flags, nobody who isn't looking directly at that comment is going to understand the muddling of the two namespaces. I would suggest not doing this, even if it means defining redundant constants and writing technically-unnecessary code to translate between them. 0004. It is not clear to me why you need to get log_heap_prune_and_freeze to do the work here. Why can't log_newpage_buffer get the job done already? 0005. It looks a little curious that you delete the identify-corruption logic from the end of the if-nest and add it to the beginning. Ceteris paribus, you'd expect that to be worse, since corruption is a rare case. 0006. "to me marked" -> "to be marked". + * If the heap page is all-visible but the VM bit is not set, we don't + * need to dirty the heap page. However, if checksums are enabled, we + * do need to make sure that the heap page is dirtied before passing + * it to visibilitymap_set(), because it may be logged. */ - PageSetAllVisible(page); - MarkBufferDirty(buf); + if (!PageIsAllVisible(page) || XLogHintBitIsNeeded()) + { + PageSetAllVisible(page); + MarkBufferDirty(buf); + } I really hate this. Maybe you're going to argue that it's not the job of this patch to fix the awfulness here, but surely marking a buffer dirty in case some other function decides to WAL-log it is a ridiculous plan. -- Robert Haas EDB: http://www.enterprisedb.com -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-09T23:07:58Z
Thanks for the review! I've made the changes to comments and minor fixes you suggested in attached v13 and have limited my inline responses to areas where further discussion is required. On Tue, Sep 9, 2025 at 3:26 PM Robert Haas <robertmhaas@gmail.com> wrote: > > 0003. What you've done here with xl_heap_prune.flags is kind of > horrifying. The problem is that, while you've added code explaining > that VISIBILITYMAP_ALL_{VISIBLE,FROZEN} are honorary XLHP flags, > nobody who isn't looking directly at that comment is going to > understand the muddling of the two namespaces. I would suggest not > doing this, even if it means defining redundant constants and writing > technically-unnecessary code to translate between them. Fair. I've introduced new XLHP flags in attached v13. Hopefully it puts an end to the horror. > 0004. It is not clear to me why you need to get > log_heap_prune_and_freeze to do the work here. Why can't > log_newpage_buffer get the job done already? Well, I need something to emit the changes to the VM. I'm eliminating all users of xl_heap_visible. Empty pages are the ones that benefit the least from switching from xl_heap_visible -> xl_heap_prune. But, if I don't transition them, we have to maintain all the xl_heap_visible code (including visibilitymap_set() in its long form). As for log_newpage_buffer(), I could keep it if you think it is too confusing to change log_heap_prune_and_freeze()'s API (by passing force_heap_fpi) to handle this case, I can leave log_newpage_buffer() there and then call log_heap_prune_and_freeze(). I just thought it seemed simple to avoid emitting the new page record and the VM update record, so why not -- but I don't have strong feelings. > 0005. It looks a little curious that you delete the > identify-corruption logic from the end of the if-nest and add it to > the beginning. Ceteris paribus, you'd expect that to be worse, since > corruption is a rare case. On master, the two corruption cases are sandwiched between the normal VM set cases. And I actually think doing it this way is brittle. If you put the cases which set the VM first, you have to have completely bulletproof the if statements guarding them to foreclose any possible corruption case from entering because otherwise you will overwrite the corruption you then try to detect. But, specifically, from a performance perspective: I think moving up the third case doesn't matter because the check is so cheap: else if (presult.lpdead_items > 0 && PageIsAllVisible(page)) And as for moving up the second case (the other corruption case), the non-cheap thing it does is call visibilitymap_get_status() else if (all_visible_according_to_vm && !PageIsAllVisible(page) && visibilitymap_get_status(vacrel->rel, blkno, &vmbuffer) != 0) But once you call visibilitymap_get_status() once, assuming there is no corruption and you need to go set the VM, you've already got that page of the VM read, so it is probably pretty cheap. Overall, I didn't think this would add noticeable overhead or many wasted operations. And I thought that reorganizing the code improved clarity as well as decreased the likelihood of bugs from insufficiently guarding positive cases against corrupt pages and overwriting corruption instead of detecting it. If we're really worried about it from a performance perspective, I could add an extra test at the top of identify_and_fix_vm_corruption() that dumps out early if (!all_visible_according_to_vm && presult.all_visible). > + * If the heap page is all-visible but the VM bit is > not set, we don't > + * need to dirty the heap page. However, if checksums > are enabled, we > + * do need to make sure that the heap page is dirtied > before passing > + * it to visibilitymap_set(), because it may be logged. > */ > - PageSetAllVisible(page); > - MarkBufferDirty(buf); > + if (!PageIsAllVisible(page) || XLogHintBitIsNeeded()) > + { > + PageSetAllVisible(page); > + MarkBufferDirty(buf); > + } > > I really hate this. Maybe you're going to argue that it's not the job > of this patch to fix the awfulness here, but surely marking a buffer > dirty in case some other function decides to WAL-log it is a > ridiculous plan. Right, it isn't pretty. But I don't quite see what the alternative is. We need to mark the buffer dirty before setting the LSN. We could perhaps rewrite visibilitymap_set()'s API to return the LSN of the xl_heap_visible record and stamp it on the heap buffer ourselves. But 1) I think visibilitymap_set() purposefully conceals its WAL logging ways from the caller and propagating that info back up starts to make the API messy in another way and 2) I'm a bit loath to make big changes to visibilitymap_set() right now since my patch set eventually resolves this by putting the changes to the VM and heap page in the same WAL record. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Robert Haas <robertmhaas@gmail.com> — 2025-09-10T20:01:25Z
On Tue, Sep 9, 2025 at 7:08 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > Fair. I've introduced new XLHP flags in attached v13. Hopefully it > puts an end to the horror. I suggest not renumbering all of the existing flags and just adding these new ones at the end. Less code churn and more likely to break in an obvious way if you mix up the two sets of flags. More on 0002: + set_heap_lsn = XLogHintBitIsNeeded() ? true : set_heap_lsn; Maybe just if (XLogHintBitIsNeeded) set_heap_lsn = true? I don't feel super-strongly that what you've done is bad but it looks weird to my eyes. + * If we released any space or line pointers or will be setting a page in + * the visibility map, measure the page's freespace to later update the "setting a page in the visibility map" seems a little muddled to me. You set bits, not pages. + * all-visible (or all-frozen, depending on the vacuum mode,) which is This comma placement is questionable. /* + * Note that the heap relation may have been dropped or truncated, leading + * us to skip updating the heap block due to the LSN interlock. However, + * even in that case, it's still safe to update the visibility map. Any + * WAL record that clears the visibility map bit does so before checking + * the page LSN, so any bits that need to be cleared will still be + * cleared. + * + * Note that the lock on the heap page was dropped above. In normal + * operation this would never be safe because a concurrent query could + * modify the heap page and clear PD_ALL_VISIBLE -- violating the + * invariant that PD_ALL_VISIBLE must be set if the corresponding bit in + * the VM is set. + * + * In recovery, we expect no other writers, so writing to the VM page + * without holding a lock on the heap page is considered safe enough. It + * is done this way when replaying xl_heap_visible records (see */ How many copies of this comment do you plan to end up with? The comment for log_heap_prune_and_freeze seems to be anticipating future work. > > 0004. It is not clear to me why you need to get > > log_heap_prune_and_freeze to do the work here. Why can't > > log_newpage_buffer get the job done already? > > Well, I need something to emit the changes to the VM. I'm eliminating > all users of xl_heap_visible. Empty pages are the ones that benefit > the least from switching from xl_heap_visible -> xl_heap_prune. But, > if I don't transition them, we have to maintain all the > xl_heap_visible code (including visibilitymap_set() in its long form). > > As for log_newpage_buffer(), I could keep it if you think it is too > confusing to change log_heap_prune_and_freeze()'s API (by passing > force_heap_fpi) to handle this case, I can leave log_newpage_buffer() > there and then call log_heap_prune_and_freeze(). > > I just thought it seemed simple to avoid emitting the new page record > and the VM update record, so why not -- but I don't have strong > feelings. Yeah, I'm not sure what the right thing to do here is. I think I was again experiencing brain fade by forgetting that there is a heap page and a VM page and, of course, log_heap_newpage() probably isn't going to touch the latter. So that makes sense. On the other hand, we could only have one type of WAL record for every single operation in the system if we gave it enough flags, and force_heap_fpi seems suspiciously like a flag that turns this into a whole different kind of WAL record. > > 0005. It looks a little curious that you delete the > > identify-corruption logic from the end of the if-nest and add it to > > the beginning. Ceteris paribus, you'd expect that to be worse, since > > corruption is a rare case. > > On master, the two corruption cases are sandwiched between the normal > VM set cases. And I actually think doing it this way is brittle. If > you put the cases which set the VM first, you have to have completely > bulletproof the if statements guarding them to foreclose any possible > corruption case from entering because otherwise you will overwrite the > corruption you then try to detect. Hmm. In the current code, we first test (!all_visible_according_to_vm && presult.all_visible), then (all_visible_according_to_vm && !PageIsAllVisible(page) && visibilitymap_get_status(vacrel->rel, blkno, &vmbuffer) != 0), and then (presult.lpdead_items > 0 && PageIsAllVisible(page)). The first and second can never coexist, because they require opposite values of all_visible_according_to_vm. The second and third cannot coexist because they require opposite values of PageIsAllVisible(page). It is not entirely obvious that the first and third tests couldn't both pass, but you'd have to have presult.all_visible and presult.lpdead_items > 0, and it's a bit hard to see how heap_page_prune_and_freeze() could ever allow that. Consider: if (prstate.all_visible && prstate.lpdead_items == 0) { presult->all_visible = prstate.all_visible; presult->all_frozen = prstate.all_frozen; } else { presult->all_visible = false; presult->all_frozen = false; } ... presult->lpdead_items = prstate.lpdead_items; So I don't really think I'm persuaded that the current way is brittle. But that having been said, I agree with you that the order of the checks is kind of random, and I don't think it really matters that much for performance. What does matter is clarity. I feel like what I'd ideally like this logic to do is say: do we want the VM bit for the page to be set to all-frozen, just all-visible, or neither? Then push the VM bit to the correct state, dragging the page-level bit along behind. And the current logic sort of does that. It's roughly: 1. Should we go from not-all-visible to either all-visible or all-frozen? If yes, do so. 2. Should we go from either all-visible or all-frozen to not-all-visible? If yes, do so. 3. Should we go from either all-visible or all-frozen to not-all-visible for a different reason? If yes, do so. 4. Should we go from all-visible to all-frozen? If yes, do so. But what's weird is that all the tests are written differently, and we have two different reasons for going to not-all-visible, namely PD_ALL_VISIBLE-not-set and dead-items-on-page, whereas there's only one test for each of the other state-transitions, because the decision-making for those cases is fully completed at an earlier stage. I would kind of like to see this expressed in a way that first decides which state transition to make (forward-to-all-frozen, forward-to-all-visible, backward-to-all-visible, backward-to-not-all-visible, nothing) and then does the corresponding work. What you're doing instead is splitting half of those functions off into a helper function while keeping the other half where they are without cleaning up any of the logic. Now, maybe that's OK: I'm far from having grokked the whole patch set. But it is not any more clear than what we have now, IMHO, and perhaps even a bit less so. -- Robert Haas EDB: http://www.enterprisedb.com -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-18T00:10:07Z
On Wed, Sep 10, 2025 at 4:01 PM Robert Haas <robertmhaas@gmail.com> wrote: > > On Tue, Sep 9, 2025 at 7:08 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > Fair. I've introduced new XLHP flags in attached v13. Hopefully it > > puts an end to the horror. > > I suggest not renumbering all of the existing flags and just adding > these new ones at the end. Less code churn and more likely to break in > an obvious way if you mix up the two sets of flags. Makes sense. In my attached v14, I have not renumbered them. > More on 0002: After an off-list discussion we had about how to make the patches in the set progressively improve the code instead of just mechanically refactoring it, I have made some big changes in the intermediate patches in the set. Before actually including the VM changes in the vacuum/prune WAL records, I first include setting PD_ALL_VISIBLE with the other changes to the heap page so that we can remove the heap page from the VM setting WAL chain. This happens to fix the bug we discussed where if you set an all-visible page all-frozen and checksums/wal_log_hints are enabled, you may end up setting an LSN on a page that was not marked dirty. 0001 is RFC but waiting on one other reviewer 0002 - 0007 is a bit of cleanup I had later in the patch set but moved up because I think it made the intermediate patches better 0008 - 0012 removes the heap page from the XLOG_HEAP2_VISIBLE WAL chain (it makes all callers of visibilitymap_set() set PD_ALL_VISIBLE in the same WAL record as changes to the heap page) 0013 - 0018 finish the job eliminating XLOG_HEAP2_VISIBLE and set VM bits in the same WAL record as the heap changes 0019 - 0024 set the VM on-access > /* > + * Note that the heap relation may have been dropped or truncated, leading > + * us to skip updating the heap block due to the LSN interlock. However, > + * even in that case, it's still safe to update the visibility map. Any > + * WAL record that clears the visibility map bit does so before checking > + * the page LSN, so any bits that need to be cleared will still be > + * cleared. > + * > + * Note that the lock on the heap page was dropped above. In normal > + * operation this would never be safe because a concurrent query could > + * modify the heap page and clear PD_ALL_VISIBLE -- violating the > + * invariant that PD_ALL_VISIBLE must be set if the corresponding bit in > + * the VM is set. > + * > + * In recovery, we expect no other writers, so writing to the VM page > + * without holding a lock on the heap page is considered safe enough. It > + * is done this way when replaying xl_heap_visible records (see > */ > > How many copies of this comment do you plan to end up with? By the end, one for copy freeze replay and one for prune/freeze/vacuum replay. I felt two wasn't too bad and was easier than meta-explaining what the other comment was explaining. > > > 0004. It is not clear to me why you need to get > > > log_heap_prune_and_freeze to do the work here. Why can't > > > log_newpage_buffer get the job done already? > > > > Well, I need something to emit the changes to the VM. I'm eliminating > > all users of xl_heap_visible. Empty pages are the ones that benefit > > the least from switching from xl_heap_visible -> xl_heap_prune. But, > > if I don't transition them, we have to maintain all the > > xl_heap_visible code (including visibilitymap_set() in its long form). > > > > As for log_newpage_buffer(), I could keep it if you think it is too > > confusing to change log_heap_prune_and_freeze()'s API (by passing > > force_heap_fpi) to handle this case, I can leave log_newpage_buffer() > > there and then call log_heap_prune_and_freeze(). > > > > I just thought it seemed simple to avoid emitting the new page record > > and the VM update record, so why not -- but I don't have strong > > feelings. > > Yeah, I'm not sure what the right thing to do here is. I think I was > again experiencing brain fade by forgetting that there is a heap page > and a VM page and, of course, log_heap_newpage() probably isn't going > to touch the latter. So that makes sense. On the other hand, we could > only have one type of WAL record for every single operation in the > system if we gave it enough flags, and force_heap_fpi seems > suspiciously like a flag that turns this into a whole different kind > of WAL record. I've kept log_heap_newpage() and used log_heap_prune_and_freeze() for setting PD_ALL_VISIBLE and the VM. > > > 0005. It looks a little curious that you delete the > > > identify-corruption logic from the end of the if-nest and add it to > > > the beginning. Ceteris paribus, you'd expect that to be worse, since > > > corruption is a rare case. > > > > On master, the two corruption cases are sandwiched between the normal > > VM set cases. And I actually think doing it this way is brittle. If > > you put the cases which set the VM first, you have to have completely > > bulletproof the if statements guarding them to foreclose any possible > > corruption case from entering because otherwise you will overwrite the > > corruption you then try to detect. > > Hmm. In the current code, we first test (!all_visible_according_to_vm > && presult.all_visible), then (all_visible_according_to_vm && > !PageIsAllVisible(page) && visibilitymap_get_status(vacrel->rel, > blkno, &vmbuffer) != 0), and then (presult.lpdead_items > 0 && > PageIsAllVisible(page)). The first and second can never coexist, > because they require opposite values of all_visible_according_to_vm. > The second and third cannot coexist because they require opposite > values of PageIsAllVisible(page). It is not entirely obvious that the > first and third tests couldn't both pass, but you'd have to have > presult.all_visible and presult.lpdead_items > 0, and it's a bit hard > to see how heap_page_prune_and_freeze() could ever allow that. > Consider: > > if (prstate.all_visible && prstate.lpdead_items == 0) > { > presult->all_visible = prstate.all_visible; > presult->all_frozen = prstate.all_frozen; > } > else > { > presult->all_visible = false; > presult->all_frozen = false; > } > ... > presult->lpdead_items = prstate.lpdead_items; > > So I don't really think I'm persuaded that the current way is brittle. I meant brittle because it has to be so carefully coded for it to work out this way. If you ever wanted to change or enhance it, it's quite hard to know how to make sure all of them are entirely mutually exclusive. > But that having been said, I agree with you that the order of the > checks is kind of random, and I don't think it really matters that > much for performance. What does matter is clarity. I feel like what > I'd ideally like this logic to do is say: do we want the VM bit for > the page to be set to all-frozen, just all-visible, or neither? Then > push the VM bit to the correct state, dragging the page-level bit > along behind. And the current logic sort of does that. It's roughly: > > 1. Should we go from not-all-visible to either all-visible or > all-frozen? If yes, do so. > 2. Should we go from either all-visible or all-frozen to > not-all-visible? If yes, do so. > 3. Should we go from either all-visible or all-frozen to > not-all-visible for a different reason? If yes, do so. > 4. Should we go from all-visible to all-frozen? If yes, do so. I don't necessarily agree that fixing corruption and setting the VM should be together -- they feel like separate things to me. But, I don't feel strongly enough about it to push it. > But what's weird is that all the tests are written differently, and we > have two different reasons for going to not-all-visible, namely > PD_ALL_VISIBLE-not-set and dead-items-on-page, whereas there's only > one test for each of the other state-transitions, because the > decision-making for those cases is fully completed at an earlier > stage. I would kind of like to see this expressed in a way that first > decides which state transition to make (forward-to-all-frozen, > forward-to-all-visible, backward-to-all-visible, > backward-to-not-all-visible, nothing) and then does the corresponding > work. What you're doing instead is splitting half of those functions > off into a helper function while keeping the other half where they are > without cleaning up any of the logic. Now, maybe that's OK: I'm far > from having grokked the whole patch set. But it is not any more clear > than what we have now, IMHO, and perhaps even a bit less so. In terms of my patch set, I do have to change something about this mixture of fixing corruption and setting the VM because I need to set the VM bits in the same critical section as making the other changes to the heap page (pruning, etc) and include the VM set changes in the same WAL record (note that clearing the VM to fix corruption is not WAL-logged). What I've gone with is determining what to set the VM bits to and then fixing the corruption at the same time. Then, later, when making the changes to the heap page, I actually set the VM. This is kind of the opposite of what you suggested above -- determining what to set the bits to altogether -- corruption and non-corruption cases together. I don't think we can do that though, because fixing the corruption is non WAL-logged changes to the page and VM and setting the VM bits is a WAL-logged change. And, you can't clear bits with visibilitymap_set() (there's an assertion about that). So you have to call different functions (not to mention emit distinct error messages). I don't know that I've come up with the ideal solution, though. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2025-09-18T16:48:45Z
Hi, On 2025-09-17 20:10:07 -0400, Melanie Plageman wrote: > 0001 is RFC but waiting on one other reviewer > From cacff6c95e38d370b87148bc48cf6ac5f086ed07 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 17 Jun 2025 17:22:10 -0400 > Subject: [PATCH v14 01/24] Eliminate COPY FREEZE use of XLOG_HEAP2_VISIBLE > diff --git a/src/backend/access/heap/heapam_xlog.c b/src/backend/access/heap/heapam_xlog.c > index cf843277938..faa7c561a8a 100644 > --- a/src/backend/access/heap/heapam_xlog.c > +++ b/src/backend/access/heap/heapam_xlog.c > @@ -551,6 +551,7 @@ heap_xlog_multi_insert(XLogReaderState *record) > int i; > bool isinit = (XLogRecGetInfo(record) & XLOG_HEAP_INIT_PAGE) != 0; > XLogRedoAction action; > + Buffer vmbuffer = InvalidBuffer; > > /* > * Insertion doesn't overwrite MVCC data, so no conflict processing is > @@ -571,11 +572,11 @@ heap_xlog_multi_insert(XLogReaderState *record) > if (xlrec->flags & XLH_INSERT_ALL_VISIBLE_CLEARED) > { > Relation reln = CreateFakeRelcacheEntry(rlocator); > - Buffer vmbuffer = InvalidBuffer; > > visibilitymap_pin(reln, blkno, &vmbuffer); > visibilitymap_clear(reln, blkno, vmbuffer, VISIBILITYMAP_VALID_BITS); > ReleaseBuffer(vmbuffer); > + vmbuffer = InvalidBuffer; > FreeFakeRelcacheEntry(reln); > } > > @@ -662,6 +663,57 @@ heap_xlog_multi_insert(XLogReaderState *record) > if (BufferIsValid(buffer)) > UnlockReleaseBuffer(buffer); > > + buffer = InvalidBuffer; > + > + /* > + * Now read and update the VM block. > + * > + * Note that the heap relation may have been dropped or truncated, leading > + * us to skip updating the heap block due to the LSN interlock. I don't fully understand this - how does dropping/truncating the relation lead to skipping due to the LSN interlock? > + * even in that case, it's still safe to update the visibility map. Any > + * WAL record that clears the visibility map bit does so before checking > + * the page LSN, so any bits that need to be cleared will still be > + * cleared. > + * > + * Note that the lock on the heap page was dropped above. In normal > + * operation this would never be safe because a concurrent query could > + * modify the heap page and clear PD_ALL_VISIBLE -- violating the > + * invariant that PD_ALL_VISIBLE must be set if the corresponding bit in > + * the VM is set. > + * > + * In recovery, we expect no other writers, so writing to the VM page > + * without holding a lock on the heap page is considered safe enough. It > + * is done this way when replaying xl_heap_visible records (see > + * heap_xlog_visible()). > + */ > + if (xlrec->flags & XLH_INSERT_ALL_FROZEN_SET && > + XLogReadBufferForRedoExtended(record, 1, RBM_ZERO_ON_ERROR, false, > + &vmbuffer) == BLK_NEEDS_REDO) > + { Why are we using RBM_ZERO_ON_ERROR here? I know it's copied from heap_xlog_visible(), but I don't immediately understand (or remember) why we do so there either? > + Page vmpage = BufferGetPage(vmbuffer); > + Relation reln = CreateFakeRelcacheEntry(rlocator); Hm. Do we really need to continue doing this ugly fake relcache stuff? I'd really like to eventually get rid of that and given that the new "code shape" delegates a lot more responsibility to the redo routines, they should have a fairly easy time not needing a fake relcache? Afaict the relation already is not used outside of debugging paths? > + /* initialize the page if it was read as zeros */ > + if (PageIsNew(vmpage)) > + PageInit(vmpage, BLCKSZ, 0); > + > + visibilitymap_set_vmbits(reln, blkno, > + vmbuffer, > + VISIBILITYMAP_ALL_VISIBLE | > + VISIBILITYMAP_ALL_FROZEN); > + > + /* > + * It is not possible that the VM was already set for this heap page, > + * so the vmbuffer must have been modified and marked dirty. > + */ I assume that's because we a) checked the LSN interlock b) are replaying something that needed to newly set the bit? Except for the above comments, this looks pretty good to me. Seems 0002 should just be applied... Re 0003: I wonder if it's getting to the point that a struct should be used as the argument. Greetings, Andres Freund -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-09-24T17:07:46Z
On Thu, Sep 18, 2025 at 12:48 PM Andres Freund <andres@anarazel.de> wrote: > > On 2025-09-17 20:10:07 -0400, Melanie Plageman wrote: > > > + /* > > + * Now read and update the VM block. > > + * > > + * Note that the heap relation may have been dropped or truncated, leading > > + * us to skip updating the heap block due to the LSN interlock. > > I don't fully understand this - how does dropping/truncating the relation lead > to skipping due to the LSN interlock? Yes, this wasn't right. I misunderstood. What I think it should say is that if the heap update was skipped due to LSN interlock we still have to replay the updates to the VM because each vm page contains bits for multiple heap blocks and if the record included a vm page FPI, subsequent updates to the VM may rely on this FPI to avoid torn pages. We don't condition it on the heap redo having been an FPI, probably because it is not worth it -- but I wonder if that is worth calling out in the comment? Do we also need to replay it when the heap redo returns BLK_NOTFOUND? I assume this can happen in the case of relation dropped or truncated -- but in this case there wouldn't be subsequent records updating the VM for other heap blocks that we need to replay because the other heap blocks won't be found either, right? > > + if (xlrec->flags & XLH_INSERT_ALL_FROZEN_SET && > > + XLogReadBufferForRedoExtended(record, 1, RBM_ZERO_ON_ERROR, false, > > + &vmbuffer) == BLK_NEEDS_REDO) > > + { > > Why are we using RBM_ZERO_ON_ERROR here? I know it's copied from > heap_xlog_visible(), but I don't immediately understand (or remember) why we > do so there either? It has been RBM_ZERO_ON_ERROR since XLogReadBufferForRedoExtended() was introduced here in 2c03216d8311. I think we probably do this because vm_readbuf() passes ReadBuffer() RBM_ZERO_ON_ERROR and has this comment * For reading we use ZERO_ON_ERROR mode, and initialize the page if * necessary. It's always safe to clear bits, so it's better to clear * corrupt pages than error out. Do you think I also should have a comment in heap_xlog_multi_insert()? > > + Page vmpage = BufferGetPage(vmbuffer); > > + Relation reln = CreateFakeRelcacheEntry(rlocator); > > Hm. Do we really need to continue doing this ugly fake relcache stuff? I'd > really like to eventually get rid of that and given that the new "code shape" > delegates a lot more responsibility to the redo routines, they should have a > fairly easy time not needing a fake relcache? Afaict the relation already is > not used outside of debugging paths? Yes, interestingly we don't have the relname in recovery anyway, so it does all this fake relcache stuff only to convert the relfilenode to a string and uses that. The fake relcache stuff will still be used by visibilitymap_pin() which callers like heap_xlog_delete() use to get the VM page. And I don't think it is worth coming up with a version of that that doesn't use the relcache. But you're right that the Relation is not needed for visibilitymap_set_vmbits(). I've changed it to just take the relation name as a string. > > + /* initialize the page if it was read as zeros */ > > + if (PageIsNew(vmpage)) > > + PageInit(vmpage, BLCKSZ, 0); > > + > > + visibilitymap_set_vmbits(reln, blkno, > > + vmbuffer, > > + VISIBILITYMAP_ALL_VISIBLE | > > + VISIBILITYMAP_ALL_FROZEN); > > + > > + /* > > + * It is not possible that the VM was already set for this heap page, > > + * so the vmbuffer must have been modified and marked dirty. > > + */ > > I assume that's because we a) checked the LSN interlock b) are replaying > something that needed to newly set the bit? Yes, perhaps it is not worth having the assert since it attracts extra attention to an invariant that is unlikely to be in danger of regression. > Seems 0002 should just be applied... Done > Re 0003: I wonder if it's getting to the point that a struct should be used as > the argument. I have been thinking about this. I have yet to come up with a good idea for a struct name or multiple struct names that seem to fit here. I could move the other output parameters into the PruneFreezeResult and then maybe make some kind of PruneFreezeParameters struct or something? - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Robert Haas <robertmhaas@gmail.com> — 2025-09-24T20:13:43Z
I find this patch set quite hard to follow. 0001 altogether removes the use of XLOG_HEAP2_VISIBLE in cases where we use XLOG_HEAP2_MULTI_INSERT, but then 0007 (the next non-refactoring patch) begins half-removing the dependency on XLOG_HEAP2_VISIBLE, assisted by 0009 and 0010, and then later you come back and remove the other half of the dependency. I know it was I who proposed (off-list) first making the XLOG_HEAP2_VISIBLE record only deal with the VM page and not the heap buffer, but I'm not sure that idea quite worked out in terms of making this easier to follow. At the least, it seems weird that COPY FREEZE is an exception that gets handled in a different way than all the other cases, fully removing the dependency in one step. It would also be nice if each time you repost this, or maybe in a README that you post along beside the actual patches, you'd include some kind of roadmap to help the reader understand the internal structure of the patch set, like 1 does this, 2-9 get us to here, 10-whatever get us to this next place. I don't really understand how the interlocking works. 0011 changes visibilitymap_set so that it doesn't take the heap block as an argument, but we'd better hold a lock on the heap page while setting the VM bit, otherwise I think somebody could come along and modify the heap page after we decided it was all-visible and before we actually set the VM bit, which would be terrible. I would expect the comments and the commit message to say something about that, but I don't see that they do. I find myself fearful of the way that 0007 propagates the existing hacks around setting the VM bit into a new place: + /* + * We always emit a WAL record when setting PD_ALL_VISIBLE, but we are + * careful not to emit a full page image unless + * checksums/wal_log_hints are enabled. We only set the heap page LSN + * if full page images were an option when emitting WAL. Otherwise, + * subsequent modifications of the page may incorrectly skip emitting + * a full page image. + */ + if (do_prune || nplans > 0 || + (xlrec.flags & XLHP_SET_PD_ALL_VIS && XLogHintBitIsNeeded())) + PageSetLSN(page, lsn); I suppose it's not the worst thing to duplicate this logic, because you're later going to remove the original copy. But, it took me >10 minutes to find the text in src/backend/access/transam/README, in the second half of the "Writing Hints" section, that explains the overall principle here, and since the patch set doesn't seem to touch that text, maybe you weren't even aware it was there. And, it's a little weird to have a single WAL record that is either a hint or not a hint depending on a complex set of conditions. (IMHO mixing & and && without parentheses is quite brave, and an explicit != 0 might not be a bad idea either.) Anyway, I kind of wonder if it's time to back out the hack that I installed here many years ago. At the time, I thought that it would be bad if a VACUUM swept over the visibility map setting VM bits and as a result emitted an FPI for every page in the entire heap ... but everyone who is running with checksums has accepted that cost already, and with those being the default, that's probably going to be most people. It would be even more compelling if we were going to freeze, prune, and set all-visible on access, because then presumably the case where we touch a page and ONLY set the VM bit would be rare, so the cost of doing that wouldn't matter much, but I guess the patch doesn't go that far -- we can freeze or set all-visible on access but not prune, without which the scenario I was worrying about at the time is still fairly plausible, I think, if checksums are turned off. -- Robert Haas EDB: http://www.enterprisedb.com
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-10-06T22:40:20Z
On Wed, Sep 24, 2025 at 4:13 PM Robert Haas <robertmhaas@gmail.com> wrote: > > I find this patch set quite hard to follow. 0001 altogether removes > the use of XLOG_HEAP2_VISIBLE in cases where we use > XLOG_HEAP2_MULTI_INSERT, but then 0007 (the next non-refactoring > patch) begins half-removing the dependency on XLOG_HEAP2_VISIBLE, > assisted by 0009 and 0010, and then later you come back and remove the > other half of the dependency. I know it was I who proposed (off-list) > first making the XLOG_HEAP2_VISIBLE record only deal with the VM page > and not the heap buffer, but I'm not sure that idea quite worked out > in terms of making this easier to follow. At the least, it seems weird > that COPY FREEZE is an exception that gets handled in a different way > than all the other cases, fully removing the dependency in one step. > It would also be nice if each time you repost this, or maybe in a > README that you post along beside the actual patches, you'd include > some kind of roadmap to help the reader understand the internal > structure of the patch set, like 1 does this, 2-9 get us to here, > 10-whatever get us to this next place. In attached v16, I’ve reverted to removing XLOG_HEAP2_VISIBLE entirely, rather than first removing each caller's heap page from the VM WAL chain. I reordered changes and squashed several refactoring patches to improve patch-by-patch readability. This should make the set read differently from earlier versions that removed XLOG_HEAP2_VISIBLE and had more step-by-step mechanical refactoring. I think if we plan to go all the way with removing XLOG_HEAP2_VISIBLE, having intermediate patches that just set PD_ALL_VISIBLE when making other heap pages are more confusing than helpful. Also, I think having separate flags for setting PD_ALL_VISIBLE in the WAL record over-complicated the code. 0001: remove XLOG_HEAP2_VISIBLE from COPY FREEZE 0002 - 0005: various refactoring in advance of removing XLOG_HEAP2_VISIBLE in pruning 0006: Pruning and freezing by vacuum sets the VM and emits a single WAL record with those changes 0007: Reaping (phase III) by vacuum sets the VM and sets line pointers unused in a single WAL record 0008 - 0009: XLOG_HEAP2_VISIBLE is eliminated 0010 - 0012: preparation for setting VM on-access 0013: set VM on-access 0014: set pd_prune_xid on insert > I find myself fearful of the way that 0007 propagates the existing > hacks around setting the VM bit into a new place: > > + /* > + * We always emit a WAL record when setting > PD_ALL_VISIBLE, but we are > + * careful not to emit a full page image unless > + * checksums/wal_log_hints are enabled. We only set > the heap page LSN > + * if full page images were an option when emitting > WAL. Otherwise, > + * subsequent modifications of the page may > incorrectly skip emitting > + * a full page image. > + */ > + if (do_prune || nplans > 0 || > + (xlrec.flags & XLHP_SET_PD_ALL_VIS && > XLogHintBitIsNeeded())) > + PageSetLSN(page, lsn); > > I suppose it's not the worst thing to duplicate this logic, because > you're later going to remove the original copy. But, it took me >10 > minutes to find the text in src/backend/access/transam/README, in the > second half of the "Writing Hints" section, that explains the overall > principle here, and since the patch set doesn't seem to touch that > text, maybe you weren't even aware it was there. I don't think that src/backend/access/transam/README must change with my patch. It is still true that if the only change we are making to the heap page is setting PD_ALL_VISIBLE and checksums/wal_log_hints are disabled, we explicitly avoid an FPI and thus can't stamp the page LSN. > And, it's a little > weird to have a single WAL record that is either a hint or not a hint > depending on a complex set of conditions. PD_ALL_VISIBLE is different from tuple hints and other page hints because setting the VM is always WAL logged and when we replay that, it will always set PD_ALL_VISIBLE, so PD_ALL_VISIBLE is effectively always WAL-logged. The other hints aren't wal-logged unless checksums are enabled and we need an FPI. So PD_ALL_VISIBLE is different from other page hints in multiple ways. We can't make it more like those hints because of needing to preserve the invariant that the VM is never set when the page is clear. The only thing we could do is forbid omitting the FPI even when checksums are not enabled. > Anyway, I kind of wonder if it's time to back out the hack that I > installed here many years ago. At the time, I thought that it would be > bad if a VACUUM swept over the visibility map setting VM bits and as a > result emitted an FPI for every page in the entire heap ... but > everyone who is running with checksums has accepted that cost already, > and with those being the default, that's probably going to be most > people. I agree that PD_ALL_VISIBLE persistence is complicated, but we have other special cases that complicate the code for a performance benefit. I guess the question is if we are saying people shouldn't run without checksums in production. If that's true, then it's fine to remove this optimization. Otherwise, I'm not so sure. I think cloud providers generally have checksums enabled, but I don't know what is common on-prem. > It would be even more compelling if we were going to freeze, > prune, and set all-visible on access, because then presumably the case > where we touch a page and ONLY set the VM bit would be rare, so the > cost of doing that wouldn't matter much, but I guess the patch doesn't > go that far -- we can freeze or set all-visible on access but not > prune, without which the scenario I was worrying about at the time is > still fairly plausible, I think, if checksums are turned off. With the whole set applied, we can prune and set the VM on access but not freeze. I have a patch to do that, but it introduced noticeable CPU overhead to prepare the freeze plans. I'd have to spend much more time studying it to avoid regressing workloads where we don't end up freezing but prepare the freeze plans during SELECT queries. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-10-08T22:54:25Z
On Mon, Oct 6, 2025 at 6:40 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > In attached v16, I’ve reverted to removing XLOG_HEAP2_VISIBLE > entirely, rather than first removing each caller's heap page from the > VM WAL chain. I reordered changes and squashed several refactoring > patches to improve patch-by-patch readability. This should make the > set read differently from earlier versions that removed > XLOG_HEAP2_VISIBLE and had more step-by-step mechanical refactoring. > > I think if we plan to go all the way with removing XLOG_HEAP2_VISIBLE, > having intermediate patches that just set PD_ALL_VISIBLE when making > other heap pages are more confusing than helpful. Also, I think having > separate flags for setting PD_ALL_VISIBLE in the WAL record > over-complicated the code. I decided to reorder the patches to remove XLOG_HEAP2_VISIBLE from vacuum phase III before removing it from vacuum phase I because removing it from phase III doesn't require preliminary refactoring patches. I've done that in the attached v17. I've also added an experimental patch on the end that refactors large chunks of heap_page_prune_and_freeze() into helpers. I got some feedback off-list that heap_page_prune_and_freeze() is too unwieldy now. I'm not sure how I feel about them yet, so I haven't documented them or moved them up in the patch set to before changes to heap_page_prune_and_freeze(). 0001: Eliminate XLOG_HEAP2_VISIBLE from COPY FREEZE 0002: Eliminate XLOG_HEAP2_VISIBLE from phase III of vacuum 0003 - 0006: cleanup and refactoring to prepare for 0007 0007: Eliminate XLOG_HEAP2_VISIBLE from vacuum prune/freeze 0008 - 0009: Remove XLOG_HEAP2_VISIBLE 0010 - 0012: refactoring to prepare for 0013 0013: Set VM on-access 0014: Set pd_prune_xid on insert 0015: Experimental refactoring of heap_page_prune_and_freeze into helpers - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2025-10-09T18:18:49Z
Hi, On 2025-10-08 18:54:25 -0400, Melanie Plageman wrote: > +uint8 > +visibilitymap_set_vmbits(BlockNumber heapBlk, > + Buffer vmBuf, uint8 flags, > + const char *heapRelname) > +{ > + BlockNumber mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk); > + uint32 mapByte = HEAPBLK_TO_MAPBYTE(heapBlk); > + uint8 mapOffset = HEAPBLK_TO_OFFSET(heapBlk); > + Page page; > + uint8 *map; > + uint8 status; > + > +#ifdef TRACE_VISIBILITYMAP > + elog(DEBUG1, "vm_set flags 0x%02X for %s %d", > + flags, heapRelname, heapBlk); > +#endif I like it doesn't take a Relation anymore, but I'd just pass the smgrrelation instead, then you don't need to allocate the string in the caller, when it's approximately never used. Otherwise this looks pretty close to me. > @@ -71,12 +84,12 @@ heap_xlog_prune_freeze(XLogReaderState *record) > } > > /* > - * If we have a full-page image, restore it and we're done. > + * If we have a full-page image of the heap block, restore it and we're > + * done with the heap block. > */ > - action = XLogReadBufferForRedoExtended(record, 0, RBM_NORMAL, > - (xlrec.flags & XLHP_CLEANUP_LOCK) != 0, > - &buffer); > - if (action == BLK_NEEDS_REDO) > + if (XLogReadBufferForRedoExtended(record, 0, RBM_NORMAL, > + (xlrec.flags & XLHP_CLEANUP_LOCK) != 0, > + &buffer) == BLK_NEEDS_REDO) > { > Page page = BufferGetPage(buffer); > OffsetNumber *redirected; Why move it around this way? > @@ -138,36 +157,104 @@ heap_xlog_prune_freeze(XLogReaderState *record) > /* There should be no more data */ > Assert((char *) frz_offsets == dataptr + datalen); > > + if ((vmflags & VISIBILITYMAP_VALID_BITS)) > + PageSetAllVisible(page); > + > + MarkBufferDirty(buffer); > + > + /* > + * Always emit a WAL record when setting PD_ALL_VISIBLE but only emit > + * an FPI if checksums/wal_log_hints are enabled. This comment reads as-if we're WAL logging here, but this is a Wendy's^Wrecovery. > Advance the page LSN > + * only if the record could include an FPI, since recovery skips > + * records <= the stamped LSN. Otherwise it might skip an earlier FPI > + * needed to repair a torn page. > + */ This is confusing, should probably just reference the stuff we did in the !recovery case. > + if (do_prune || nplans > 0 || > + ((vmflags & VISIBILITYMAP_VALID_BITS) && XLogHintBitIsNeeded())) > + PageSetLSN(page, lsn); > + > /* > * Note: we don't worry about updating the page's prunability hints. > * At worst this will cause an extra prune cycle to occur soon. > */ Not your fault, but that seems odd? Why aren't we just doing the right thing? > /* > - * If we released any space or line pointers, update the free space map. > + * If we released any space or line pointers or set PD_ALL_VISIBLE or the > + * VM, update the freespace map. I'd replace the first or with a , ;) > + * Even when no actual space is freed (e.g., when only marking the page > + * all-visible or frozen), we still update the FSM. Because the FSM is > + * unlogged and maintained heuristically, it often becomes stale on > + * standbys. If such a standby is later promoted and runs VACUUM, it will > + * skip recalculating free space for pages that were marked all-visible > + * (or all-frozen, depending on the mode). FreeSpaceMapVacuum can then > + * propagate overly optimistic free space values upward, causing future > + * insertions to select pages that turn out to be unusable. In bulk, this > + * can lead to long stalls. > + * > + * To prevent this, always refresh the FSM’s view when a page becomes > + * all-visible or all-frozen. I'd s/refresh/update/, because refresh sounds more like rereading the current state of the FSM, rather than changing the FSM. > + /* We don't have relation name during recovery, so use relfilenode */ > + relname = psprintf("%u", rlocator.relNumber); > + old_vmbits = visibilitymap_set_vmbits(blkno, vmbuffer, vmflags, relname); > > - XLogRecordPageWithFreeSpace(rlocator, blkno, freespace); > + /* Only set VM page LSN if we modified the page */ > + if (old_vmbits != vmflags) > + { > + Assert(BufferIsDirty(vmbuffer)); > + PageSetLSN(BufferGetPage(vmbuffer), lsn); > } > - else > - UnlockReleaseBuffer(buffer); > + pfree(relname); Hm. When can we actually enter the old_vmbits == vmflags case? It might also be fine to just say that we don't expect it to change but are mirroring the code in visibilitymap_set(). I wonder if the VM specific redo portion should be in a common helper? Might not be enough code to worry though... > @@ -2070,8 +2079,24 @@ log_heap_prune_and_freeze(Relation relation, Buffer buffer, > xlhp_prune_items dead_items; > xlhp_prune_items unused_items; > OffsetNumber frz_offsets[MaxHeapTuplesPerPage]; > + bool do_prune = nredirected > 0 || ndead > 0 || nunused > 0; > + bool do_set_vm = vmflags & VISIBILITYMAP_VALID_BITS; > > xlrec.flags = 0; > + regbuf_flags = REGBUF_STANDARD; > + > + Assert((vmflags & VISIBILITYMAP_VALID_BITS) == vmflags); > + > + /* > + * We can avoid an FPI if the only modification we are making to the heap > + * page is to set PD_ALL_VISIBLE and checksums/wal_log_hints are disabled. Maybe s/an FPI/an FPI for the heap pae/? > + * Note that if we explicitly skip an FPI, we must not set the heap page > + * LSN later. > + */ > + if (!do_prune && > + nfrozen == 0 && > + (!do_set_vm || !XLogHintBitIsNeeded())) > + regbuf_flags |= REGBUF_NO_IMAGE; > /* > * Prepare data for the buffer. The arrays are not actually in the > @@ -2079,7 +2104,11 @@ log_heap_prune_and_freeze(Relation relation, Buffer buffer, > * page image, the arrays can be omitted. > */ > XLogBeginInsert(); > - XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); > + XLogRegisterBuffer(0, buffer, regbuf_flags); > + > + if (do_set_vm) > + XLogRegisterBuffer(1, vmbuffer, 0); Seems a bit confusing that it's named regbuf_flags but isn't used all the XLogRegisterBuffer calls. Maybe make the name more specific (regbuf_flags_heap?)... > } > recptr = XLogInsert(RM_HEAP2_ID, info); > > - PageSetLSN(BufferGetPage(buffer), recptr); > + if (do_set_vm) > + { > + Assert(BufferIsDirty(vmbuffer)); > + PageSetLSN(BufferGetPage(vmbuffer), recptr); > + } > + /* > + * We must bump the page LSN if pruning or freezing. If we are only > + * updating PD_ALL_VISIBLE, though, we can skip doing this unless > + * wal_log_hints/checksums are enabled. Torn pages are possible if we > + * update PD_ALL_VISIBLE without bumping the LSN, but this is deemed okay > + * for page hint updates. > + */ Arguably it's not a torn page if we only modified something as narrow as a hint bit, and are redoing that change after recovery. But that's extremely nitpicky. I wonder if the comment explaining this should be put into one place and reference it from all the different places. > @@ -2860,6 +2867,29 @@ lazy_vacuum_heap_page(LVRelState *vacrel, BlockNumber blkno, Buffer buffer, > VACUUM_ERRCB_PHASE_VACUUM_HEAP, blkno, > InvalidOffsetNumber); > > + /* > + * Before marking dead items unused, check whether the page will become > + * all-visible once that change is applied. So the function is named _would_ but here you say will :) > This lets us reap the tuples > + * and mark the page all-visible within the same critical section, > + * enabling both changes to be emitted in a single WAL record. Since the > + * visibility checks may perform I/O and allocate memory, they must be > + * done outside the critical section. > + */ > + if (heap_page_would_be_all_visible(vacrel, buffer, > + deadoffsets, num_offsets, > + &all_frozen, &visibility_cutoff_xid)) > + { > + vmflags |= VISIBILITYMAP_ALL_VISIBLE; > + if (all_frozen) > + { > + vmflags |= VISIBILITYMAP_ALL_FROZEN; > + Assert(!TransactionIdIsValid(visibility_cutoff_xid)); > + } > + > + /* Take the lock on the vmbuffer before entering a critical section */ > + LockBuffer(vmbuffer, BUFFER_LOCK_EXCLUSIVE); It sure would be nice if we had documented the lock order between the heap page and the corresponding VM page anywhere. This is just doing what we did before, so it's not this patch's fault, but I did get worried about it for a moment. > +/* > + * Check whether the heap page in buf is all-visible except for the dead > + * tuples referenced in the deadoffsets array. > + * > + * The visibility checks may perform IO and allocate memory so they must not > + * be done in a critical section. This function is used by vacuum to determine > + * if the page will be all-visible once it reaps known dead tuples. That way > + * it can do both in the same critical section and emit a single WAL record. > + * > + * Returns true if the page is all-visible other than the provided > + * deadoffsets and false otherwise. > + * > + * Output parameters: > + * > + * - *all_frozen: true if every tuple on the page is frozen > + * - *visibility_cutoff_xid: newest xmin; valid only if page is all-visible > + * Callers looking to verify that the page is already all-visible can call > + * heap_page_is_all_visible(). > + * > + * This logic is closely related to heap_prune_record_unchanged_lp_normal(). > + * If you modify this function, ensure consistency with that code. An > + * assertion cross-checks that both remain in agreement. Do not introduce new > + * side-effects. > + */ > +static bool > +heap_page_would_be_all_visible(LVRelState *vacrel, Buffer buf, > + OffsetNumber *deadoffsets, > + int ndeadoffsets, > + bool *all_frozen, > + TransactionId *visibility_cutoff_xid) > +{ > Page page = BufferGetPage(buf); > BlockNumber blockno = BufferGetBlockNumber(buf); > OffsetNumber offnum, > maxoff; > bool all_visible = true; > + int matched_dead_count = 0; > > *visibility_cutoff_xid = InvalidTransactionId; > *all_frozen = true; > > + Assert(ndeadoffsets == 0 || deadoffsets); > + > +#ifdef USE_ASSERT_CHECKING > + /* Confirm input deadoffsets[] is strictly sorted */ > + if (ndeadoffsets > 1) > + { > + for (int i = 1; i < ndeadoffsets; i++) > + Assert(deadoffsets[i - 1] < deadoffsets[i]); > + } > +#endif > + > maxoff = PageGetMaxOffsetNumber(page); > for (offnum = FirstOffsetNumber; > offnum <= maxoff && all_visible; > @@ -3649,9 +3712,15 @@ heap_page_is_all_visible(LVRelState *vacrel, Buffer buf, > */ > if (ItemIdIsDead(itemid)) > { > - all_visible = false; > - *all_frozen = false; > - break; > + if (!deadoffsets || > + matched_dead_count >= ndeadoffsets || > + deadoffsets[matched_dead_count] != offnum) > + { > + *all_frozen = all_visible = false; > + break; > + } > + matched_dead_count++; > + continue; > } > > Assert(ItemIdIsNormal(itemid)); Hm, what about an assert checking that matched_dead_count == ndeadoffsets at the end? > From 6b5fc27f0d80bab1df86a2e6fb51b64fd20c3cbb Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Mon, 15 Sep 2025 12:06:19 -0400 > Subject: [PATCH v17 03/15] Assorted trivial heap_page_prune_and_freeze cleanup Seems like a good idea, but I'm too lazy to go through this in detail. > From c69a5219a9b792f3c9f6dc730b8810a88d088ae6 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 16 Sep 2025 14:22:10 -0400 > Subject: [PATCH v17 04/15] Add helper for freeze determination to > heap_page_prune_and_freeze > > After scanning through the line pointers on the heap page during > vacuum's first phase, we use several statuses and information we > collected to determine whether or not we will use the freeze plans we > assembled. > > Do this in a helper for better readability. > @@ -663,85 +775,11 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > * Decide if we want to go ahead with freezing according to the freeze > * plans we prepared, or not. > */ > - do_freeze = false; > - ... > + do_freeze = heap_page_will_freeze(params->relation, buffer, > + did_tuple_hint_fpi, > + do_prune, > + do_hint_prune, > + &prstate); > Assuming this is just moving the code, I like this quite bit. > From d4a4be3eed25853fc1ea84ebc2cbe0226afd823a Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Mon, 15 Sep 2025 16:25:44 -0400 > Subject: [PATCH v17 05/15] Update PruneState.all_[visible|frozen] earlier in > pruning > MIME-Version: 1.0 > Content-Type: text/plain; charset=UTF-8 > Content-Transfer-Encoding: 8bit > > In the prune/freeze path, we currently delay clearing all_visible and > all_frozen when dead items are present. This allows opportunistic > freezing if the page would otherwise be fully frozen, since those dead > items are later removed in vacuum’s third phase. > > However, if no freezing will be attempted, there’s no need to delay. > Clearing the flags promptly avoids extra bookkeeping in > heap_prune_unchanged_lp_normal(). At present this has no runtime effect > because all callers that consider setting the VM also attempt freezing, > but future callers (e.g. on-access pruning) may want to set the VM > without preparing freeze plans. s/heap_prune_unchanged_lp_normal/heap_prune_record_unchanged_lp_normal/ I think this should make it clearer that this is about reducing overhead for future use of this code in on-access-pruning. > We also used to defer clearing all_visible and all_frozen until after > computing the visibility cutoff XID. By determining the cutoff earlier, > we can update these flags immediately after deciding whether to > opportunistically freeze. This is necessary if we want to set the VM in > the same WAL record that prunes and freezes tuples on the page. I think this last sentence needs to be first. This is the only really important thing in this patch, afaict. > From 86193a71d2ff9649b5b1c1e6963bd610285ad369 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Fri, 3 Oct 2025 15:57:02 -0400 > Subject: [PATCH v17 06/15] Make heap_page_is_all_visible independent of > LVRelState > > Future commits will use this function inside of pruneheap.c where we do > not have access to the LVRelState. We only need a few parameters from > the LVRelState, so just pass those in explicitly. > > Author: Melanie Plageman <melanieplageman@gmail.com> > Reviewed-by: Kirill Reshke <reshkekirill@gmail.com> > Reviewed-by: Robert Haas <robertmhaas@gmail.com> > Discussion: https://postgr.es/m/flat/CAAKRu_ZMw6Npd_qm2KM%2BFwQ3cMOMx1Dh3VMhp8-V7SOLxdK9-g%40mail.gmail.com Makes sense. I don't think we need to wait for other stuff to be committed to commit this. > From dde0dfc578137f7c93f9a0e34af38dcdb841b080 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 8 Oct 2025 15:39:01 -0400 > Subject: [PATCH v17 07/15] Eliminate XLOG_HEAP2_VISIBLE from vacuum > prune/freeze > MIME-Version: 1.0 > Content-Type: text/plain; charset=UTF-8 > Content-Transfer-Encoding: 8bit Seems very mildly odd that 0002 references phase III in the subject, but this doesn't... (I'm just very lightly skimming from this point on) > During vacuum's first and third phases, we examine tuples' visibility > to determine if we can set the page all-visible in the visibility map. > > Previously, this check compared tuple xmins against a single XID chosen at > the start of vacuum (OldestXmin). We now use GlobalVisState, which also > enables future work to set the VM during on-access pruning, since ordinary > queries have access to GlobalVisState but not OldestXmin. > > This also benefits vacuum directly: GlobalVisState may advance > during a vacuum, allowing more pages to become considered all-visible. > In the rare case that it moves backward, VACUUM falls back to OldestXmin > to ensure we don’t attempt to freeze a dead tuple that wasn’t yet > prunable according to the GlobalVisState. It could, but it currently won't advance in vacuum, right? > From e412f9298b0735d1091f4769ace4d2d1a7e62312 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 29 Jul 2025 09:57:13 -0400 > Subject: [PATCH v17 12/15] Inline TransactionIdFollows/Precedes() > > Calling these from on-access pruning code had noticeable overhead in a > profile. There does not seem to be a reason not to inline them. Makes sense, just commit this ahead of the more complicated rest. > From 54fcba140e515eba0eb1f9d48e7d5875b92e7e39 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 29 Jul 2025 14:34:30 -0400 > Subject: [PATCH v17 13/15] Allow on-access pruning to set pages all-visible Sorry, will have to look at this another time... Greetings, Andres Freund -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-10-14T03:31:04Z
On Thu, 9 Oct 2025 at 03:54, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Mon, Oct 6, 2025 at 6:40 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > > In attached v16, I’ve reverted to removing XLOG_HEAP2_VISIBLE > > entirely, rather than first removing each caller's heap page from the > > VM WAL chain. I reordered changes and squashed several refactoring > > patches to improve patch-by-patch readability. This should make the > > set read differently from earlier versions that removed > > XLOG_HEAP2_VISIBLE and had more step-by-step mechanical refactoring. > > > > I think if we plan to go all the way with removing XLOG_HEAP2_VISIBLE, > > having intermediate patches that just set PD_ALL_VISIBLE when making > > other heap pages are more confusing than helpful. Also, I think having > > separate flags for setting PD_ALL_VISIBLE in the WAL record > > over-complicated the code. > > I decided to reorder the patches to remove XLOG_HEAP2_VISIBLE from > vacuum phase III before removing it from vacuum phase I because > removing it from phase III doesn't require preliminary refactoring > patches. I've done that in the attached v17. > > I've also added an experimental patch on the end that refactors large > chunks of heap_page_prune_and_freeze() into helpers. I got some > feedback off-list that heap_page_prune_and_freeze() is too unwieldy > now. I'm not sure how I feel about them yet, so I haven't documented > them or moved them up in the patch set to before changes to > heap_page_prune_and_freeze(). > > 0001: Eliminate XLOG_HEAP2_VISIBLE from COPY FREEZE > 0002: Eliminate XLOG_HEAP2_VISIBLE from phase III of vacuum > 0003 - 0006: cleanup and refactoring to prepare for 0007 > 0007: Eliminate XLOG_HEAP2_VISIBLE from vacuum prune/freeze > 0008 - 0009: Remove XLOG_HEAP2_VISIBLE > 0010 - 0012: refactoring to prepare for 0013 > 0013: Set VM on-access > 0014: Set pd_prune_xid on insert > 0015: Experimental refactoring of heap_page_prune_and_freeze into helpers > > - Melanie Hi! Should we also bump XLOG_PAGE_MAGIC after d96f87332 & add323da40a or do we wait for full set to be committed? -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Michael Paquier <michael@paquier.xyz> — 2025-10-14T03:42:59Z
On Tue, Oct 14, 2025 at 08:31:04AM +0500, Kirill Reshke wrote: > Hi! Should we also bump XLOG_PAGE_MAGIC after d96f87332 & add323da40a > or do we wait for full set to be committed? I may be missing something, of course, but d96f87332 has not changed the WAL format, VISIBILITYMAP_ALL_VISIBLE and VISIBILITYMAP_ALL_FROZEN existing before that. The change in xl_heap_prune as done in add323da40a6 should have bumped the format number. -- Michael
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-10-14T14:16:24Z
On Mon, Oct 13, 2025 at 11:43 PM Michael Paquier <michael@paquier.xyz> wrote: > > On Tue, Oct 14, 2025 at 08:31:04AM +0500, Kirill Reshke wrote: > > Hi! Should we also bump XLOG_PAGE_MAGIC after d96f87332 & add323da40a > > or do we wait for full set to be committed? > > I may be missing something, of course, but d96f87332 has not changed > the WAL format, VISIBILITYMAP_ALL_VISIBLE and VISIBILITYMAP_ALL_FROZEN > existing before that. The change in xl_heap_prune as done in > add323da40a6 should have bumped the format number. Oops! Thanks for reporting. I messed up and forgot to do this. And, if I'm not misunderstanding the criteria, I did the same thing at the beginning of September with 4b5f206de2bb. I've committed the bump. Hopefully I learned my lesson. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-10-14T23:26:57Z
Thanks so much for the review! I've addressed all your feedback except what is commented on inline below. I've gone ahead and committed the preliminary patches that you thought were ready to commit. Attached v18 is what remains. 0001 - 0003: refactoring 0004 - 0006: finish eliminating XLOG_HEAP2_VISIBLE 0007 - 0009: refactoring 0010: Set VM on-access 0011: Set prune xid on insert 0012: Some refactoring for discussion For 0001, I got feedback heap_page_prune_and_freeze() has too many arguments, so I tried to address that. I'm interested to know if folks like this more. 0011 still needs a bit of investigation to understand fully if anything else in the index-killtuples test needs to be changed to make sure we have the same coverage. 0012 is sort of WIP. I got feedback heap_page_prune_and_freeze() was too long and should be split up into helpers. I want to know if this split makes sense. I can pull it down the patch stack if so. Only 0001 and 0012 are optional amongst the refactoring patches. The others are required to make on-access VM-setting possible or viable. On Thu, Oct 9, 2025 at 2:18 PM Andres Freund <andres@anarazel.de> wrote: > > > @@ -71,12 +84,12 @@ heap_xlog_prune_freeze(XLogReaderState *record) > > } > > - action = XLogReadBufferForRedoExtended(record, 0, RBM_NORMAL, > > - (xlrec.flags & XLHP_CLEANUP_LOCK) != 0, > > - &buffer); > > - if (action == BLK_NEEDS_REDO) > > + if (XLogReadBufferForRedoExtended(record, 0, RBM_NORMAL, > > + (xlrec.flags & XLHP_CLEANUP_LOCK) != 0, > > + &buffer) == BLK_NEEDS_REDO) > > { > > Page page = BufferGetPage(buffer); > > OffsetNumber *redirected; > > Why move it around this way? Because there will be an action for the visibility map XLogReadBufferForRedoExtended(). I could have renamed it heap_action, but it is being used only in one place, so I preferred to just cut it to avoid any confusion. > > Advance the page LSN > > + * only if the record could include an FPI, since recovery skips > > + * records <= the stamped LSN. Otherwise it might skip an earlier FPI > > + * needed to repair a torn page. > > + */ > > This is confusing, should probably just reference the stuff we did in the > !recovery case. I fixed this and addressed all your feedback related to this before committing. > > + if (do_prune || nplans > 0 || > > + ((vmflags & VISIBILITYMAP_VALID_BITS) && XLogHintBitIsNeeded())) > > + PageSetLSN(page, lsn); > > + > > /* > > * Note: we don't worry about updating the page's prunability hints. > > * At worst this will cause an extra prune cycle to occur soon. > > */ > > Not your fault, but that seems odd? Why aren't we just doing the right thing? The comment dates back to 6f10eb2. I imagine no one ever bothered to fuss with extracting the XID. You could change heap_page_prune_execute() to return the right value -- though that's a bit ugly since it is used in normal operation as well as recovery. > I wonder if the VM specific redo portion should be in a common helper? Might > not be enough code to worry though... I think it might be more code as a helper at this point. > > @@ -2860,6 +2867,29 @@ lazy_vacuum_heap_page(LVRelState *vacrel, BlockNumber blkno, Buffer buffer, > > VACUUM_ERRCB_PHASE_VACUUM_HEAP, blkno, > > InvalidOffsetNumber); > > > > + /* > > + * Before marking dead items unused, check whether the page will become > > + * all-visible once that change is applied. > > So the function is named _would_ but here you say will :) I thought about it more and still feel that this function name should contain "would". From vacuum's perspective it is "will" -- because it knows it will remove those dead items, but from the function's perspective it is hypothetical. I changed the comment though. > > + if (heap_page_would_be_all_visible(vacrel, buffer, > > + deadoffsets, num_offsets, > > + &all_frozen, &visibility_cutoff_xid)) > > + { > > + vmflags |= VISIBILITYMAP_ALL_VISIBLE; > > + if (all_frozen) > > + { > > + vmflags |= VISIBILITYMAP_ALL_FROZEN; > > + Assert(!TransactionIdIsValid(visibility_cutoff_xid)); > > + } > > + > > + /* Take the lock on the vmbuffer before entering a critical section */ > > + LockBuffer(vmbuffer, BUFFER_LOCK_EXCLUSIVE); > > It sure would be nice if we had documented the lock order between the heap > page and the corresponding VM page anywhere. This is just doing what we did > before, so it's not this patch's fault, but I did get worried about it for a > moment. Well, the comment above the visibilitymap_set* functions says what expectations they have for the heap page being locked. > > +static bool > > +heap_page_would_be_all_visible(LVRelState *vacrel, Buffer buf, > > + OffsetNumber *deadoffsets, > > + int ndeadoffsets, > > + bool *all_frozen, > > + TransactionId *visibility_cutoff_xid) > > +{ > > Page page = BufferGetPage(buf); > Hm, what about an assert checking that matched_dead_count == ndeadoffsets at > the end? I was going to put an Assert(ndeadoffsets <= matched_dead_count), but then I started wondering if there is a way we could end up with fewer dead items than we collected during phase I. I had thought about if we dropped an index and then did on-access pruning -- but we don't allow setting LP_DEAD items LP_UNUSED in on-access pruning. So, maybe this is safe... I can do a follow-on commit to add the assert. But I'm just not 100% sure I've thought of all the cases where we might end up with fewer dead items. > > During vacuum's first and third phases, we examine tuples' visibility > > to determine if we can set the page all-visible in the visibility map. > > > > Previously, this check compared tuple xmins against a single XID chosen at > > the start of vacuum (OldestXmin). We now use GlobalVisState, which also > > enables future work to set the VM during on-access pruning, since ordinary > > queries have access to GlobalVisState but not OldestXmin. > > > > This also benefits vacuum directly: GlobalVisState may advance > > during a vacuum, allowing more pages to become considered all-visible. > > In the rare case that it moves backward, VACUUM falls back to OldestXmin > > to ensure we don’t attempt to freeze a dead tuple that wasn’t yet > > prunable according to the GlobalVisState. > > It could, but it currently won't advance in vacuum, right? I thought it was possible for it to advance when calling heap_prune_satisfies_vacuum() -> GlobalVisTestIsRemovableXid()->...GlobalVisUpdate(). This case isn't going to be common, but some things can cause us to update it. We have talked about explicitly updating GlobalVisState more often during vacuums of large tables. But I was under the impression that it was at least possible for it to advance during vacuum now. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-10-29T11:03:14Z
On Wed, 15 Oct 2025 at 04:27, Melanie Plageman <melanieplageman@gmail.com> wrote: > > Thanks so much for the review! I've addressed all your feedback except > what is commented on inline below. > I've gone ahead and committed the preliminary patches that you thought > were ready to commit. > > Attached v18 is what remains. > > 0001 - 0003: refactoring > 0004 - 0006: finish eliminating XLOG_HEAP2_VISIBLE > 0007 - 0009: refactoring > 0010: Set VM on-access > 0011: Set prune xid on insert > 0012: Some refactoring for discussion > > For 0001, I got feedback heap_page_prune_and_freeze() has too many > arguments, so I tried to address that. I'm interested to know if folks > like this more. > > 0011 still needs a bit of investigation to understand fully if > anything else in the index-killtuples test needs to be changed to make > sure we have the same coverage. > > 0012 is sort of WIP. I got feedback heap_page_prune_and_freeze() was > too long and should be split up into helpers. I want to know if this > split makes sense. I can pull it down the patch stack if so. > > Only 0001 and 0012 are optional amongst the refactoring patches. The > others are required to make on-access VM-setting possible or viable. > > On Thu, Oct 9, 2025 at 2:18 PM Andres Freund <andres@anarazel.de> wrote: > > > > > @@ -71,12 +84,12 @@ heap_xlog_prune_freeze(XLogReaderState *record) > > > } > > > - action = XLogReadBufferForRedoExtended(record, 0, RBM_NORMAL, > > > - (xlrec.flags & XLHP_CLEANUP_LOCK) != 0, > > > - &buffer); > > > - if (action == BLK_NEEDS_REDO) > > > + if (XLogReadBufferForRedoExtended(record, 0, RBM_NORMAL, > > > + (xlrec.flags & XLHP_CLEANUP_LOCK) != 0, > > > + &buffer) == BLK_NEEDS_REDO) > > > { > > > Page page = BufferGetPage(buffer); > > > OffsetNumber *redirected; > > > > Why move it around this way? > > Because there will be an action for the visibility map > XLogReadBufferForRedoExtended(). I could have renamed it heap_action, > but it is being used only in one place, so I preferred to just cut it > to avoid any confusion. > > > > Advance the page LSN > > > + * only if the record could include an FPI, since recovery skips > > > + * records <= the stamped LSN. Otherwise it might skip an earlier FPI > > > + * needed to repair a torn page. > > > + */ > > > > This is confusing, should probably just reference the stuff we did in the > > !recovery case. > > I fixed this and addressed all your feedback related to this before committing. > > > > + if (do_prune || nplans > 0 || > > > + ((vmflags & VISIBILITYMAP_VALID_BITS) && XLogHintBitIsNeeded())) > > > + PageSetLSN(page, lsn); > > > + > > > /* > > > * Note: we don't worry about updating the page's prunability hints. > > > * At worst this will cause an extra prune cycle to occur soon. > > > */ > > > > Not your fault, but that seems odd? Why aren't we just doing the right thing? > > The comment dates back to 6f10eb2. I imagine no one ever bothered to > fuss with extracting the XID. You could change > heap_page_prune_execute() to return the right value -- though that's a > bit ugly since it is used in normal operation as well as recovery. > > > I wonder if the VM specific redo portion should be in a common helper? Might > > not be enough code to worry though... > > I think it might be more code as a helper at this point. > > > > @@ -2860,6 +2867,29 @@ lazy_vacuum_heap_page(LVRelState *vacrel, BlockNumber blkno, Buffer buffer, > > > VACUUM_ERRCB_PHASE_VACUUM_HEAP, blkno, > > > InvalidOffsetNumber); > > > > > > + /* > > > + * Before marking dead items unused, check whether the page will become > > > + * all-visible once that change is applied. > > > > So the function is named _would_ but here you say will :) > > I thought about it more and still feel that this function name should > contain "would". From vacuum's perspective it is "will" -- because it > knows it will remove those dead items, but from the function's > perspective it is hypothetical. I changed the comment though. > > > > + if (heap_page_would_be_all_visible(vacrel, buffer, > > > + deadoffsets, num_offsets, > > > + &all_frozen, &visibility_cutoff_xid)) > > > + { > > > + vmflags |= VISIBILITYMAP_ALL_VISIBLE; > > > + if (all_frozen) > > > + { > > > + vmflags |= VISIBILITYMAP_ALL_FROZEN; > > > + Assert(!TransactionIdIsValid(visibility_cutoff_xid)); > > > + } > > > + > > > + /* Take the lock on the vmbuffer before entering a critical section */ > > > + LockBuffer(vmbuffer, BUFFER_LOCK_EXCLUSIVE); > > > > It sure would be nice if we had documented the lock order between the heap > > page and the corresponding VM page anywhere. This is just doing what we did > > before, so it's not this patch's fault, but I did get worried about it for a > > moment. > > Well, the comment above the visibilitymap_set* functions says what > expectations they have for the heap page being locked. > > > > +static bool > > > +heap_page_would_be_all_visible(LVRelState *vacrel, Buffer buf, > > > + OffsetNumber *deadoffsets, > > > + int ndeadoffsets, > > > + bool *all_frozen, > > > + TransactionId *visibility_cutoff_xid) > > > +{ > > > Page page = BufferGetPage(buf); > > > Hm, what about an assert checking that matched_dead_count == ndeadoffsets at > > the end? > > I was going to put an Assert(ndeadoffsets <= matched_dead_count), but > then I started wondering if there is a way we could end up with fewer > dead items than we collected during phase I. > > I had thought about if we dropped an index and then did on-access > pruning -- but we don't allow setting LP_DEAD items LP_UNUSED in > on-access pruning. So, maybe this is safe... I can do a follow-on > commit to add the assert. But I'm just not 100% sure I've thought of > all the cases where we might end up with fewer dead items. > > > > During vacuum's first and third phases, we examine tuples' visibility > > > to determine if we can set the page all-visible in the visibility map. > > > > > > Previously, this check compared tuple xmins against a single XID chosen at > > > the start of vacuum (OldestXmin). We now use GlobalVisState, which also > > > enables future work to set the VM during on-access pruning, since ordinary > > > queries have access to GlobalVisState but not OldestXmin. > > > > > > This also benefits vacuum directly: GlobalVisState may advance > > > during a vacuum, allowing more pages to become considered all-visible. > > > In the rare case that it moves backward, VACUUM falls back to OldestXmin > > > to ensure we don’t attempt to freeze a dead tuple that wasn’t yet > > > prunable according to the GlobalVisState. > > > > It could, but it currently won't advance in vacuum, right? > > I thought it was possible for it to advance when calling > heap_prune_satisfies_vacuum() -> > GlobalVisTestIsRemovableXid()->...GlobalVisUpdate(). This case isn't > going to be common, but some things can cause us to update it. > > We have talked about explicitly updating GlobalVisState more often > during vacuums of large tables. But I was under the impression that it > was at least possible for it to advance during vacuum now. > > - Melanie Hi! First of all, I rechecked v18 patches, they still cause WAL bytes reduction. In a no-index vacuum case my result is a 39% reduction in WAL bytes. Almost like in your first message. Here are my comments about code, I may be very nitpicky in minor details, sorry for that In 0003: get_conflict_xid function logic is bit strange for me, it assigns conflict_xid to some value, but in the very end we have > + /* >+ * We can omit the snapshot conflict horizon if we are not pruning or >+ * freezing any tuples and are setting an already all-visible page >+ * all-frozen in the VM. In this case, all of the tuples on the page must >+ * already be visible to all MVCC snapshots on the standby. >+ */ >+ if (!do_prune && !do_freeze && >+ do_set_vm && blk_already_av && set_blk_all_frozen) > + conflict_xid = InvalidTransactionId; I feel like we should move this check to the beginning of the function, and just return InvalidTransactionId in that if cond. in 0004: > + if (old_vmbits == new_vmbits) > + { > + LockBuffer(vmbuffer, BUFFER_LOCK_UNLOCK); > + /* Unset so we don't emit WAL since no change occurred */ > + do_set_vm = false; > + } and then > END_CRIT_SECTION(); > + if (do_set_vm) > + LockBuffer(vmbuffer, BUFFER_LOCK_UNLOCK); > + So, in the heap_page_prune_and_freeze function we release buffer lock both inside and outside the crit section. As I understand, this is actually safe. I also looked in other xlog coding practices for other access methods (GiST, GIN, ....), and I can see that some of them release buffers before leaving crit sections and some of them after. But I still suggest to be in sync with 'Write-Ahead Log Coding' section of src/backend/access/transam/README, which says: 6. END_CRIT_SECTION() 7. Unlock and unpin the buffer(s). Let's be consistent in this at least in this single function context. In 0010: I'm not terribly convenient that adding SO_ALLOW_VM_SET to TAM ScanOptions is the right thing to do. Looks like VM bits are something that make sense for HEAP AM for not for any TAM. So, don't we break some layer of abstraction here? Would it be better for HEAP AM to set some flags in heap_beginscan? Overall 0001-0003 are mostly fine for me, 0004-0006 are the right thing to do IMHO, but maybe they need some more review from hackers. Other patches i did not review in a great detail, will return to this later -- Best regards, Kirill Reshke -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-11-04T16:48:15Z
Thanks for the review! On Wed, Oct 29, 2025 at 7:03 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > get_conflict_xid function logic is bit strange for me, it assigns > conflict_xid to some value, but in the very end we have > > > + /* > >+ * We can omit the snapshot conflict horizon if we are not pruning or > >+ * freezing any tuples and are setting an already all-visible page > >+ * all-frozen in the VM. In this case, all of the tuples on the page must > >+ * already be visible to all MVCC snapshots on the standby. > >+ */ > >+ if (!do_prune && !do_freeze && > >+ do_set_vm && blk_already_av && set_blk_all_frozen) > > + conflict_xid = InvalidTransactionId; > > I feel like we should move this check to the beginning of the > function, and just return InvalidTransactionId in that if cond. You're right. I've changed it as you suggest in attached v19. > > + if (old_vmbits == new_vmbits) > > + { > > + LockBuffer(vmbuffer, BUFFER_LOCK_UNLOCK); > > + /* Unset so we don't emit WAL since no change occurred */ > > + do_set_vm = false; > > + } > > and then > > > END_CRIT_SECTION(); > > + if (do_set_vm) > > + LockBuffer(vmbuffer, BUFFER_LOCK_UNLOCK); > > + > > So, in the heap_page_prune_and_freeze function we release buffer lock > both inside and outside the crit section. As I understand, this is > actually safe. I also looked in other xlog coding practices for other > access methods (GiST, GIN, ....), and I can see that some of them > release buffers before leaving crit sections and some of them after. > But I still suggest to be in sync with 'Write-Ahead Log Coding' > section of > src/backend/access/transam/README, which says: > > 6. END_CRIT_SECTION() > > 7. Unlock and unpin the buffer(s). > > Let's be consistent in this at least in this single function context. I see what you are saying. However, I don't see a good way to determine whether or not we need to unlock the VM without introducing another local variable in the outermost scope -- like "unlock_vm". This function already has a lot of local variables, so I'm loath to do that. And we want do_set_vm to reflect whether or not we actually set it in case it gets used in the future. This function doesn't lock or unlock the heap buffer so it doesn't seem as urgent to me to follow the letter of the law in this case. Attached patch doesn't have this change, but this is what it would look like: /* Lock vmbuffer before entering a critical section */ + unlock_vm = do_set_vm; if (do_set_vm) LockBuffer(vmbuffer, BUFFER_LOCK_EXCLUSIVE); @@ -1112,12 +1114,9 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, old_vmbits = visibilitymap_set(blockno, vmbuffer, new_vmbits, params->relation->rd_locator); - if (old_vmbits == new_vmbits) - { - LockBuffer(vmbuffer, BUFFER_LOCK_UNLOCK); - /* Unset so we don't emit WAL since no change occurred */ - do_set_vm = false; - } + + /* Unset so we don't emit WAL since no change occurred */ + do_set_vm = old_vmbits != new_vmbits; } /* @@ -1145,7 +1144,7 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, END_CRIT_SECTION(); - if (do_set_vm) + if (unlock_vm) LockBuffer(vmbuffer, BUFFER_LOCK_UNLOCK); > In 0010: > > I'm not terribly convenient that adding SO_ALLOW_VM_SET to TAM > ScanOptions is the right thing to do. Looks like VM bits are something > that make sense for HEAP AM for not for any TAM. So, don't we break > some layer of abstraction here? Would it be better for HEAP AM to set > some flags in heap_beginscan? I don't see another good way of doing it. The information about whether or not the relation is modified in the query is gathered during planning and saved in the plan. We need to get that information to the scan descriptor, which is all we have when we call heap_page_prune_opt() during the scan. The scan descriptor is created by the table AM implementations of scan_begin(). The table AM callbacks don't pass down the plan -- which makes sense; the scan shouldn't know about the plan. They do pass down flags, so I thought it made the most sense to add a flag. Note that I was able to avoid modifying the actual table and index AM callbacks (scan_begin() and ambeginscan()). I only made new wrappers that took "modifies_rel". Now, it is true that referring to the VM is somewhat of a layering violation. Though, other table AMs may use the information about if the query modifies the relation -- which is really what this flag represents. The ScanOptions are usually either a type or a call to action. Which is why I felt a bit uncomfortable calling it something like SO_MODIFIES_REL -- which is less of an option and more a piece of information. And it makes it sound like the scan modifies the rel, which is not the case. I wonder if there is another solution. Or maybe we call it SO_QUERY_MODIFIES_REL? - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-11-17T23:07:27Z
Attached v20 has general cleanup, changes to the table/index AM callbacks detailed below, and it moves the heap_page_prune_and_freeze() refactoring commit down the stack to 0004. 0001 - 0003 are fairly trivial cleanup patches. I think they are ready to commit, so if I don't hear any objections in the next few days, I'll go ahead and commit them. On Tue, Nov 4, 2025 at 11:48 AM Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Wed, Oct 29, 2025 at 7:03 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > > > In 0010: > > > > I'm not terribly convenient that adding SO_ALLOW_VM_SET to TAM > > ScanOptions is the right thing to do. Looks like VM bits are something > > that make sense for HEAP AM for not for any TAM. So, don't we break > > some layer of abstraction here? Would it be better for HEAP AM to set > > some flags in heap_beginscan? > > I don't see another good way of doing it. > > The information about whether or not the relation is modified in the > query is gathered during planning and saved in the plan. We need to > get that information to the scan descriptor, which is all we have when > we call heap_page_prune_opt() during the scan. The scan descriptor is > created by the table AM implementations of scan_begin(). The table AM > callbacks don't pass down the plan -- which makes sense; the scan > shouldn't know about the plan. They do pass down flags, so I thought > it made the most sense to add a flag. Note that I was able to avoid > modifying the actual table and index AM callbacks (scan_begin() and > ambeginscan()). I only made new wrappers that took "modifies_rel". > > Now, it is true that referring to the VM is somewhat of a layering > violation. Though, other table AMs may use the information about if > the query modifies the relation -- which is really what this flag > represents. The ScanOptions are usually either a type or a call to > action. Which is why I felt a bit uncomfortable calling it something > like SO_MODIFIES_REL -- which is less of an option and more a piece of > information. And it makes it sound like the scan modifies the rel, > which is not the case. I wonder if there is another solution. Or maybe > we call it SO_QUERY_MODIFIES_REL? Attached v20 changes the ScanOption name to SO_HINT_REL_READ_ONLY and removes the new helper functions which took modifies_rel as a parameter. Instead it modifies the existing table_beginscan()/index_beginscan() helpers and the relevant callbacks they invoke to have a new flags parameter. These are additional caller provider flags. In master, the IndexScan structures and helpers don't use ScanOptions, but since I'm using them for properties of the base relation, I think it is fine. I'm not sure if I should name the parameter base_rel_flags instead of flags for the index-related callbacks and helpers or if leaving it more generic is better, though. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-11-19T09:35:39Z
On Tue, 18 Nov 2025 at 04:07, Melanie Plageman <melanieplageman@gmail.com> wrote: > > Attached v20 has general cleanup, changes to the table/index AM > callbacks detailed below, and it moves the > heap_page_prune_and_freeze() refactoring commit down the stack to > 0004. > > 0001 - 0003 are fairly trivial cleanup patches. I think they are ready > to commit, so if I don't hear any objections in the next few days, > I'll go ahead and commit them. > Hi! I looked up these 0002-0003 patches once again, LGTM. In particular, I think 0002 & 0003 makes VM bits management more simple. My only review comment is about 0003: Should we make frz_conflict_horizon not a heap_page_will_freeze's argument but rather just another field of PruneState struct? If i'm not mistaken, 'frz_conflict_horizon' fits good to be a part of pruning state -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-11-19T23:13:30Z
On Wed, Nov 19, 2025 at 4:35 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > Hi! I looked up these 0002-0003 patches once again, LGTM. In > particular, I think 0002 & 0003 makes VM bits management more simple. Thanks for the review! > My only review comment is about 0003: > Should we make frz_conflict_horizon not a heap_page_will_freeze's > argument but rather just another field of PruneState struct? If i'm > not mistaken, 'frz_conflict_horizon' fits good to be a part of pruning > state Since it is passed into one of the helpers, I think I agree. Attached v21 has this change. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-11-20T17:19:58Z
On Wed, Nov 19, 2025 at 6:13 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > Since it is passed into one of the helpers, I think I agree. Attached > v21 has this change. I've committed the first three patches. Attached v22 is the remaining patches which set the VM in heap_page_prune_and_freeze() for vacuum and then allow on-access pruning to also set the VM. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Dagfinn Ilmari Mannsåker <ilmari@ilmari.org> — 2025-11-20T17:55:05Z
Melanie Plageman <melanieplageman@gmail.com> writes: > + PruneFreezeParams params = {.relation = relation,.buffer = buffer, > + .reason = PRUNE_ON_ACCESS,.options = 0, > + .vistest = vistest,.cutoffs = NULL > + }; I didn't pay much attention to this thread, so I didn't notice this until it got committed, but I'd like to lodge an objection to this formatting, especially the lack of spaces before the field names. This would be much more readable with one struct field per line, i.e. PruneFreezeParams params = { .relation = rel, .buffer = buf, .reason = PRUNE_VACUUM_SCAN, .options = HEAP_PAGE_PRUNE_FREEZE, .vistest = vacrel->vistest, .cutoffs = &vacrel->cutoffs, }; or at a pinch, if we're really being stingy with the vertical space: PruneFreezeParams params = { .relation = rel, .buffer = buf, .reason = PRUNE_VACUUM_SCAN, .options = HEAP_PAGE_PRUNE_FREEZE, .vistest = vacrel->vistest, .cutoffs = &vacrel->cutoffs, }; I had a quick grep, and every other designated struct initialiser I could find uses the one-field-per-line form, but they're not consistent about the comma after the last field. I personally prefer having it, so that one can add more fields later without having to modify the unrelated line. - ilmari -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Dagfinn Ilmari Mannsåker <ilmari@ilmari.org> — 2025-11-20T18:02:20Z
Dagfinn Ilmari Mannsåker <ilmari@ilmari.org> writes: > Melanie Plageman <melanieplageman@gmail.com> writes: > >> + PruneFreezeParams params = {.relation = relation,.buffer = buffer, >> + .reason = PRUNE_ON_ACCESS,.options = 0, >> + .vistest = vistest,.cutoffs = NULL >> + }; > > I didn't pay much attention to this thread, so I didn't notice this > until it got committed, but I'd like to lodge an objection to this > formatting, especially the lack of spaces before the field names. This > would be much more readable with one struct field per line, i.e. > > PruneFreezeParams params = { > .relation = rel, > .buffer = buf, > .reason = PRUNE_VACUUM_SCAN, > .options = HEAP_PAGE_PRUNE_FREEZE, > .vistest = vacrel->vistest, > .cutoffs = &vacrel->cutoffs, > }; D'oh, my mail client untabified the .buffer line while I was editing it, that should of course be: PruneFreezeParams params = { .relation = rel, .buffer = buf, .reason = PRUNE_VACUUM_SCAN, .options = HEAP_PAGE_PRUNE_FREEZE, .vistest = vacrel->vistest, .cutoffs = &vacrel->cutoffs, }; - ilmari -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-11-20T22:23:05Z
On Thu, Nov 20, 2025 at 12:55 PM Dagfinn Ilmari Mannsåker <ilmari@ilmari.org> wrote: > > I didn't pay much attention to this thread, so I didn't notice this > until it got committed, but I'd like to lodge an objection to this > formatting, especially the lack of spaces before the field names. This > would be much more readable with one struct field per line, i.e. > > PruneFreezeParams params = { > .relation = rel, > .buffer = buf, > .reason = PRUNE_VACUUM_SCAN, > .options = HEAP_PAGE_PRUNE_FREEZE, > .vistest = vacrel->vistest, > .cutoffs = &vacrel->cutoffs, > }; > > or at a pinch, if we're really being stingy with the vertical space: > > PruneFreezeParams params = { > .relation = rel, .buffer = buf, > .reason = PRUNE_VACUUM_SCAN, .options = HEAP_PAGE_PRUNE_FREEZE, > .vistest = vacrel->vistest, .cutoffs = &vacrel->cutoffs, > }; > > I had a quick grep, and every other designated struct initialiser I > could find uses the one-field-per-line form, but they're not consistent > about the comma after the last field. I personally prefer having it, so > that one can add more fields later without having to modify the > unrelated line. pgindent doesn't allow for a space after the comma before the period. One reason I used struct initialization was to save space, so I'm a bit loath to put every member on its own line. However, I don't want to make the code less readable to others. So, I will commit an update as you request. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2025-11-21T01:09:21Z
> On Nov 21, 2025, at 01:19, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Wed, Nov 19, 2025 at 6:13 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: >> >> Since it is passed into one of the helpers, I think I agree. Attached >> v21 has this change. > > I've committed the first three patches. Attached v22 is the remaining > patches which set the VM in heap_page_prune_and_freeze() for vacuum > and then allow on-access pruning to also set the VM. > I just started reviewing 0001 yesterday and got a few comments. However, it was late, I didn’t have enough time to wrap up, so I decided to review a few more today and send the comments together. As you have pushed 0001-0003, I’d still raise my comment for them now, and I will review the rest of commits next week. 1 - pushed 0001 ``` /* * Report the number of tuples reclaimed to pgstats. This is @@ -419,60 +425,44 @@ heap_page_will_freeze(Relation relation, Buffer buffer, * also need to account for a reduction in the length of the line pointer * array following array truncation by us. * - * If the HEAP_PRUNE_FREEZE option is set, we will also freeze tuples if it's - * required in order to advance relfrozenxid / relminmxid, or if it's - * considered advantageous for overall system performance to do so now. The - * 'cutoffs', 'presult', 'new_relfrozen_xid' and 'new_relmin_mxid' arguments - * are required when freezing. When HEAP_PRUNE_FREEZE option is set, we also - * set presult->all_visible and presult->all_frozen on exit, to indicate if - * the VM bits can be set. They are always set to false when the - * HEAP_PRUNE_FREEZE option is not set, because at the moment only callers - * that also freeze need that information. - * - * vistest is used to distinguish whether tuples are DEAD or RECENTLY_DEAD - * (see heap_prune_satisfies_vacuum). - * - * options: - * MARK_UNUSED_NOW indicates that dead items can be set LP_UNUSED during - * pruning. + * params contains the input parameters used to control freezing and pruning + * behavior. See the definition of PruneFreezeParams for more on what each + * parameter does. * - * FREEZE indicates that we will also freeze tuples, and will return - * 'all_visible', 'all_frozen' flags to the caller. - * - * cutoffs contains the freeze cutoffs, established by VACUUM at the beginning - * of vacuuming the relation. Required if HEAP_PRUNE_FREEZE option is set. - * cutoffs->OldestXmin is also used to determine if dead tuples are - * HEAPTUPLE_RECENTLY_DEAD or HEAPTUPLE_DEAD. + * If the HEAP_PAGE_PRUNE_FREEZE option is set in params, we will freeze + * tuples if it's required in order to advance relfrozenxid / relminmxid, or + * if it's considered advantageous for overall system performance to do so + * now. The 'params.cutoffs', 'presult', 'new_relfrozen_xid' and + * 'new_relmin_mxid' arguments are required when freezing. When + * HEAP_PAGE_PRUNE_FREEZE option is passed, we also set presult->all_visible + * and presult->all_frozen on exit, to indicate if the VM bits can be set. + * They are always set to false when the HEAP_PAGE_PRUNE_FREEZE option is not + * passed, because at the moment only callers that also freeze need that + * information. * * presult contains output parameters needed by callers, such as the number of * tuples removed and the offsets of dead items on the page after pruning. * heap_page_prune_and_freeze() is responsible for initializing it. Required * by all callers. * - * reason indicates why the pruning is performed. It is included in the WAL - * record for debugging and analysis purposes, but otherwise has no effect. - * * off_loc is the offset location required by the caller to use in error * callback. * * new_relfrozen_xid and new_relmin_mxid must provided by the caller if the - * HEAP_PRUNE_FREEZE option is set. On entry, they contain the oldest XID and - * multi-XID seen on the relation so far. They will be updated with oldest - * values present on the page after pruning. After processing the whole - * relation, VACUUM can use these values as the new relfrozenxid/relminmxid - * for the relation. + * HEAP_PAGE_PRUNE_FREEZE option is set in params. On entry, they contain the + * oldest XID and multi-XID seen on the relation so far. They will be updated + * with oldest values present on the page after pruning. After processing the + * whole relation, VACUUM can use these values as the new + * relfrozenxid/relminmxid for the relation. */ void -heap_page_prune_and_freeze(Relation relation, Buffer buffer, - GlobalVisState *vistest, - int options, - struct VacuumCutoffs *cutoffs, +heap_page_prune_and_freeze(PruneFreezeParams *params, PruneFreezeResult *presult, - PruneReason reason, OffsetNumber *off_loc, TransactionId *new_relfrozen_xid, MultiXactId *new_relmin_mxid) { ``` For this function interface change, I got a concern. The old function comment says "cutoffs contains the freeze cutoffs …. Required if HEAP_PRUNE_FREEZE option is set.”, meaning that cutoffs is only useful and must be set when HEAP_PRUNE_FREEZE is set. But the new comment seems to have lost this indication. And in the old function interface, cutoffs sat right next to options, readers are easy to notice: * when options is 0, cutoffs is null ``` heap_page_prune_and_freeze(relation, buffer, vistest, 0, NULL, &presult, PRUNE_ON_ACCESS, &dummy_off_loc, NULL, NULL); ``` * when options has HEAP_PAGE_PRUNE_FREEZE, cutoffs is passed in ``` prune_options = HEAP_PAGE_PRUNE_FREEZE; if (vacrel->nindexes == 0) prune_options |= HEAP_PAGE_PRUNE_MARK_UNUSED_NOW; heap_page_prune_and_freeze(rel, buf, vacrel->vistest, prune_options, &vacrel->cutoffs, &presult, PRUNE_VACUUM_SCAN, &vacrel->offnum, &vacrel->NewRelfrozenXid, &vacrel->NewRelminMxid); ``` So, the change doesn’t break anything, but makes code a little bit harder to read. So, my suggestion is to add an assert in heap_page_prune_and_freeze, something like: ``` Assert(!(params->options & HEAP_PAGE_PRUNE_FREEZE) || params->cutoffs != NULL); ``` 2 - pushed 0001 ``` + PruneFreezeParams params = {.relation = rel,.buffer = buf, + .reason = PRUNE_VACUUM_SCAN,.options = HEAP_PAGE_PRUNE_FREEZE, + .cutoffs = &vacrel->cutoffs,.vistest = vacrel->vistest + }; ``` Using a designated initializer is not wrong, but makes future maintenance harder, because when a new field is added, this initializer will leave the new field uninitiated. From my impression, I don’t remember I ever see a designated initializer in PG code. I only remember 3 ways I have seen: * use an initialize function to set every fields individually * palloc0 to set all 0, then set non-zero fields individually * {0} to set all 0, then set non-zero fields individually 3 - pushed 0002 ``` prstate->all_visible = false; + prstate->all_frozen = false; ``` Nit: Now setting the both fields to false repeat in 6 places. Maybe add a static inline function, say PruneClearVisibilityFlags(), may improve maintainability. 4 - pushed 0003 ``` + * opporunistically freeze, to indicate if the VM bits can be set. They are ``` Typo: opporunistically, missed a “t”. I’d stop here today, and continue reviewing rest commits in next week. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2025-11-24T08:07:59Z
> On Nov 21, 2025, at 09:09, Chao Li <li.evan.chao@gmail.com> wrote: > > I’d stop here today, and continue reviewing rest commits in next week. I continue reviewing today. 0004 This a pure refactoring. It splits heap_page_prune_and_freeze to multiple small functions. LGTM, no comment. 0005 overall good, a few nit comments as below. 0006, 0007 look good, no comment. 5 - 0005 - heapam.h ``` + /* + * + * vmbuffer is the buffer that must already contain contain the required + * block of the visibility map if we are to update it. blk_known_av is the ``` Nit: * an unnecessary empty comment line. * “contain contain” => “contain" 6 - 0005 heapam_xlog.c ``` + * The critical integrity requirement here is that we must never end + * up with with the visibility map bit set and the page-level ``` Nit: “with with” => “with” I will continue reviewing 0008 and rest tomorrow. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2025-11-24T09:31:31Z
> On Nov 24, 2025, at 16:07, Chao Li <li.evan.chao@gmail.com> wrote: > > 0006, 0007 look good, no comment. I missed a nit comment in 0007: 7 - 0007 ``` + * To handle recovery conflict during logical decoding on standby, we must know + * if the table is a catalog table. Note that in visibilitymapdefs.h + * VISIBLITYMAP_XLOG_CATALOG_REL is also defined as (1 << 2). xl_heap_prune + * records should use XLHP_IS_CATALOG_REL, not VISIBILIYTMAP_XLOG_CATALOG_REL -- + * even if they only contain updates to the VM. ``` VISIBLITYMAP_XLOG_CATALOG_REL missed “I” after “B”. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2025-11-24T22:24:46Z
Hi, On 2025-11-20 12:19:58 -0500, Melanie Plageman wrote: > From 363f0e4ac9ac7699a6d9c2a267a2ad60825407c8 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Mon, 17 Nov 2025 15:11:27 -0500 > Subject: [PATCH v22 1/9] Split heap_page_prune_and_freeze() into helpers > > Refactor the setup and planning phases of pruning and freezing into > helpers. This streamlines heap_page_prune_and_freeze() and makes it more > clear when the examination of tuples ends and page modifications begin. I think this is a considerable improvement. I didn't review this with a lot of detail, given that it's mostly moving code. One minor thing: It's slightly odd that prune_freeze_plan() gets an oid argument, prune_freeze_setup() gets the entire prstate, heap_page_will_freeze() gets the Relation. It's what they need, but still a bit odd. FWIW, I found the diff generated by git show --diff-algorithm=minimal --color-moved-ws=allow-indentation-change useful for viewing this diff, showed much more clearly how little the code actually changed. > From 8ebaf434af5afaebcf71550116c59355b3bf15c1 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 8 Oct 2025 15:39:01 -0400 > Subject: [PATCH v22 2/9] Eliminate XLOG_HEAP2_VISIBLE from vacuum phase I > prune/freeze > > Vacuum no longer emits a separate WAL record for each page set > all-visible or all-frozen during phase I. Instead, visibility map > updates are now included in the XLOG_HEAP2_PRUNE_VACUUM_SCAN record that > is already emitted for pruning and freezing. > > Previously, heap_page_prune_and_freeze() determined whether a page was > all-visible, but the corresponding VM bits were only set later in > lazy_scan_prune(). Now the VM is updated immediately in > heap_page_prune_and_freeze(), at the same time as the heap > modifications. > > This change applies only to vacuum phase I, not to pruning performed > during normal page access. Hm. This change makes sense, but unfortunately I find it somewhat hard to review. There are a lot of changes that don't obviously work towards one goal in this commit. >@@ -238,6 +239,16 @@ typedef struct PruneFreezeParams > Relation relation; /* relation containing buffer to be pruned */ > Buffer buffer; /* buffer to be pruned */ > >+ /* >+ * >+ * vmbuffer is the buffer that must already contain contain the required >+ * block of the visibility map if we are to update it. blk_known_av is the >+ * visibility status of the heap block as of the last call to >+ * find_next_unskippable_block(). >+ */ >+ Buffer vmbuffer; >+ bool blk_known_av; >+ > /* > * The reason pruning was performed. It is used to set the WAL record > * opcode which is used for debugging and analysis purposes. What is blk_known_av set to if the block is known to not be all visible? Compared to the case where we did not yet determine the visibility status of the block? >@@ -250,8 +261,10 @@ typedef struct PruneFreezeParams > * HEAP_PAGE_PRUNE_MARK_UNUSED_NOW indicates that dead items can be set > * LP_UNUSED during pruning. > * >- * HEAP_PAGE_PRUNE_FREEZE indicates that we will also freeze tuples, and >- * will return 'all_visible', 'all_frozen' flags to the caller. >+ * HEAP_PAGE_PRUNE_FREEZE indicates that we will also freeze tuples >+ * >+ * HEAP_PAGE_PRUNE_UPDATE_VIS indicates that we will set the page's status >+ * in the VM. > */ > int options; nit^2: The previous version and the other paragraphs end in a . > @@ -157,17 +159,36 @@ heap_xlog_prune_freeze(XLogReaderState *record) > /* There should be no more data */ > Assert((char *) frz_offsets == dataptr + datalen); > > - if (vmflags & VISIBILITYMAP_VALID_BITS) > - PageSetAllVisible(page); > - > - MarkBufferDirty(buffer); > + if (do_prune || nplans > 0) > + mark_buffer_dirty = set_lsn = true; > > /* > - * See log_heap_prune_and_freeze() for commentary on when we set the > - * heap page LSN. > + * The critical integrity requirement here is that we must never end > + * up with with the visibility map bit set and the page-level > + * PD_ALL_VISIBLE bit clear. If that were to occur, a subsequent page s/clear/unset/ would be a tad clearer. > + * modification would fail to clear the visibility map bit. > + * > + * vmflags may be nonzero with PD_ALL_VISIBLE already set (e.g. when > + * marking an all-visible page all-frozen). If only the VM is updated, > + * the heap page need not be dirtied. > */ > - if (do_prune || nplans > 0 || > - ((vmflags & VISIBILITYMAP_VALID_BITS) && XLogHintBitIsNeeded())) > + if ((vmflags & VISIBILITYMAP_VALID_BITS) && !PageIsAllVisible(page)) > + { > + PageSetAllVisible(page); > + mark_buffer_dirty = true; > + > + /* > + * See log_heap_prune_and_freeze() for commentary on when we set > + * the heap page LSN. > + */ > + if (XLogHintBitIsNeeded()) > + set_lsn = true; > + } Maybe worth adding something like Assert(!set_lsn || mark_buffer_dirty)? > +/* > + * Decide whether to set the visibility map bits for heap_blk, using > + * information from PruneState and blk_known_av. Some callers may already > + * have examined this page’s VM bits (e.g., VACUUM in the previous > + * heap_vac_scan_next_block() call) and can pass that along. That's not entirely trivial to follow, tbh. As mentioned above, it's not clear to me how the state of a block where did determine that the block is *not* all-visible is represented. > + * Returns true if one or both VM bits should be set, along with the desired > + * flags in *vmflags. Also indicates via do_set_pd_vis whether PD_ALL_VISIBLE > + * should be set on the heap page. > + */ > +static bool > +heap_page_will_set_vis(Relation relation, > + BlockNumber heap_blk, > + Buffer heap_buf, > + Buffer vmbuffer, > + bool blk_known_av, > + const PruneState *prstate, > + uint8 *vmflags, > + bool *do_set_pd_vis) > +{ > + Page heap_page = BufferGetPage(heap_buf); > + bool do_set_vm = false; > + > + *do_set_pd_vis = false; > + > + > + /* > + * Now handle two potential corruption cases: > + * > + * These do not need to happen in a critical section and are not > + * WAL-logged. > + * > + * As of PostgreSQL 9.2, the visibility map bit should never be set if the > + * page-level bit is clear. However, it's possible that in vacuum the bit > + * got cleared after heap_vac_scan_next_block() was called, so we must > + * recheck with buffer lock before concluding that the VM is corrupt. > + */ > + else if (blk_known_av && !PageIsAllVisible(heap_page) && > + visibilitymap_get_status(relation, heap_blk, &vmbuffer) != 0) > + { > + ereport(WARNING, > + (errcode(ERRCODE_DATA_CORRUPTED), > + errmsg("page is not marked all-visible but visibility map bit is set in relation \"%s\" page %u", > + RelationGetRelationName(relation), heap_blk))); > + > + visibilitymap_clear(relation, heap_blk, vmbuffer, > + VISIBILITYMAP_VALID_BITS); Wait, why is it ok to perform this check iff blk_known_av is set? > + old_vmbits = visibilitymap_set_vmbits(blockno, > + vmbuffer, new_vmbits, > + params->relation->rd_locator); > + if (old_vmbits == new_vmbits) > + { > + LockBuffer(vmbuffer, BUFFER_LOCK_UNLOCK); > + /* Unset so we don't emit WAL since no change occurred */ > + do_set_vm = false; > + } > + } What can lead to this path being reached? Doesn't this mean that something changed the state of the VM while we were holding an exclusive lock on the heap buffer? > + /* > + * Emit a WAL XLOG_HEAP2_PRUNE* record showing what we did. If we were > + * only updating the VM and it turns out it was already set, we will > + * have unset do_set_vm earlier. As such, check it again before > + * emitting the record. > + */ > + if (RelationNeedsWAL(params->relation) && > + (do_prune || do_freeze || do_set_vm)) > + { > log_heap_prune_and_freeze(params->relation, buffer, > - InvalidBuffer, /* vmbuffer */ > - 0, /* vmflags */ > + do_set_vm ? vmbuffer : InvalidBuffer, > + do_set_vm ? new_vmbits : 0, > conflict_xid, > - true, params->reason, > + true, /* cleanup lock */ > + do_set_pd_vis, > + params->reason, > prstate.frozen, prstate.nfrozen, > prstate.redirected, prstate.nredirected, > prstate.nowdead, prstate.ndead, This function is now taking 16 parameters :/ > @@ -959,28 +1148,47 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > > END_CRIT_SECTION(); > > + if (do_set_vm) > + LockBuffer(vmbuffer, BUFFER_LOCK_UNLOCK); > + > + /* > + * During its second pass over the heap, VACUUM calls > + * heap_page_would_be_all_visible() to determine whether a page is > + * all-visible and all-frozen. The logic here is similar. After completing > + * pruning and freezing, use an assertion to verify that our results > + * remain consistent with heap_page_would_be_all_visible(). > + */ > +#ifdef USE_ASSERT_CHECKING > + if (prstate.all_visible) > + { > + TransactionId debug_cutoff; > + bool debug_all_frozen; > + > + Assert(prstate.lpdead_items == 0); > + Assert(prstate.cutoffs); > + > + if (!heap_page_is_all_visible(params->relation, buffer, > + prstate.cutoffs->OldestXmin, > + &debug_all_frozen, > + &debug_cutoff, off_loc)) > + Assert(false); I don't love Assert(false), because the message for the assert failure is pretty much meaningless. Sometimes it's hard to avoid, but here you have an if () that has no body other than Assert(false)? Just Assert the expression directly. > From 34f0009570e117d7d48b560cd097ee25c6cdcc7c Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Sat, 27 Sep 2025 11:55:21 -0400 > Subject: [PATCH v22 3/9] Eliminate XLOG_HEAP2_VISIBLE from empty-page vacuum > > As part of removing XLOG_HEAP2_VISIBLE records, phase I of VACUUM now > marks empty pages all-visible in a XLOG_HEAP2_PRUNE_VACUUM_SCAN record. This whole business of treating empty pages as all-visible continues to not make any sense to me. Particularly in combination with a not crashsafe FSM it just seems ... unhelpful. It also means that there there's a decent chance of extra WAL when bulk extending. But that's not the fault of this change. > From 0d6a06d4533cfe153440d301c3d20915ba07892f Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Sat, 27 Sep 2025 11:55:36 -0400 > Subject: [PATCH v22 4/9] Remove XLOG_HEAP2_VISIBLE entirely > > As no remaining users emit XLOG_HEAP2_VISIBLE records. > This includes deleting the xl_heap_visible struct and all functions > responsible for emitting or replaying XLOG_HEAP2_VISIBLE records. Probably worth mentioning that this changes the VM API. > @@ -2396,14 +2396,18 @@ get_conflict_xid(bool do_prune, bool do_freeze, bool do_set_vm, > * > * This is used for several different page maintenance operations: > * > - * - Page pruning, in VACUUM's 1st pass or on access: Some items are > + * - Page pruning, in vacuum phase I or on-access: Some items are > * redirected, some marked dead, and some removed altogether. > * > - * - Freezing: Items are marked as 'frozen'. > + * - Freezing: During vacuum phase I, items are marked as 'frozen' > * > - * - Vacuum, 2nd pass: Items that are already LP_DEAD are marked as unused. > + * - Reaping: During vacuum phase III, items that are already LP_DEAD are > + * marked as unused. > * > - * They have enough commonalities that we use a single WAL record for them > + * - VM updates: After vacuum phases I and III, the heap page may be marked > + * all-visible and all-frozen. > + * > + * These changes all happen together, so we use a single WAL record for them > * all. > * > * If replaying the record requires a cleanup lock, pass cleanup_lock = > true. How's that related to the commit's subject? > From fd0455230968fd919999a5c035f3830d310f0e49 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Fri, 18 Jul 2025 16:30:04 -0400 > Subject: [PATCH v22 5/9] Rename GlobalVisTestIsRemovableXid() to > GlobalVisXidVisibleToAll() > MIME-Version: 1.0 > Content-Type: text/plain; charset=UTF-8 > Content-Transfer-Encoding: 8bit > > The function is currently only used to check whether a tuple’s xmax is > visible to all transactions (and thus removable). Upcoming changes will > also use it to test whether a tuple’s xmin is visible to all to > decide if a page can be marked all-visible in the visibility map. > > The new name, GlobalVisXidVisibleToAll(), better reflects this broader > purpose. If we want this - and I'm not convinced we do - I think it needs to go further and change the other uses of removable in procarray.c. ComputeXidHorizonsResult has a lot of related fields. There's also GetOldestNonRemovableTransactionId(), GlobalVisCheckRemovableXid(), GlobalVisCheckRemovableFullXid() that weren't included in the renaming. > From 565014e31aa117fb43993ee2e64da38ffb74f372 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 29 Jul 2025 14:38:24 -0400 > Subject: [PATCH v22 6/9] Use GlobalVisState in vacuum to determine page level > visibility > MIME-Version: 1.0 > Content-Type: text/plain; charset=UTF-8 > Content-Transfer-Encoding: 8bit > > During vacuum's first and third phases, we examine tuples' visibility > to determine if we can set the page all-visible in the visibility map. > > Previously, this check compared tuple xmins against a single XID chosen at > the start of vacuum (OldestXmin). We now use GlobalVisState, which also > enables future work to set the VM during on-access pruning, since ordinary > queries have access to GlobalVisState but not OldestXmin. > > This also benefits vacuum directly: in some cases, GlobalVisState may > advance during a vacuum, allowing more pages to become considered > all-visible. And, in the future, we could easily add a heuristic to > update GlobalVisState more frequently during vacuums of large tables. In > the rare case that the GlobalVisState moves backward, vacuum falls back > to OldestXmin to ensure we don’t attempt to freeze a dead tuple that > wasn’t yet prunable according to the GlobalVisState. I think it may be better to make sure that the GlobalVisState can't move backward. > From bced81f6df3d303679fac2a1414d42f0db401232 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 29 Jul 2025 14:34:30 -0400 > Subject: [PATCH v22 8/9] Allow on-access pruning to set pages all-visible > > Many queries do not modify the underlying relation. For such queries, if > on-access pruning occurs during the scan, we can check whether the page > has become all-visible and update the visibility map accordingly. > Previously, only vacuum and COPY FREEZE marked pages as all-visible or > all-frozen. > Supporting this requires passing information about whether the relation > is modified from the executor down to the scan descriptor. I think it'd be good to split this part into a separate commit. The set of folks to review that are distinct (and broader) from the ones looking at heapam internals. Greetings, Andres Freund -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-11-25T21:43:58Z
Thanks for the review! On Thu, Nov 20, 2025 at 8:10 PM Chao Li <li.evan.chao@gmail.com> wrote: > > * new_relfrozen_xid and new_relmin_mxid must provided by the caller if the > - * HEAP_PRUNE_FREEZE option is set. On entry, they contain the oldest XID and > - * multi-XID seen on the relation so far. They will be updated with oldest > - * values present on the page after pruning. After processing the whole > - * relation, VACUUM can use these values as the new relfrozenxid/relminmxid > - * for the relation. > + * HEAP_PAGE_PRUNE_FREEZE option is set in params. On entry, they contain the > + * oldest XID and multi-XID seen on the relation so far. They will be updated > + * with oldest values present on the page after pruning. After processing the > + * whole relation, VACUUM can use these values as the new > + * relfrozenxid/relminmxid for the relation. > */ > void > -heap_page_prune_and_freeze(Relation relation, Buffer buffer, > - GlobalVisState *vistest, > - int options, > - struct VacuumCutoffs *cutoffs, > +heap_page_prune_and_freeze(PruneFreezeParams *params, > PruneFreezeResult *presult, > - PruneReason reason, > OffsetNumber *off_loc, > TransactionId *new_relfrozen_xid, > MultiXactId *new_relmin_mxid) > { > ``` > > For this function interface change, I got a concern. The old function comment says "cutoffs contains the freeze cutoffs …. Required if HEAP_PRUNE_FREEZE option is set.”, meaning that cutoffs is only useful and must be set when HEAP_PRUNE_FREEZE is set. But the new comment seems to have lost this indication. I did move that comment into the PruneFreezeParams struct definition. > And in the old function interface, cutoffs sat right next to options, readers are easy to notice: > > * when options is 0, cutoffs is null > ``` > heap_page_prune_and_freeze(relation, buffer, vistest, 0, > NULL, &presult, PRUNE_ON_ACCESS, &dummy_off_loc, NULL, NULL); > ``` > > * when options has HEAP_PAGE_PRUNE_FREEZE, cutoffs is passed in > ``` > prune_options = HEAP_PAGE_PRUNE_FREEZE; > if (vacrel->nindexes == 0) > prune_options |= HEAP_PAGE_PRUNE_MARK_UNUSED_NOW; > > heap_page_prune_and_freeze(rel, buf, vacrel->vistest, prune_options, > &vacrel->cutoffs, &presult, PRUNE_VACUUM_SCAN, > &vacrel->offnum, > &vacrel->NewRelfrozenXid, &vacrel->NewRelminMxid); > ``` > > So, the change doesn’t break anything, but makes code a little bit harder to read. So, my suggestion is to add an assert in heap_page_prune_and_freeze, something like: > > Assert(!(params->options & HEAP_PAGE_PRUNE_FREEZE) || params->cutoffs != NULL); That's fair. I've gone ahead and pushed a commit with your suggested assert. > 2 - pushed 0001 > ``` > + PruneFreezeParams params = {.relation = rel,.buffer = buf, > + .reason = PRUNE_VACUUM_SCAN,.options = HEAP_PAGE_PRUNE_FREEZE, > + .cutoffs = &vacrel->cutoffs,.vistest = vacrel->vistest > + }; > ``` > > Using a designated initializer is not wrong, but makes future maintenance harder, because when a new field is added, this initializer will leave the new field uninitiated. From my impression, I don’t remember I ever see a designated initializer in PG code. I only remember 3 ways I have seen: > > * use an initialize function to set every fields individually > * palloc0 to set all 0, then set non-zero fields individually > * {0} to set all 0, then set non-zero fields individually Well, the main reason you don't see them much in the code is that a lot of the code is old and we didn't require a c99-compliant compiler until fairly recently (okay like 2018/2019) -- and thus couldn't use designated initializers. I agree that they are rare for structs (they are quite commonly used with arrays), but they are there -- for example these bufmgr init macros #define BMR_REL(p_rel) \ ((BufferManagerRelation){.rel = p_rel}) #define BMR_SMGR(p_smgr, p_relpersistence) \ ((BufferManagerRelation){.smgr = p_smgr, .relpersistence = p_relpersistence}) #define BMR_GET_SMGR(bmr) \ (RelationIsValid((bmr).rel) ? RelationGetSmgr((bmr).rel) : (bmr).smgr) I don't see how it would be harder to remember to initialize a field with a designated initializer vs if you have to just remember to add a line initializing that field in the code. And using a designated initializer ensures all unspecified fields will be zeroed out. In general, I have seen threads [1] encouraging the use of designated initializers, so I'm inclined to leave it as is since it is committed, and I haven't heard other pushback. > 3 - pushed 0002 > ``` > prstate->all_visible = false; > + prstate->all_frozen = false; > ``` > > Nit: Now setting the both fields to false repeat in 6 places. Maybe add a static inline function, say PruneClearVisibilityFlags(), may improve maintainability. I see your point. However, I don't think it would necessarily be an improvement. This function already has a lot of helpers you have to jump to to understand what's going on. And in the place where they are cleared most often, heap_prune_record_unchanged_lp_normal(), we set other fields of the prstate directly, so it is nice visual symmetry in my opinion to set them inline. I did want to use chained assignment (all_visible = all_frozen = false), but I have had people complain about that in my code before more than once, so I refrained. > 4 - pushed 0003 > ``` > + * opporunistically freeze, to indicate if the VM bits can be set. They are > ``` > > Typo: opporunistically, missed a “t”. Fixed in same commit that added the assert. - Melanie [1] https://www.postgresql.org/message-id/flat/5B873BED.9080501%40anastigmatix.net#4a067c1314783f0e171b4e1be76f7574 -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-03T23:07:38Z
Thanks for the review! All the small changes you suggested I made in attached v23 unless otherwise noted below. On Mon, Nov 24, 2025 at 5:24 PM Andres Freund <andres@anarazel.de> wrote: > > On 2025-11-20 12:19:58 -0500, Melanie Plageman wrote: > > Subject: [PATCH v22 1/9] Split heap_page_prune_and_freeze() into helpers > > One minor thing: It's slightly odd that prune_freeze_plan() gets an oid > argument, prune_freeze_setup() gets the entire prstate, > heap_page_will_freeze() gets the Relation. It's what they need, but still a > bit odd. They all get the PruneState actually. I've committed this patch (but actually have to do a follow-on commit to silence coverity. Will do that next.) > > Subject: [PATCH v22 2/9] Eliminate XLOG_HEAP2_VISIBLE from vacuum phase I > > prune/freeze > > > Hm. This change makes sense, but unfortunately I find it somewhat hard to > review. There are a lot of changes that don't obviously work towards one > goal in this commit. I've split up the first commit into 4 patches in attached v23 (0002-0005). They are not meant to be committed separately but are separate only for ease of review. They comprise the logical steps for getting to the final code state. I originally had it split up but got feedback it was more work to review them each. So, let's see how this goes. > >@@ -238,6 +239,16 @@ typedef struct PruneFreezeParams > > >+ * vmbuffer is the buffer that must already contain contain the required > >+ * block of the visibility map if we are to update it. blk_known_av is the > >+ * visibility status of the heap block as of the last call to > >+ * find_next_unskippable_block(). > >+ */ > >+ Buffer vmbuffer; > >+ bool blk_known_av; > > What is blk_known_av set to if the block is known to not be all visible? > Compared to the case where we did not yet determine the visibility status of > the block? blk_known_av should always be set to false if the caller doesn't know. It is used as an optimization. I've added to the comment in this struct to clarify that. More on this further down in my mail. > > + * Decide whether to set the visibility map bits for heap_blk, using > > + * information from PruneState and blk_known_av. Some callers may already > > + * have examined this page’s VM bits (e.g., VACUUM in the previous > > + * heap_vac_scan_next_block() call) and can pass that along. > > That's not entirely trivial to follow, tbh. As mentioned above, it's not clear > to me how the state of a block where did determine that the block is *not* > all-visible is represented. There is no need to distinguish between knowing it is not all-visible and not knowing if it is all-visible. That is, "not known" and "known not" are the same for our purposes. This is only an optimization and not needed for correctness. I've tried to add comments to this effect in various places where blk_known_av is used. > > + else if (blk_known_av && !PageIsAllVisible(heap_page) && > > + visibilitymap_get_status(relation, heap_blk, &vmbuffer) != 0) > > + { > > + ereport(WARNING, > > + (errcode(ERRCODE_DATA_CORRUPTED), > > + errmsg("page is not marked all-visible but visibility map bit is set in relation \"%s\" page %u", > > + RelationGetRelationName(relation), heap_blk))); > > + > > + visibilitymap_clear(relation, heap_blk, vmbuffer, > > + VISIBILITYMAP_VALID_BITS); > > Wait, why is it ok to perform this check iff blk_known_av is set? This is existing logic in vacuum. It would be okay to perform the check even if blk_known_av is false but might be too expensive for the common case where the page is not all-visible (especially on-access). The next vacuum should be able to enter this code path and fix it. Or do you think it will be cheap enough because the caller will have read in and pinned the VM page? > > + old_vmbits = visibilitymap_set_vmbits(blockno, > > + vmbuffer, new_vmbits, > > + params->relation->rd_locator); > > + if (old_vmbits == new_vmbits) > > + { > > + LockBuffer(vmbuffer, BUFFER_LOCK_UNLOCK); > > + /* Unset so we don't emit WAL since no change occurred */ > > + do_set_vm = false; > > + } > > + } > > What can lead to this path being reached? Doesn't this mean that something > changed the state of the VM while we were holding an exclusive lock on the > heap buffer? This shouldn't be in this commit (I've fixed that). However, it is needed once we have on-access VM setting because we could have set the page all-visible in the VM on-access in between when find_next_unskippable_block() first checks the VM and sets all_visible_according_to_vm/blk_known_av and when we take the lock and prune/freeze the page. > > log_heap_prune_and_freeze(params->relation, buffer, > > - InvalidBuffer, /* vmbuffer */ > > - 0, /* vmflags */ > > + do_set_vm ? vmbuffer : InvalidBuffer, > > + do_set_vm ? new_vmbits : 0, > > conflict_xid, > > - true, params->reason, > > + true, /* cleanup lock */ > > + do_set_pd_vis, > > + params->reason, > > prstate.frozen, prstate.nfrozen, > > prstate.redirected, prstate.nredirected, > > prstate.nowdead, prstate.ndead, > > This function is now taking 16 parameters :/ Is this complaint about readability or performance of parameter passing? Because if it's the latter, I can't imagine that will be noticeable when compared to the overhead of emitting a WAL record. I could add a struct just for passing the parameters to the log_heap_prune_and_freeze(). Something like: typedef struct PruneFreezeChanges { int nredirected; int ndead; int nunused; int nfrozen; OffsetNumber *redirected; OffsetNumber *nowdead; OffsetNumber *nowunused; HeapTupleFreeze *frozen; } PruneFreezeChanges; PruneFreezeChanges c = { .redirected = prstate.redirected, .nredirected = prstate.nredirected, .ndead = prstate.ndead, .nowdead = prstate.nowdead, .nunused = prstate.nunused, .nowunused = prstate.nowunused, .nfrozen = prstate.nfrozen, .frozen = prstate.frozen, }; log_heap_prune_and_freeze(params->relation, buffer, InvalidBuffer, /* vmbuffer */ 0, /* vmflags */ conflict_xid, true, params->reason, c); However, I fear it is a bit confusing to have this struct just to pass the parameters to the log_heap_prune_and_freeze(). We can't use that struct inline in the PruneState because then we would need all the arrays to be inline in the PruneFreezeChanges struct which would cause 4*MaxHeapTuplesPerPage stack allocated OffsetNumbers in vacuum phase III than it currently has and needs. The only other related parameters I see that could be stuck into a struct are vmflags and set_pd_all_vis -- maybe called VisiChanges or HeapPageVisiChanges. But again, I'm not sure if it is worth adding a new struct for this. > > +#ifdef USE_ASSERT_CHECKING > > + if (prstate.all_visible) > > + { > > + TransactionId debug_cutoff; > > + bool debug_all_frozen; > > + > > + Assert(prstate.lpdead_items == 0); > > + Assert(prstate.cutoffs); > > + > > + if (!heap_page_is_all_visible(params->relation, buffer, > > + prstate.cutoffs->OldestXmin, > > + &debug_all_frozen, > > + &debug_cutoff, off_loc)) > > + Assert(false); > > I don't love Assert(false), because the message for the assert failure is > pretty much meaningless. Sometimes it's hard to avoid, but here you have an if > () that has no body other than Assert(false)? Just Assert the expression > directly. This is existing code. I agree it's weird, but I remember Peter saying something about why he did it this way that I no longer remember. Anyway, 0001 changes the assert as you suggest. > > Subject: [PATCH v22 3/9] Eliminate XLOG_HEAP2_VISIBLE from empty-page vacuum > > > > As part of removing XLOG_HEAP2_VISIBLE records, phase I of VACUUM now > > marks empty pages all-visible in a XLOG_HEAP2_PRUNE_VACUUM_SCAN record. > > This whole business of treating empty pages as all-visible continues to not > make any sense to me. Particularly in combination with a not crashsafe FSM it > just seems ... unhelpful. It also means that there there's a decent chance of > extra WAL when bulk extending. But that's not the fault of this change. Is the argument for setting them av/af that we can skip them more easily in future vacuums (i.e. not have to read in the page and take a lock etc)? > > Subject: [PATCH v22 4/9] Remove XLOG_HEAP2_VISIBLE entirely > > > > As no remaining users emit XLOG_HEAP2_VISIBLE records. > > This includes deleting the xl_heap_visible struct and all functions > > responsible for emitting or replaying XLOG_HEAP2_VISIBLE records. > > Probably worth mentioning that this changes the VM API. I've added a mention about this in the commit. Are you imagining I have any comments anywhere about how XLOG_HEAP2_VISIBLE used to exist? I realized I need to bump XLOG_PAGE_MAGIC in this commit because the code to replay XLOG_HEAP2_VISIBLE records is gone now. What I'm not sure is if I have to bump it in some of the other commits that change which WAL records are emitted by a particular operation (e.g. not emitting a separate VM record from phase I of vacuum). > > - * - Page pruning, in VACUUM's 1st pass or on access: Some items are > > + * - Page pruning, in vacuum phase I or on-access: Some items are > > * redirected, some marked dead, and some removed altogether. > > * > > - * - Freezing: Items are marked as 'frozen'. > > + * - Freezing: During vacuum phase I, items are marked as 'frozen' > > * > > - * - Vacuum, 2nd pass: Items that are already LP_DEAD are marked as unused. > > + * - Reaping: During vacuum phase III, items that are already LP_DEAD are > > + * marked as unused. > > * > > - * They have enough commonalities that we use a single WAL record for them > > + * - VM updates: After vacuum phases I and III, the heap page may be marked > > + * all-visible and all-frozen. > > + * > > + * These changes all happen together, so we use a single WAL record for them > > * all. > > * > > * If replaying the record requires a cleanup lock, pass cleanup_lock = > > true. > > How's that related to the commit's subject? Oops, I moved it to the relevant commit. > > Subject: [PATCH v22 5/9] Rename GlobalVisTestIsRemovableXid() to > > GlobalVisXidVisibleToAll() > > > > The function is currently only used to check whether a tuple’s xmax is > > visible to all transactions (and thus removable). Upcoming changes will > > also use it to test whether a tuple’s xmin is visible to all to > > decide if a page can be marked all-visible in the visibility map. > > > > The new name, GlobalVisXidVisibleToAll(), better reflects this broader > > purpose. > > If we want this - and I'm not convinced we do - I think it needs to go further > and change the other uses of removable in > procarray.c. ComputeXidHorizonsResult has a lot of related fields. > > There's also GetOldestNonRemovableTransactionId(), > GlobalVisCheckRemovableXid(), GlobalVisCheckRemovableFullXid() that weren't > included in the renaming. Okay, I see what you are saying. When you say you're not sure if we want "this" -- do you mean using GlobalVisState for determining if xmins are visible to all (which is required to set the VM on-access) or do you mean renaming those functions? If we're just talking about the renaming, looking at procarray.c, it is full of the word "removable" because its functions were largely used to examine and determine if everyone can see an xmax as committed and thus if that tuple is removable from their perspective. But nothing about the code that I can see means it has to be an xmax. We could just as well use the functions to determine if everyone can see an xmin as committed. I don't see how we can leave the names as is and use it on xmins because that tuple is _not_ removable based on testing if everyone can see the xmin. So the function basically returns an incorrect result. That being said, the problem with replacing "removable" with "visible to all" -- which isn't _terrible_ -- means we have to replace "nonremovable" with "not visible to all" -- which is terrible. I think getting rid of "removable" from procarray.c would be an improvement because that file feels tightly coupled to vacuum and removing tuples because of the names of variables and functions when actually its functionality isn't. So, the issue is coming up with something palatable. One alternative idea (that requires no renaming) is to add a wrapper function somewhere outside procarray.c which invokes GlobalVisTestIsRemovableXid() but is called something like XidVisibleToAll() and is documented for use with xmins/etc. It would avoid the messy work of coming up with a good name. What do you think? > > Subject: [PATCH v22 6/9] Use GlobalVisState in vacuum to determine page level > > visibility > > > > This also benefits vacuum directly: in some cases, GlobalVisState may > > advance during a vacuum, allowing more pages to become considered > > all-visible. And, in the future, we could easily add a heuristic to > > update GlobalVisState more frequently during vacuums of large tables. In > > the rare case that the GlobalVisState moves backward, vacuum falls back > > to OldestXmin to ensure we don’t attempt to freeze a dead tuple that > > wasn’t yet prunable according to the GlobalVisState. > > I think it may be better to make sure that the GlobalVisState can't move > backward. Do you mean that I shouldn't use the GlobalVisState to determine visibility until I make sure it can't move backwards? There is actually no functional difference in my patch set with the code this commit message refers to (in heap_prune_satisfies_vacuum()). I only mentioned it to make sure folks knew that even though I was widening usage of GlobalVisState, we wouldn't encounter synchronization issues with freezing horizons. > > Subject: [PATCH v22 8/9] Allow on-access pruning to set pages all-visible > > > > Many queries do not modify the underlying relation. For such queries, if > > on-access pruning occurs during the scan, we can check whether the page > > has become all-visible and update the visibility map accordingly. > > Previously, only vacuum and COPY FREEZE marked pages as all-visible or > > all-frozen. > > > Supporting this requires passing information about whether the relation > > is modified from the executor down to the scan descriptor. > > I think it'd be good to split this part into a separate commit. The set of > folks to review that are distinct (and broader) from the ones looking at > heapam internals. Good point. I've split it into 3 commits in this patch set (0011-0013) - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-03T23:08:53Z
On Mon, Nov 24, 2025 at 3:08 AM Chao Li <li.evan.chao@gmail.com> wrote: > > > On Nov 21, 2025, at 09:09, Chao Li <li.evan.chao@gmail.com> wrote: > > > > I’d stop here today, and continue reviewing rest commits in next week. > > I continue reviewing today. I incorporated all your feedback in my recently posted v23. Thanks for the review! - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2025-12-04T05:10:33Z
Hi Melanie, I resisted this patch again today. I reviewed 0001-0004, and got a few more comments: > On Dec 4, 2025, at 07:07, Melanie Plageman <melanieplageman@gmail.com> wrote: > > <v23-0001-Simplify-vacuum-visibility-assertion.patch><v23-0002-Refactor-lazy_scan_prune-VM-set-logic-into-helpe.patch><v23-0003-Set-the-VM-in-prune-code.patch><v23-0004-Move-VM-assert-into-prune-freeze-code.patch><v23-0005-Eliminate-XLOG_HEAP2_VISIBLE-from-vacuum-phase-I.patch><v23-0006-Eliminate-XLOG_HEAP2_VISIBLE-from-empty-page-vac.patch><v23-0007-Remove-XLOG_HEAP2_VISIBLE-entirely.patch><v23-0008-Rename-GlobalVisTestIsRemovableXid-to-GlobalVisX.patch><v23-0009-Use-GlobalVisState-in-vacuum-to-determine-page-l.patch><v23-0010-Unset-all_visible-sooner-if-not-freezing.patch><v23-0011-Track-which-relations-are-modified-by-a-query.patch><v23-0012-Pass-down-information-on-table-modification-to-s.patch><v23-0013-Allow-on-access-pruning-to-set-pages-all-visible.patch><v23-0014-Set-pd_prune_xid-on-insert.patch> 1 - 0002 ``` +static bool +heap_page_will_set_vis(Relation relation, + BlockNumber heap_blk, + Buffer heap_buf, + Buffer vmbuffer, + bool all_visible_according_to_vm, + const PruneFreezeResult *presult, + uint8 *new_vmbits, + bool *do_set_pd_vis) ``` Actually, I wanted to comment on the new function name in last round of review, but I guess I missed that. I was very confused what “set_vis” means, and finally figured out “vis” should stand for “visibility”. Here “vis” actually means “visibility map bits”. There is the other “vis” in the last parameter’s name “do_set_pd_vis” where the “vis” should be mean “PD_ALL_VISIBLE” bit. So the two “vis” feels making things confusing. How about rename the function to “heap_page_will_set_vm_bits”, and rename the last parameter to “do_set_all_visible”? 2 - 0002 ``` + * Decide whether to set the visibility map bits for heap_blk, using + * information from PruneFreezeResult and all_visible_according_to_vm. This + * function does not actually set the VM bit or page-level hint, + * PD_ALL_VISIBLE. + * + * If it finds that the page-level visibility hint or VM is corrupted, it will + * fix them by clearing the VM bit and page hint. This does not need to be + * done in a critical section. + * + * Returns true if one or both VM bits should be set, along with the desired + * flags in *new_vmbits. Also indicates via do_set_pd_vis whether + * PD_ALL_VISIBLE should be set on the heap page. + */ ``` This function comment mentions PD_ALL_VISIBLE twice, but never mentions ALL_FROZEN. So “Returns true if one or both VM bits should be set” fells unclear. How about rephrase like "Returns true if the all-visible and/or all-frozen VM bits should be set.” 3 - 0002 ``` + /* + * Now handle two potential corruption cases: + * + * These do not need to happen in a critical section and are not + * WAL-logged. + * + * As of PostgreSQL 9.2, the visibility map bit should never be set if the + * page-level bit is clear. However, it's possible that the bit got + * cleared after heap_vac_scan_next_block() was called, so we must recheck + * with buffer lock before concluding that the VM is corrupt. + */ + else if (all_visible_according_to_vm && !PageIsAllVisible(heap_page) && + visibilitymap_get_status(relation, heap_blk, &vmbuffer) != 0) + { + ereport(WARNING, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("page is not marked all-visible but visibility map bit is set in relation \"%s\" page %u", + RelationGetRelationName(relation), heap_blk))); + + visibilitymap_clear(relation, heap_blk, vmbuffer, + VISIBILITYMAP_VALID_BITS); + } ``` Here in the comment and error message, I guess “visibility map bit” refers to “all visible bit”, can we be explicit? Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-09T17:48:45Z
On Thu, Dec 4, 2025 at 12:11 AM Chao Li <li.evan.chao@gmail.com> wrote: > > I resisted this patch again today. I reviewed 0001-0004, and got a few more comments: Thanks for the review! v24 attached with updates you suggested as well as the bug fix described below. I realized my code didn't mark the heap buffer dirty if we were not modifying it (i.e. only setting the VM). This trips an assert in XLogRegisterBuffer() which requires that all buffers registered with the WAL machinery are marked dirty unless REGBUF_NO_CHANGE is passed. It wasn't possible to hit it in master because we unconditionally dirtied the buffer if we found the VM not set in find_next_unskippable_block() -- even if we made no changes to the heap buffer. But my refactoring only dirtied the heap buffer if we modified it (either pruning/freezing or setting PD_ALL_VISIBLE). In attached v24, I once again always dirty the heap buffer before registering it. We can't skip adding the heap buffer to the WAL chain even if we didn't modify it, because we use it to update the freespace map during recovery. We could pass REGBUF_NO_CHANGE when the heap buffer is completely unmodified. But the delicate special case logic doesn't seem worth the effort to maintain, as the only time the heap buffer should be unmodified is when the VM has been truncated or removed. I added a test to the commit doing this refactoring that would have caught my mistake (0003). I also split the refactoring of the VM setting logic into more commits to help make it clearer (0003-0004). We could technically commit the refactoring commits to master. I had not originally intended to do so since they do not have independent value beyond clarity for the reviewer. In this set 0001 and 0002 are independent. 0003-0007 are all small steps toward the single change in 0007 which combines the VM updates into the same WAL record as pruning and freezing. 0008 and 0009 are removing the rest of XLOG_HEAP2_VISIBLE. 0010 - 0012 are refactoring needed to set the VM during on-access pruning. 0013 - 0015 are small steps toward setting the VM on-access. And 0016 sets the prune xid on insert so we may set the VM on-access for pages that have only new data. > +static bool > +heap_page_will_set_vis(Relation relation, > > Actually, I wanted to comment on the new function name in last round of review, but I guess I missed that. > > I was very confused what “set_vis” means, and finally figured out “vis” should stand for “visibility”. Here “vis” actually means “visibility map bits”. There is the other “vis” in the last parameter’s name “do_set_pd_vis” where the “vis” should be mean “PD_ALL_VISIBLE” bit. So the two “vis” feels making things confusing. > > How about rename the function to “heap_page_will_set_vm_bits”, and rename the last parameter to “do_set_all_visible”? I named it that way because it was responsible for telling us what we should set the VM to _and_ if we should set PD_ALL_VISIBLE. However, once I corrected the bug mentioned above, we always set PD_ALL_VISIBLE if setting the VM, so I was able to remove this ambiguity. As such I've renamed the function heap_page_will_set_vm() (and removed the last parameter). > + * Decide whether to set the visibility map bits for heap_blk, using > + * information from PruneFreezeResult and all_visible_according_to_vm. This > + * function does not actually set the VM bit or page-level hint, > + * PD_ALL_VISIBLE. > + * > + * If it finds that the page-level visibility hint or VM is corrupted, it will > + * fix them by clearing the VM bit and page hint. This does not need to be > + * done in a critical section. > + * > + * Returns true if one or both VM bits should be set, along with the desired > + * flags in *new_vmbits. Also indicates via do_set_pd_vis whether > + * PD_ALL_VISIBLE should be set on the heap page. > + */ > ``` > > This function comment mentions PD_ALL_VISIBLE twice, but never mentions ALL_FROZEN. So “Returns true if one or both VM bits should be set” fells unclear. How about rephrase like "Returns true if the all-visible and/or all-frozen VM bits should be set.” PD_ALL_VISIBLE is the page-level visibility hint (not the VM bit) and there is no page level frozen hint. It doesn't mention that the VM bits are all-visible and all-frozen, though, so I have modified the comment a bit to make sure the all-frozen bit of the VM is mentioned. > + * Now handle two potential corruption cases: > + * > + * These do not need to happen in a critical section and are not > + * WAL-logged. > + * > + * As of PostgreSQL 9.2, the visibility map bit should never be set if the > + * page-level bit is clear. However, it's possible that the bit got > + * cleared after heap_vac_scan_next_block() was called, so we must recheck > + * with buffer lock before concluding that the VM is corrupt. > + */ > + else if (all_visible_according_to_vm && !PageIsAllVisible(heap_page) && > + visibilitymap_get_status(relation, heap_blk, &vmbuffer) != 0) > + { > + ereport(WARNING, > + (errcode(ERRCODE_DATA_CORRUPTED), > + errmsg("page is not marked all-visible but visibility map bit is set in relation \"%s\" page %u", > + RelationGetRelationName(relation), heap_blk))); > + > + visibilitymap_clear(relation, heap_blk, vmbuffer, > + VISIBILITYMAP_VALID_BITS); > + } > ``` > > Here in the comment and error message, I guess “visibility map bit” refers to “all visible bit”, can we be explicit? This is an existing comment in lazy_scan_prune() that I simply moved. It isn't valid for the all-frozen bit to be set unless the all-visible bit is set. I'm not sure whether specifying which bits were set in the warning will help users debug the corruption they are seeing. But I think it is a reasonable suggestion to make. Perhaps it is worth suggesting this (adding the specific vmbits to the warning message) in a separate thread since it is an independent improvement on master? - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-10T23:35:47Z
On Tue, Dec 9, 2025 at 12:48 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > In this set 0001 and 0002 are independent. 0003-0007 are all small > steps toward the single change in 0007 which combines the VM updates > into the same WAL record as pruning and freezing. 0008 and 0009 are > removing the rest of XLOG_HEAP2_VISIBLE. 0010 - 0012 are refactoring > needed to set the VM during on-access pruning. 0013 - 0015 are small > steps toward setting the VM on-access. And 0016 sets the prune xid on > insert so we may set the VM on-access for pages that have only new > data. I committed 0001 and 0002. attached v25 reflects that. 0001-0004 refactoring steps for eliminate visible record from phase I (not probably independent commits in the end) 0005 eliminate XLOG_HEAP2_VISIBLE from phase I vac 0006-0007 removing the rest of XLOG_HEAP2_VISIBLE 0008-0010 refactoring for setting VM on-access 0011-0013 setting the VM on-access 0014 - setting pd_prune_xid on insert - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2025-12-11T04:06:57Z
> On Dec 11, 2025, at 07:35, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Tue, Dec 9, 2025 at 12:48 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: >> >> In this set 0001 and 0002 are independent. 0003-0007 are all small >> steps toward the single change in 0007 which combines the VM updates >> into the same WAL record as pruning and freezing. 0008 and 0009 are >> removing the rest of XLOG_HEAP2_VISIBLE. 0010 - 0012 are refactoring >> needed to set the VM during on-access pruning. 0013 - 0015 are small >> steps toward setting the VM on-access. And 0016 sets the prune xid on >> insert so we may set the VM on-access for pages that have only new >> data. > > I committed 0001 and 0002. attached v25 reflects that. > 0001-0004 refactoring steps for eliminate visible record from phase I > (not probably independent commits in the end) > 0005 eliminate XLOG_HEAP2_VISIBLE from phase I vac > 0006-0007 removing the rest of XLOG_HEAP2_VISIBLE > 0008-0010 refactoring for setting VM on-access > 0011-0013 setting the VM on-access > 0014 - setting pd_prune_xid on insert > > - Melanie > <v25-0001-Combine-visibilitymap_set-cases-in-lazy_scan_pru.patch><v25-0002-Refactor-lazy_scan_prune-VM-set-logic-into-helpe.patch><v25-0003-Set-the-VM-in-heap_page_prune_and_freeze.patch><v25-0004-Move-VM-assert-into-prune-freeze-code.patch><v25-0005-Eliminate-XLOG_HEAP2_VISIBLE-from-vacuum-phase-I.patch><v25-0006-Eliminate-XLOG_HEAP2_VISIBLE-from-empty-page-vac.patch><v25-0007-Remove-XLOG_HEAP2_VISIBLE-entirely.patch><v25-0008-Rename-GlobalVisTestIsRemovableXid-to-GlobalVisX.patch><v25-0009-Use-GlobalVisState-in-vacuum-to-determine-page-l.patch><v25-0011-Track-which-relations-are-modified-by-a-query.patch><v25-0012-Pass-down-information-on-table-modification-to-s.patch><v25-0013-Allow-on-access-pruning-to-set-pages-all-visible.patch><v25-0014-Set-pd_prune_xid-on-insert.patch> A few more small comments. Sorry for keeping come out new comments. Actually I learned a lot about vacuum from reviewing this patch. 1 - 0001 ``` +-- the checkpoint cleans the buffer dirtied by freezing the sole tuple +checkpoint; +-- truncating the VM ensures that the next vacuum will need to set it +select pg_truncate_visibility_map('test_vac_unmodified_heap'); +-- vacuum sets the VM but does not need to set PD_ALL_VISIBLE so no heap page +-- modification +vacuum test_vac_unmodified_heap; ``` The last vacuum is expected to set vm bits, but the test doesn’t verify that. Should we verify that like: ``` evantest=# SELECT blkno, all_visible, all_frozen FROM pg_visibility_map('test_vac_unmodified_heap'); blkno | all_visible | all_frozen -------+-------------+------------ 0 | t | t (1 row) ``` As you have been using the extension pg_visibility, adding the verification with pg_visibility_map() should not be a burden. 2 - 0001 ``` if (presult.all_frozen) { + /* + * We can pass InvalidTransactionId as our cutoff_xid, since a + * snapshotConflictHorizon sufficient to make everything safe for + * REDO was logged when the page's tuples were frozen. + */ Assert(!TransactionIdIsValid(presult.vm_conflict_horizon)); - flags |= VISIBILITYMAP_ALL_FROZEN; + new_vmbits |= VISIBILITYMAP_ALL_FROZEN; } ``` The comment here is a little confusing. In the old code, the Assert() as immediately above the call visibilitymap_set(), and cutoff_xid is a parameter to the call. But the new code moves the Assert() as well as the comment far away from the call visibilitymap_set(), so I think the comment should stay together with the call of visibilitymap_set(). 3 - 0002 ``` * If it finds that the page-level visibility hint or VM is corrupted, it will * fix them by clearing the VM bits and visibility page hint. This does not ``` In the second line, “visibility page hint” is understandable but feels not quite good. I know it’s actually “page-level visibility hint”, so how about just “visibility hint”. 4 - 0002 ``` /* - * As of PostgreSQL 9.2, the visibility map bit should never be set if the - * page-level bit is clear. However, it's possible that the bit got - * cleared after heap_vac_scan_next_block() was called, so we must recheck - * with buffer lock before concluding that the VM is corrupt. + * For the purposes of logging, count whether or not the page was newly + * set all-visible and, potentially, all-frozen. */ - else if (all_visible_according_to_vm && !PageIsAllVisible(page) && - visibilitymap_get_status(vacrel->rel, blkno, &vmbuffer) != 0) + if ((old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0 && + (new_vmbits & VISIBILITYMAP_ALL_VISIBLE) != 0) { ``` Without do_set_vm==true, old_vmbits will only be 0, thus this “if-elseif” that uses old_vmbits should be moved into “if (do_set_vm)”. From this perspective, if not do_set_vm, this function can return early, like: ``` Do_set_vm = heap_page_will_set_vm(&new_vmbits) If (!do_set_vm) Return presult.ndeleted; PageSetAllVisible(page); MarkBufferDirty(buf); old_vmbits = visibilitymap_set(new_vmbits); If (old_vmbits..) { .. } Else if (old_vmbits…) { … } Return presult.ndeleted; ``` 5 - 0003 ``` /* * lazy_scan_prune() -- lazy_scan_heap() pruning and freezing. * @@ -2076,15 +1979,14 @@ lazy_scan_prune(LVRelState *vacrel, bool *vm_page_frozen) { Relation rel = vacrel->rel; - bool do_set_vm = false; - uint8 new_vmbits = 0; - uint8 old_vmbits = 0; PruneFreezeResult presult; PruneFreezeParams params = { .relation = rel, .buffer = buf, + .vmbuffer = vmbuffer, + .blk_known_av = all_visible_according_to_vm, .reason = PRUNE_VACUUM_SCAN, - .options = HEAP_PAGE_PRUNE_FREEZE, + .options = HEAP_PAGE_PRUNE_FREEZE | HEAP_PAGE_PRUNE_UPDATE_VM, .vistest = vacrel->vistest, .cutoffs = &vacrel->cutoffs, }; ``` This maybe a legacy bug. Here presult is not initialized, and it is immediately passed to heap_page_prune_and_freeze(): ``` heap_page_prune_and_freeze(¶ms, &presult, <=== here &vacrel->offnum, &vacrel->NewRelfrozenXid, &vacrel->NewRelminMxid); ``` Then heap_page_prune_and_freeze() immediately calls prune_freeze_setup(): ``` /* Initialize prstate */ prune_freeze_setup(params, new_relfrozen_xid, new_relmin_mxid, presult, &prstate); ``` And prune_freeze_setup() takes presult as a const pointer: ``` static void prune_freeze_setup(PruneFreezeParams *params, TransactionId *new_relfrozen_xid, MultiXactId *new_relmin_mxid, const PruneFreezeResult *presult, <=== here PruneState *prstate) { prstate->deadoffsets = (OffsetNumber *) presult->deadoffsets; <== here, presult->deadoffsets could be a random value } ``` As this is a separate issue off the current patch, I just filed a new patch to fix it. Please take a look at: https://www.postgresql.org/message-id/CAEoWx2%3DjiD1nqch4JQN%2BodAxZSD7mRvdoHUGJYN2r6tQG_66yQ%40mail.gmail.com 6 - 0003 ``` + * Returns true if one or both VM bits should be set, along with returning the + * desired what bits should be set in the VM in *new_vmbits. ``` Looks like a typo: “returning the desired what bits should be set”, maybe change to “returning the desired bits to be set”. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Peter Eisentraut <peter@eisentraut.org> — 2025-12-13T13:59:29Z
On 20.11.25 18:19, Melanie Plageman wrote: > + prstate->deadoffsets = (OffsetNumber *) presult->deadoffsets; In your patch v22-0001-Split-heap_page_prune_and_freeze-into-helpers.patch, the assignment above casts away the const qualification of the function argument presult: +static void +prune_freeze_setup(PruneFreezeParams *params, + TransactionId new_relfrozen_xid, + MultiXactId new_relmin_mxid, + const PruneFreezeResult *presult, + PruneState *prstate) (The cast is otherwise unnecessary, since the underlying type is the same on both sides.) Since prstate->deadoffsets is in fact later modified, this makes the original const qualification invalid. I suggest the attached patch to remove the faulty const qualification and the then-unnecessary cast.
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-15T21:05:19Z
On Sat, Dec 13, 2025 at 8:59 AM Peter Eisentraut <peter@eisentraut.org> wrote: > > On 20.11.25 18:19, Melanie Plageman wrote: > > + prstate->deadoffsets = (OffsetNumber *) presult->deadoffsets; > > In your patch > v22-0001-Split-heap_page_prune_and_freeze-into-helpers.patch, the > assignment above casts away the const qualification of the function > argument presult: Yea, this code (prune_freeze_setup() with a const-qualified PruneFreezeResult parameter) is actually already in master -- not just in this patchset. > +static void > +prune_freeze_setup(PruneFreezeParams *params, > + TransactionId new_relfrozen_xid, > + MultiXactId new_relmin_mxid, > + const PruneFreezeResult *presult, > + PruneState *prstate) > > (The cast is otherwise unnecessary, since the underlying type is the > same on both sides.) > > Since prstate->deadoffsets is in fact later modified, this makes the > original const qualification invalid. I didn't realize I was misusing const here. What I meant to indicate by defining the prune_freeze_setup() parameter, as const, is that the PruneFreezeResult wouldn't be modified by prune_freeze_setup(). I did not mean to indicate that no members of PruneFreezeResult would ever be modified. deadoffsets is not modified in prune_freeze_setup(). So, are you saying that I can't define a parameter as const if even the caller modifies it? I'm fine with committing a change, I just want to understand. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-15T21:29:03Z
Hi, Attached v26 includes a new patch, 0002, which gets rid of all_visible_according_to_vm in lazy_scan_prune(). We've kept this cached copy of the all-visible bit since the VM was added way back in 608195a3a365. Back then, the VM wasn't pinned unless all_visible_according_to_vm was false. Now that we unconditionally have the VM page pinned, there isn't much performance benefit to using that cached value. I did some testing of the worst possible case and saw no difference in timing. By removing that, we simplify heap vacuum code now. And we improve clarity once the VM update is combined into the prune/freeze WAL record and when the VM is set on-access. I think 0001 and 0002 (and maybe 0003) are worthwhile clarity improvements on their own. On Wed, Dec 10, 2025 at 11:07 PM Chao Li <li.evan.chao@gmail.com> wrote: > > A few more small comments. Sorry for keeping come out new comments. Actually I learned a lot about vacuum from reviewing this patch. Thanks for the continued review. Your feedback is improving the patchset. > The last vacuum is expected to set vm bits, but the test doesn’t verify that. Should we verify that like: > ``` > evantest=# SELECT blkno, all_visible, all_frozen FROM pg_visibility_map('test_vac_unmodified_heap'); > blkno | all_visible | all_frozen > -------+-------------+------------ > 0 | t | t > (1 row) I've done this. I've actually added three such verifications -- one after each step where the VM is expected to change. It shouldn't be very expensive, so I think it is okay. The way the test would fail if the buffer wasn't correctly dirtied is that it would assert out -- so the visibility map test wouldn't even have a chance to fail. But, I think it is also okay to confirm that the expected things are happening with the VM -- it just gives us extra coverage. > if (presult.all_frozen) > { > + /* > + * We can pass InvalidTransactionId as our cutoff_xid, since a > + * snapshotConflictHorizon sufficient to make everything safe for > + * REDO was logged when the page's tuples were frozen. > + */ > Assert(!TransactionIdIsValid(presult.vm_conflict_horizon)); > - flags |= VISIBILITYMAP_ALL_FROZEN; > + new_vmbits |= VISIBILITYMAP_ALL_FROZEN; > } > > The comment here is a little confusing. In the old code, the Assert() as immediately above the call visibilitymap_set(), and cutoff_xid is a parameter to the call. But the new code moves the Assert() as well as the comment far away from the call visibilitymap_set(), so I think the comment should stay together with the call of visibilitymap_set(). Good point. I've moved it closer to visibilitymap_set() and modified and moved the assert so that it is together with the comment. I think the comment makes little sense without the assertion. > * If it finds that the page-level visibility hint or VM is corrupted, it will > * fix them by clearing the VM bits and visibility page hint. This does not > > In the second line, “visibility page hint” is understandable but feels not quite good. I know it’s actually “page-level visibility hint”, so how about just “visibility hint”. I've changed this. > /* > - * As of PostgreSQL 9.2, the visibility map bit should never be set if the > - * page-level bit is clear. However, it's possible that the bit got > - * cleared after heap_vac_scan_next_block() was called, so we must recheck > - * with buffer lock before concluding that the VM is corrupt. > + * For the purposes of logging, count whether or not the page was newly > + * set all-visible and, potentially, all-frozen. > */ > - else if (all_visible_according_to_vm && !PageIsAllVisible(page) && > - visibilitymap_get_status(vacrel->rel, blkno, &vmbuffer) != 0) > + if ((old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0 && > + (new_vmbits & VISIBILITYMAP_ALL_VISIBLE) != 0) > { > ``` > > Without do_set_vm==true, old_vmbits will only be 0, thus this “if-elseif” that uses old_vmbits should be moved into “if (do_set_vm)”. From this perspective, if not do_set_vm, this function can return early, like: Good point. I've actually gone ahead in 0002 and refactored this whole section a bit (I got rid of all_visible_according_to_vm). 0002 is a new patch in this attached v26, and it needs review. I think this refactoring makes the code quite a bit clearer -- especially once we start setting the VM on-access. It does, amongst other things, return early if all_visible is false, like you suggested. > + * Returns true if one or both VM bits should be set, along with returning the > + * desired what bits should be set in the VM in *new_vmbits. > ``` > > Looks like a typo: “returning the desired what bits should be set”, maybe change to “returning the desired bits to be set”. Fixed. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Peter Eisentraut <peter@eisentraut.org> — 2025-12-16T12:18:25Z
On 15.12.25 22:05, Melanie Plageman wrote: > On Sat, Dec 13, 2025 at 8:59 AM Peter Eisentraut <peter@eisentraut.org> wrote: >> >> On 20.11.25 18:19, Melanie Plageman wrote: >>> + prstate->deadoffsets = (OffsetNumber *) presult->deadoffsets; >> >> In your patch >> v22-0001-Split-heap_page_prune_and_freeze-into-helpers.patch, the >> assignment above casts away the const qualification of the function >> argument presult: > > Yea, this code (prune_freeze_setup() with a const-qualified > PruneFreezeResult parameter) is actually already in master -- not just > in this patchset. > >> +static void >> +prune_freeze_setup(PruneFreezeParams *params, >> + TransactionId new_relfrozen_xid, >> + MultiXactId new_relmin_mxid, >> + const PruneFreezeResult *presult, >> + PruneState *prstate) >> >> (The cast is otherwise unnecessary, since the underlying type is the >> same on both sides.) >> >> Since prstate->deadoffsets is in fact later modified, this makes the >> original const qualification invalid. > > I didn't realize I was misusing const here. What I meant to indicate > by defining the prune_freeze_setup() parameter, as const, is that the > PruneFreezeResult wouldn't be modified by prune_freeze_setup(). I did > not mean to indicate that no members of PruneFreezeResult would ever > be modified. I'm not sure there is a difference between these two statements. The struct won't be modified is the same as none of its fields will be modified. > deadoffsets is not modified in prune_freeze_setup(). So, > are you saying that I can't define a parameter as const if even the > caller modifies it? You are not modifying deadoffsets in prune_freeze_setup(), but you are assigning its address to a pointer variable that is not const-qualified, and so it could be used to modify it later on. A caller to prune_freeze_setup() that sees the signature const PruneFreezeResult *presult could pass a pointer to a PruneFreezeResult object that is notionally in read-only memory. But through the non-const-qualified pointer you could later modify the pointed-to memory, which would be invalid. The point of propagating the qualifiers is to prevent that at compile time. If what you want is something like, "prune_freeze_setup() does not change any of the fields of what presult points to, but it does record a pointer to one of its fields with the intention of modifying it later after prune_freeze_setup() is finished", then I think C cannot represent that with this API. Here is a simplified example: #include <stdlib.h> // corresponds to PruneFreezeResult struct foo { int offsets[5]; }; // corresponds to PruneState struct bar { int *offsets; }; static void setup(const struct foo *f) { struct bar *b = malloc(sizeof(struct bar)); b->offsets = f->offsets; // warning } This produces a warning: test.c:20:20: warning: assignment discards 'const' qualifier from pointer target type The reason is that what "f" points to is const, which means that all its fields are const. The fix is to remove the const from the function argument declaration. One of the possible sources of confusion here is that one struct uses an array and the other a pointer, and these sometimes behave similarly and sometimes not. -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-16T16:07:17Z
On Tue, Dec 16, 2025 at 7:18 AM Peter Eisentraut <peter@eisentraut.org> wrote: > > You are not modifying deadoffsets in prune_freeze_setup(), but you are > assigning its address to a pointer variable that is not const-qualified, > and so it could be used to modify it later on. > > A caller to prune_freeze_setup() that sees the signature const > PruneFreezeResult *presult could pass a pointer to a PruneFreezeResult > object that is notionally in read-only memory. But through the > non-const-qualified pointer you could later modify the pointed-to > memory, which would be invalid. The point of propagating the qualifiers > is to prevent that at compile time. Thanks for the explanation. I've committed your proposed fix. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-16T16:58:50Z
On Wed, Dec 3, 2025 at 6:07 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > If we're just talking about the renaming, looking at procarray.c, it > is full of the word "removable" because its functions were largely > used to examine and determine if everyone can see an xmax as committed > and thus if that tuple is removable from their perspective. But > nothing about the code that I can see means it has to be an xmax. We > could just as well use the functions to determine if everyone can see > an xmin as committed. In the attached v27, I've removed the commit that renamed functions in procarray.c. I've added a single wrapper GlobalVisTestXidNotRunning() that is used in my code where I am testing live tuples. I think you'll find that I've addressed all of your review comments now -- as I've also gotten rid of the confusing blk_known_av logic through a series of refactors. The one outstanding point is which commits should bump XLOG_PAGE_MAGIC. (also review of the reworked patches). - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-12-17T18:27:05Z
Hi! in v27-0001: > Melanie Plageman <melanieplageman(at)gmail(dot)com> wrote: > > The last vacuum is expected to set vm bits, but the test doesn’t verify that. Should we verify that like: > > ``` > > evantest=# SELECT blkno, all_visible, all_frozen FROM pg_visibility_map('test_vac_unmodified_heap'); > > blkno | all_visible | all_frozen > > -------+-------------+------------ > > 0 | t | t > > (1 row) > I've done this. I've actually added three such verifications -- one > after each step where the VM is expected to change. It shouldn't be > very expensive, so I think it is okay. The way the test would fail if > the buffer wasn't correctly dirtied is that it would assert out -- so > the visibility map test wouldn't even have a chance to fail. But, I > think it is also okay to confirm that the expected things are > happening with the VM -- it just gives us extra coverage. +1 on extra coverage. Should we also do sql-level check that the VM indeed does not need to set PD_ALL_VISIBLE (check header bytes using pageinspect?). v27-0003 & v27-0004: I did not get the exact reason we introduced `identify_and_fix_vm_corruption` in 0003 and moved code in 0004 to another place. I can see we have this starting v25 of patch set. Well, maybe this is not an issue at all... in v27-0005. This patch changes code which is not exercised in tests[0]. I spent some time understanding the conditions when we entered this. There is a comment about non-finished relation extension, but I got no success trying to reproduce this. I ended up modifying code to lose PageSetAllVisible in proper places and running vacuum. Looks like everything works as expected. I will spend some more time on this, maybe I will be successful in writing an injection-point-based TAP test which hits this... [0] https://coverage.postgresql.org/src/backend/access/heap/vacuumlazy.c.gcov.html#1902 -- Best regards, Kirill Reshke -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-18T00:30:01Z
Thanks for the review! In addition to addressing your feedback, attached v28 includes a number of small fixes to comments, commit messages, and other things. Notably, I've added one new refactoring patch 0009, which reduces the diff of 0010 -- using the GlobalVisState instead of OldestXmin for page visibility -- even further. On Wed, Dec 17, 2025 at 1:27 PM Kirill Reshke <reshkekirill@gmail.com> wrote: > > > I've done this. I've actually added three such verifications -- one > > after each step where the VM is expected to change. It shouldn't be > > very expensive, so I think it is okay. The way the test would fail if > > the buffer wasn't correctly dirtied is that it would assert out -- so > > the visibility map test wouldn't even have a chance to fail. But, I > > think it is also okay to confirm that the expected things are > > happening with the VM -- it just gives us extra coverage. > > +1 on extra coverage. Should we also do sql-level check that the VM > indeed does not need to set PD_ALL_VISIBLE (check header bytes using > pageinspect?). That's an interesting idea. I checked and, AFAICT, there are no tests currently directly comparing the flags column returned by the pageinspect page_header() function to one of the flag values. I've added the following to attached v28. SELECT (flags & x'0004'::int) <> 0 FROM page_header(get_raw_page('test_vac_unmodified_heap', 0)); But I'm not sure if it is weird/confusing to be comparing the flag directly to the number 4 like this. I don't really want to bother with adding another function to pageinspect returning the status of PD_ALL_VISIBLE (like page_visible() or something). > v27-0003 & v27-0004: I did not get the exact reason we introduced > `identify_and_fix_vm_corruption` in 0003 and moved code in 0004 to > another place. I can see we have this starting v25 of patch set. Well, > maybe this is not an issue at all... It's mostly for ease of review. This is a pretty sensitive area of code, so I thought it would be easier for the reviewer to confirm correctness if I split it up. Andres had mentioned that the commit was hard to review because so many different things were happening. In v27, 0003 moves the VM clear code into a helper. 0004 and 0005 moves all the VM setting/clearing code to heap_page_prune_and_freeze(). And 0006 actually sets the VM in the same critical section as pruning/freezing and emits a single WAL record. I'm not really sure which commits should stay independent in the final version I push to master. > in v27-0005. This patch changes code which is not exercised in > tests[0]. I spent some time understanding the conditions when we > entered this. There is a comment about non-finished relation > extension, but I got no success trying to reproduce this. I ended up > modifying code to lose PageSetAllVisible in proper places and running > vacuum. Looks like everything works as expected. I will spend some > more time on this, maybe I will be successful in writing an > injection-point-based TAP test which hits this... Based on the coverage report link you provided, that code is changed by v27 0007, not 0005. 0005 is about moving an assertion out of lazy_scan_prune(). 0007 changes lazy_scan_new_or_empty() (the code in question). Regarding 0007, it looks like what is uncovered (the orange bits in the coverage report are uncovered, I assume) is empty pages _without_ PD_ALL_VISIBLE set. I don't see anywhere where PageSetAllVisible() is called except vacuum and COPY FREEZE. If I was trying to guess how empty pages with PD_ALL_VISIBLE set are getting vacuumed, I would think it is due to SKIP_PAGES_THRESHOLD causing us to vacuum an all-frozen empty page. Then the question is, why wouldn't we have coverage of the empty page first being set all-visible/all-frozen? It can't be COPY FREEZE because the page is empty. And it can't be vacuum, because then we would have coverage. It's very mysterious. It would be good to have coverage for this case. I don't think you'll need an injection point for the main case of "empty page not yet set all-visible is vacuumed for the first time" (unless I'm misunderstanding something). I'm not sure how you'll test the "vacuuming an empty, previously uninitialized page" case described in this comment, though. * It's possible that another backend has extended the heap, * initialized the page, and then failed to WAL-log the page due * to an ERROR. Since heap extension is not WAL-logged, recovery * might try to replay our record setting the page all-visible and * find that the page isn't initialized, which will cause a PANIC. * To prevent that, check whether the page has been previously * WAL-logged, and if not, do that now. You'd want to force an error during relation extension and then vacuum the page. I don't know if you need an injection point to force the error -- depends on what kind of error, I think. So that I know for attribution, did you review 0003-0005? - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-12-18T08:55:46Z
On Thu, 18 Dec 2025 at 05:30, Melanie Plageman <melanieplageman@gmail.com> wrote: > > > in v27-0005. This patch changes code which is not exercised in > > tests[0]. I spent some time understanding the conditions when we > > entered this. There is a comment about non-finished relation > > extension, but I got no success trying to reproduce this. I ended up > > modifying code to lose PageSetAllVisible in proper places and running > > vacuum. Looks like everything works as expected. I will spend some > > more time on this, maybe I will be successful in writing an > > injection-point-based TAP test which hits this... > > Based on the coverage report link you provided, that code is changed > by v27 0007, not 0005. 0005 is about moving an assertion out of > lazy_scan_prune(). 0007 changes lazy_scan_new_or_empty() (the code in > question). > > Regarding 0007, it looks like what is uncovered (the orange bits in > the coverage report are uncovered, I assume) is empty pages _without_ > PD_ALL_VISIBLE set. I don't see anywhere where PageSetAllVisible() is > called except vacuum and COPY FREEZE. Sure, I meant 0007. > If I was trying to guess how empty pages with PD_ALL_VISIBLE set are > getting vacuumed, I would think it is due to SKIP_PAGES_THRESHOLD > causing us to vacuum an all-frozen empty page. Yes, vacuum (disable_page_skipping); > Then the question is, why wouldn't we have coverage of the empty page > first being set all-visible/all-frozen? It can't be COPY FREEZE > because the page is empty. And it can't be vacuum, because then we > would have coverage. It's very mysterious. > > It would be good to have coverage for this case. I don't think you'll > need an injection point for the main case of "empty page not yet set > all-visible is vacuumed for the first time" (unless I'm > misunderstanding something). > > I'm not sure how you'll test the "vacuuming an empty, previously > uninitialized page" case described in this comment, though. > > * It's possible that another backend has extended the heap, > * initialized the page, and then failed to WAL-log the page due > * to an ERROR. Since heap extension is not WAL-logged, recovery > * might try to replay our record setting the page all-visible and > * find that the page isn't initialized, which will cause a PANIC. > * To prevent that, check whether the page has been previously > * WAL-logged, and if not, do that now. > > You'd want to force an error during relation extension and then vacuum > the page. I don't know if you need an injection point to force the > error -- depends on what kind of error, I think. I did small archeology and this "if (PageIsEmpty(page)) { if (!PageIsAllVisible(page)) { .... }}" code originates back to 608195a3a365. Comment about not WAL-logged relation extension is from a6370fd9ed3d, and I don't think we need to think about this case. I am currently inclined to think that we cannot see an empty page that has PD_ALL_VISIBLE not-set. This is because when we make a page empty, we are in a critical section, and we WAL-log everything we do, so our changes should not be half-made. Maybe as of 608195a3a365, there was a case with empry-page-without-PD_ALL_VISIBLE, but I dont think this happens on HEAD. > So that I know for attribution, did you review 0003-0005? yes, but I did not have any valuable review points for them. Also, after the whole set is committed, we should then never experience discrepancy between PD_ALL_VISIBLE and VM bits? Because they will be set in a single WAL record. The only cases when heap and VM disagrees on all-visibility then are corruption, pg_visibilitymap_truncate and old data (data before v19+ upgrade?) If my understanding is correct, should we add document this? -- Best regards, Kirill Reshke -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-18T15:18:09Z
On Thu, Dec 18, 2025 at 3:55 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > On Thu, 18 Dec 2025 at 05:30, Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > If I was trying to guess how empty pages with PD_ALL_VISIBLE set are > > getting vacuumed, I would think it is due to SKIP_PAGES_THRESHOLD > > causing us to vacuum an all-frozen empty page. > > Yes, vacuum (disable_page_skipping); Ah, right, that would be a reliable way for it to happen. > > Then the question is, why wouldn't we have coverage of the empty page > > first being set all-visible/all-frozen? It can't be COPY FREEZE > > because the page is empty. And it can't be vacuum, because then we > > would have coverage. It's very mysterious. <--snip--> > I am currently inclined to think that we cannot see an empty page that > has PD_ALL_VISIBLE not-set. This is because when we make a page empty, > we are in a critical section, and we WAL-log everything we do, so our > changes should not be half-made. Maybe as of 608195a3a365, there was a > case with empry-page-without-PD_ALL_VISIBLE, but I dont think this > happens on HEAD. Right, so the way that empty pages get set PD_ALL_VISIBLE is when a page has all its tuples deleted, the next time it is vacuumed it will be set all-visible and all-frozen and have PD_ALL_VISIBLE set. (if it's a trailing page it will be truncated, but any non-trailing page will be like this). But you are right, I don't see any non-error code path where a heap page would become empty (all line pointers set unused) and then not be set all-visible. Only vacuum sets line pointers unused and if all the line pointers are unused it will always set the page all-visible. I think, though, that if we error out in lazy_scan_prune() after returning from heap_page_prune_and_freeze() such that we don't set the empty page all-visible, we can end up with an empty page without PD_ALL_VISIBLE set. You can see how this might work by patching the VM set code in lazy_scan_prune() to skip empty pages. > I did small archeology and this "if (PageIsEmpty(page)) { if > (!PageIsAllVisible(page)) { .... }}" code originates back to > 608195a3a365. Comment about not WAL-logged relation extension is from > a6370fd9ed3d, and I don't think we need to think about this case. Thanks for looking into this. Even if this code was added to handle the error codepath I mentioned above, it seems like it would have been good enough to just let lazy_scan_prune() handle setting the empty page all-visible the next time the page was vacuumed. Since there is no non-error code path where this can happen, it doesn't seem like it would merit its own special case. It is possible it was more common as of 608195a3a365, as you say. I don't understand how the bug fixed by a6370fd9ed3d can happen. When a new page is initialized, flags are set to 0, so regardless of WAL logging of the extension not happening, how would the new page have been set PD_ALL_VISIBLE? We'll have to ask Andres or Robert about how this was hit. > Also, after the whole set is committed, we should then never > experience discrepancy between PD_ALL_VISIBLE and VM bits? Because > they will be set in a single WAL record. The only cases when heap and > VM disagrees on all-visibility then are corruption, > pg_visibilitymap_truncate and old data (data before v19+ upgrade?) > If my understanding is correct, should we add document this? Even on current master, I don't see a scenario other than VM corruption or truncation where PD_ALL_VISIBLE can be set but not the VM (or vice versa). The only way would be if you error out after setting PD_ALL_VISIBLE before setting the VM. Setting PD_ALL_VISIBLE is not in a critical section in lazy_scan_prune(), so it won't panic and dump shared memory, so the buffer with PD_ALL_VISIBLE set may later get written out. But the only obvious way I see to error out of MarkBufferDirty() is if the buffer is not valid -- which would have kept us from doing previous operations on the buffer, I would think. It's true this will no longer happen after my patches, as PageSetAllVisible() will happen in a critical section. We could add a comment about this particular scenario in the code somewhere. But I don't think we should document it in any user-facing documentation since you could still truncate the VM and have the two out of sync. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-12-18T15:45:58Z
On Thu, 18 Dec 2025 at 20:18, Melanie Plageman <melanieplageman@gmail.com> wrote: > > Also, after the whole set is committed, we should then never > > experience discrepancy between PD_ALL_VISIBLE and VM bits? Because > > they will be set in a single WAL record. The only cases when heap and > > VM disagrees on all-visibility then are corruption, > > pg_visibilitymap_truncate and old data (data before v19+ upgrade?) > > If my understanding is correct, should we add document this? > > Even on current master, I don't see a scenario other than VM > corruption or truncation where PD_ALL_VISIBLE can be set but not the > VM (or vice versa). The only way would be if you error out after > setting PD_ALL_VISIBLE before setting the VM. Setting PD_ALL_VISIBLE > is not in a critical section in lazy_scan_prune(), so it won't panic > and dump shared memory, so the buffer with PD_ALL_VISIBLE set may > later get written out. But the only obvious way I see to error out of > MarkBufferDirty() is if the buffer is not valid -- which would have > kept us from doing previous operations on the buffer, I would think. > Well... I may be missing something, but on current HEAD, XLOG_HEAP2_PRUNE_VACUUM_SCAN and XLOG_HEAP2_VISIBLE are two different record, XLOG_HEAP2_PRUNE_VACUUM_SCAN being always emitted first. So, WAL writer may end up kill-9-ed just after XLOG_HEAP2_PRUNE_VACUUM_SCAN makes it to the disk, and XLOG_HEAP2_VISIBLE never. Crash recovery then, and we have discrepancy. This does not happen with a single WAL record. Another simple reproducer here: standby streaming, receiving XLOG_HEAP2_PRUNE_VACUUM_SCAN from primary, Then network becomes bad, and we never get XLOG_HEAP2_VISIBLE from primary. Then we promoted by the admin. And again, VM bit vs PD_ALL_VISIBLE discrepancy. Am I missing something? -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-12-18T18:07:20Z
On Thu, 18 Dec 2025 at 20:18, Melanie Plageman <melanieplageman@gmail.com> wrote: > But you are right, I don't see any non-error code path where a heap > page would become empty (all line pointers set unused) and then not be > set all-visible. Only vacuum sets line pointers unused and if all the > line pointers are unused it will always set the page all-visible. > > I think, though, that if we error out in lazy_scan_prune() after > returning from heap_page_prune_and_freeze() such that we don't set the > empty page all-visible, we can end up with an empty page without > PD_ALL_VISIBLE set. You can see how this might work by patching the VM > set code in lazy_scan_prune() to skip empty pages. > Thank you for your explanation! I completely forgot that PD_ALL_VIS is a non-persistent change (hint bit). so its update can be trivially lost. The simplest real-life example is being killed just after returning from heap_page_prune_and_freeze, yes. PFA tap test covering lazy_scan_new_or_empty code path for empty-but-not-all-visible page -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-18T19:57:57Z
On Thu, Dec 18, 2025 at 1:07 PM Kirill Reshke <reshkekirill@gmail.com> wrote: > > On Thu, 18 Dec 2025 at 20:18, Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > But you are right, I don't see any non-error code path where a heap > > page would become empty (all line pointers set unused) and then not be > > set all-visible. Only vacuum sets line pointers unused and if all the > > line pointers are unused it will always set the page all-visible. > > > > I think, though, that if we error out in lazy_scan_prune() after > > returning from heap_page_prune_and_freeze() such that we don't set the > > empty page all-visible, we can end up with an empty page without > > PD_ALL_VISIBLE set. You can see how this might work by patching the VM > > set code in lazy_scan_prune() to skip empty pages. > > Thank you for your explanation! I completely forgot that PD_ALL_VIS > is a non-persistent change (hint bit). so its update can be trivially > lost. > The simplest real-life example is being killed just after returning > from heap_page_prune_and_freeze, yes. > PFA tap test covering lazy_scan_new_or_empty code path for > empty-but-not-all-visible page Cool test! I'm going to have to think more about whether or not it is worth adding a whole new TAP test for this codepath. Is there an existing TAP test we could add it to so we don't need to make a new cluster, etc? How long does the test take to run? Obviously it will be quite short, but every bit we add to the test suite counts. I don't actually know how much overhead there is with injection points. I was chatting with Andres and he mentioned there is one other case where you can end up in this code path (empty page without PD_ALL_VISIBLE set) and this case does actually trigger this code: if (RelationNeedsWAL(vacrel->rel) && !XLogRecPtrIsValid(PageGetLSN(page))) log_newpage_buffer(buf, true); If you are inserting to a new page and you successfully call PageInit() (making the page no longer considered new by PageIsNew() because pd_upper will be set) but you error out before actually inserting the tuple, then you will have an empty page without PD_ALL_VISIBLE set. And assuming you error out before emitting WAL, the page will not have a valid LSN set. So you will hit that code which calls log_newpage_buffer(). I would say this case is so narrow (the log_newpage_buffer() codepath in lazy_scan_new_or_empty()), it's not worth the added test overhead, but I just wanted to share what I learned about when this code could be hit. Previously it was more common in the bulk extension case to have empty pages not set PD_ALL_VISIBLE because bulk extension would call PageInit() on all of the pages it extended so all the pages except the target page were empty (today they are not initialized so they go into the PageIsNew() branch). So, in both cases, it seems like the empty page not set PD_ALL_VISIBLE mostly only hit if we previously errored out. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-18T20:04:34Z
On Thu, Dec 18, 2025 at 10:46 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > On Thu, 18 Dec 2025 at 20:18, Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > Also, after the whole set is committed, we should then never > > > experience discrepancy between PD_ALL_VISIBLE and VM bits? Because > > > they will be set in a single WAL record. The only cases when heap and > > > VM disagrees on all-visibility then are corruption, > > > pg_visibilitymap_truncate and old data (data before v19+ upgrade?) > > > If my understanding is correct, should we add document this? > > > > Even on current master, I don't see a scenario other than VM > > corruption or truncation where PD_ALL_VISIBLE can be set but not the > > VM (or vice versa). The only way would be if you error out after > > setting PD_ALL_VISIBLE before setting the VM. Setting PD_ALL_VISIBLE > > is not in a critical section in lazy_scan_prune(), so it won't panic > > and dump shared memory, so the buffer with PD_ALL_VISIBLE set may > > later get written out. But the only obvious way I see to error out of > > MarkBufferDirty() is if the buffer is not valid -- which would have > > kept us from doing previous operations on the buffer, I would think. > > Well... I may be missing something, but on current HEAD, > XLOG_HEAP2_PRUNE_VACUUM_SCAN and XLOG_HEAP2_VISIBLE are two different > record, XLOG_HEAP2_PRUNE_VACUUM_SCAN being always emitted first. So, > WAL writer may end up kill-9-ed just after > XLOG_HEAP2_PRUNE_VACUUM_SCAN makes it to the disk, and > XLOG_HEAP2_VISIBLE never. Crash recovery then, and we have > discrepancy. This does not happen with a single WAL record. > Another simple reproducer here: standby streaming, receiving > XLOG_HEAP2_PRUNE_VACUUM_SCAN from primary, Then network becomes bad, > and we never get XLOG_HEAP2_VISIBLE from primary. Then we promoted by > the admin. And again, VM bit vs PD_ALL_VISIBLE discrepancy. Am I > missing something? Well, currently XLOG_HEAP2_PRUNE_VACUUM_SCAN doesn't set PD_ALL_VISIBLE. PD_ALL_VISIBLE is WAL-logged in the XLOG_HEAP2_VISIBLE record because in lazy_scan_prune() we call PageSetAllVisible() and then visibilitymap_set() -> log_heap_visible() adds the heap buffer to the WAL chain (with XLogRegisterBuffer()). And if you notice when XLOG_HEAP2_VISIBLE is replayed in heap_xlog_visible(), that is where we do PageSetAllVisible() on the heap page. So I think you can end up with PD_ALL_VISIBLE set if you error out precisely between setting it and WAL logging it because we don't set it in a critical section. But you can't end up with a WAL record that sets PD_ALL_VISIBLE and another one that sets the VM. Once we have my code changes, you can never end up with PD_ALL_VISIBLE set and the VM not set because they are in the same critical section and if we error out, it will cause a panic which will purge shared memory. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-12-18T20:31:27Z
On Fri, 19 Dec 2025 at 00:58, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Thu, Dec 18, 2025 at 1:07 PM Kirill Reshke <reshkekirill@gmail.com> wrote: > > > > On Thu, 18 Dec 2025 at 20:18, Melanie Plageman > > <melanieplageman@gmail.com> wrote: > > > > > But you are right, I don't see any non-error code path where a heap > > > page would become empty (all line pointers set unused) and then not be > > > set all-visible. Only vacuum sets line pointers unused and if all the > > > line pointers are unused it will always set the page all-visible. > > > > > > I think, though, that if we error out in lazy_scan_prune() after > > > returning from heap_page_prune_and_freeze() such that we don't set the > > > empty page all-visible, we can end up with an empty page without > > > PD_ALL_VISIBLE set. You can see how this might work by patching the VM > > > set code in lazy_scan_prune() to skip empty pages. > > > > Thank you for your explanation! I completely forgot that PD_ALL_VIS > > is a non-persistent change (hint bit). so its update can be trivially > > lost. > > The simplest real-life example is being killed just after returning > > from heap_page_prune_and_freeze, yes. > > PFA tap test covering lazy_scan_new_or_empty code path for > > empty-but-not-all-visible page > > Cool test! I'm going to have to think more about whether or not it is > worth adding a whole new TAP test for this codepath. Is there an > existing TAP test we could add it to so we don't need to make a new > cluster, etc? How long does the test take to run? Obviously it will be > quite short, but every bit we add to the test suite counts. I don't > actually know how much overhead there is with injection points. > Well, on my pc this test runs in ~1.5 sec. I did not find any other TAP test to place this, so created a new. Actually, I only check for specific patterns in the log file of the cluster in this test, so this test can instead be a regression test. ``` reshke=# VACUUM (DISABLE_PAGE_SKIPPING) vac_empty_test; NOTICE: notice triggered for injection point vacuum-empty-page-non-all-vis VACUUM reshke=# ``` We will just check in the .out file that the code hits 'vacuum-empty-page-non-all-vis' after an error. injection points overhead should not be that awful, just from my experience. Maybe buildfarm members can say something here, I dunno. Also, we already have a bunch of regression+inj point tests for some rare cases, exempli gratia src/test/modules/nbtree/sql/nbtree_half_dead_pages.sql. -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Xuneng Zhou <xunengzhou@gmail.com> — 2025-12-19T03:38:24Z
He Melanie, Thanks for working on this. On Wed, Dec 17, 2025 at 12:59 AM Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Wed, Dec 3, 2025 at 6:07 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > > If we're just talking about the renaming, looking at procarray.c, it > > is full of the word "removable" because its functions were largely > > used to examine and determine if everyone can see an xmax as committed > > and thus if that tuple is removable from their perspective. But > > nothing about the code that I can see means it has to be an xmax. We > > could just as well use the functions to determine if everyone can see > > an xmin as committed. > > In the attached v27, I've removed the commit that renamed functions in > procarray.c. I've added a single wrapper GlobalVisTestXidNotRunning() > that is used in my code where I am testing live tuples. I think you'll > find that I've addressed all of your review comments now -- as I've > also gotten rid of the confusing blk_known_av logic through a series > of refactors. > > The one outstanding point is which commits should bump > XLOG_PAGE_MAGIC. (also review of the reworked patches). > > - Melanie I’ve done a basic review of patches 1 and 2. Here are some comments which may be somewhat immature, as this is a fairly large change set and I’m new to some parts of the code. 1) Potential stale old_vmbits after VM repair n v2 // Corruption check 1 if (!PageIsAllVisible(page) && (old_vmbits & VISIBILITYMAP_ALL_VISIBLE) != 0) { visibilitymap_clear(...); // VM now cleared to 0 // but old_vmbits still holds ALL_VISIBLE } // ... later ... if (!presult.all_visible) return presult.ndeleted; // Not taken if presult.all_visible=true new_vmbits = VISIBILITYMAP_ALL_VISIBLE; // Want to set this if (old_vmbits == new_vmbits) // Stale old_vmbits=ALL_VISIBLE, new_vmbits=ALL_VISIBLE return presult.ndeleted; // issue: early return After corruption repair clears the VM, old_vmbits is stale. The early return can fire unexpectedly, leaving the VM cleared when it should be re-set. Should we reset old_vmbits = 0 after the visibilitymap_clear? 2) Add Assert(BufferIsDirty(buf)) Since the patch's core claim is "buffer must be dirty before WAL registration", an assertion encodes this invariant. Should we add: Assert(BufferIsValid(buf)); Assert(BufferIsDirty(buf)); right before the visibilitymap_set() call? 3) Comment about "only scenario" The comment at lines: > "The only scenario where it is not already dirty is if the VM was removed…" This phrasing could become misleading after future refactors. Can we make it more direct like: > "We must mark the heap buffer dirty before calling visibilitymap_set(), because it may WAL-log the buffer and XLogRegisterBuffer() requires it." 4) Comment clarity Current comment: > "Even if PD_ALL_VISIBLE is already set, we don't need to worry about unnecessarily dirtying the heap buffer, as it must be marked dirty before adding it to the WAL chain. The only scenario where it is not already dirty is if the VM was removed..." In this test we now call MarkBufferDirty() on the heap page even when only setting the VM, so the comments claiming “does not need to modify the heap buffer”/“no heap page modification” might be misleading. It might be better to say the test doesn’t need to modify heap tuples/page contents or doesn’t need to prune/freeze. -- Best, Xuneng -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-19T21:09:47Z
Attached v29 addresses some feedback and also corrects a small error with the assertion I had added in the previous version's 0009. On Thu, Dec 18, 2025 at 10:38 PM Xuneng Zhou <xunengzhou@gmail.com> wrote: > > I’ve done a basic review of patches 1 and 2. Here are some comments > which may be somewhat immature, as this is a fairly large change set > and I’m new to some parts of the code. > > 1) Potential stale old_vmbits after VM repair n v2 Good catch! I've fixed this in attached v29. > 2) Add Assert(BufferIsDirty(buf)) > > Since the patch's core claim is "buffer must be dirty before WAL > registration", an assertion encodes this invariant. Should we add: > > Assert(BufferIsValid(buf)); > Assert(BufferIsDirty(buf)); > > right before the visibilitymap_set() call? There are already assertions that will trip in various places -- most importantly in XLogRegisterBuffer(), which is the one that inspired this refactor. > The comment at lines: > > "The only scenario where it is not already dirty is if the VM was removed…" > > This phrasing could become misleading after future refactors. Can we > make it more direct like: > > > "We must mark the heap buffer dirty before calling visibilitymap_set(), because it may WAL-log the buffer and XLogRegisterBuffer() requires it." I see your point about future refactors missing updating comments like this. But, I don't think we are going to refactor the code such that we can have PD_ALL_VISIBLE set without the VM bits set more often. Also, it is common practice in Postgres to describe very specific edge cases or odd scenarios in order to explain code that may seem confusing without the comment. It does risk that comment later becoming stale, but it is better that future developers understand why the code is there. That being said, I take your point that the comment is confusing. I have updated it in a different way. > > "Even if PD_ALL_VISIBLE is already set, we don't need to worry about unnecessarily dirtying the heap buffer, as it must be marked dirty before adding it to the WAL chain. The only scenario where it is not already dirty is if the VM was removed..." > > In this test we now call MarkBufferDirty() on the heap page even when > only setting the VM, so the comments claiming “does not need to modify > the heap buffer”/“no heap page modification” might be misleading. It > might be better to say the test doesn’t need to modify heap > tuples/page contents or doesn’t need to prune/freeze. The point I'm trying to make is that we have to dirty the buffer even if we don't modify the page because of the XLOG sub-system requirements. And, it may seem like a waste to do that if not modifying the page, but the page will rarely be clean anyway. I've tried to make this more clear in attached v29. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2025-12-20T12:32:38Z
On Sat, 20 Dec 2025 at 02:10, Melanie Plageman <melanieplageman@gmail.com> wrote: > > Attached v29 addresses some feedback and also corrects a small error > with the assertion I had added in the previous version's 0009. > > On Thu, Dec 18, 2025 at 10:38 PM Xuneng Zhou <xunengzhou@gmail.com> wrote: > > > > I’ve done a basic review of patches 1 and 2. Here are some comments > > which may be somewhat immature, as this is a fairly large change set > > and I’m new to some parts of the code. > > > > 1) Potential stale old_vmbits after VM repair n v2 > > Good catch! I've fixed this in attached v29. > > > 2) Add Assert(BufferIsDirty(buf)) > > > > Since the patch's core claim is "buffer must be dirty before WAL > > registration", an assertion encodes this invariant. Should we add: > > > > Assert(BufferIsValid(buf)); > > Assert(BufferIsDirty(buf)); > > > > right before the visibilitymap_set() call? > > There are already assertions that will trip in various places -- most > importantly in XLogRegisterBuffer(), which is the one that inspired > this refactor. > > > The comment at lines: > > > "The only scenario where it is not already dirty is if the VM was removed…" > > > > This phrasing could become misleading after future refactors. Can we > > make it more direct like: > > > > > "We must mark the heap buffer dirty before calling visibilitymap_set(), because it may WAL-log the buffer and XLogRegisterBuffer() requires it." > > I see your point about future refactors missing updating comments like > this. But, I don't think we are going to refactor the code such that > we can have PD_ALL_VISIBLE set without the VM bits set more often. > Also, it is common practice in Postgres to describe very specific edge > cases or odd scenarios in order to explain code that may seem > confusing without the comment. It does risk that comment later > becoming stale, but it is better that future developers understand why > the code is there. > > That being said, I take your point that the comment is confusing. I > have updated it in a different way. > > > > "Even if PD_ALL_VISIBLE is already set, we don't need to worry about unnecessarily dirtying the heap buffer, as it must be marked dirty before adding it to the WAL chain. The only scenario where it is not already dirty is if the VM was removed..." > > > > In this test we now call MarkBufferDirty() on the heap page even when > > only setting the VM, so the comments claiming “does not need to modify > > the heap buffer”/“no heap page modification” might be misleading. It > > might be better to say the test doesn’t need to modify heap > > tuples/page contents or doesn’t need to prune/freeze. > > The point I'm trying to make is that we have to dirty the buffer even > if we don't modify the page because of the XLOG sub-system > requirements. And, it may seem like a waste to do that if not > modifying the page, but the page will rarely be clean anyway. I've > tried to make this more clear in attached v29. > > - Melanie Hi! I checked v29-0009, about HeapTupleSatisfiesVacuumHorizon. Origins of this code track down to fdf9e21196a6 which was committed as part of [0], at which point there was no HeapTupleSatisfiesVacuumHorizon function. I guess this is the reason this optimization was not performed earlier. I also think this patch is correct, because we do similar things for HEAPTUPLE_DEAD & HEAPTUPLE_RECENTLY_DEAD, and HeapTupleSatisfiesVacuumHorizon is just a proxy to HeapTupleSatisfiesVacuumHorizon with only difference in DEAD VS RECENTLY_DEAD handling. Similar change could be done at heapam_scan_analyze_next_tuple ... case HEAPTUPLE_DEAD: case HEAPTUPLE_RECENTLY_DEAD: /* Count dead and recently-dead rows */ *deadrows += 1; break; ... [0] https://www.postgresql.org/message-id/CABOikdP0meGuXPPWuYrP%3DvDvoqUdshF2xJAzZHWSKg03Rz_%2B9Q%40mail.gmail.com -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2025-12-22T07:19:39Z
> On Dec 20, 2025, at 05:09, Melanie Plageman <melanieplageman@gmail.com> wrote: > > Attached v29 addresses some feedback and also corrects a small error > with the assertion I had added in the previous version's 0009. > > On Thu, Dec 18, 2025 at 10:38 PM Xuneng Zhou <xunengzhou@gmail.com> wrote: >> >> I’ve done a basic review of patches 1 and 2. Here are some comments >> which may be somewhat immature, as this is a fairly large change set >> and I’m new to some parts of the code. >> >> 1) Potential stale old_vmbits after VM repair n v2 > > Good catch! I've fixed this in attached v29. > >> 2) Add Assert(BufferIsDirty(buf)) >> >> Since the patch's core claim is "buffer must be dirty before WAL >> registration", an assertion encodes this invariant. Should we add: >> >> Assert(BufferIsValid(buf)); >> Assert(BufferIsDirty(buf)); >> >> right before the visibilitymap_set() call? > > There are already assertions that will trip in various places -- most > importantly in XLogRegisterBuffer(), which is the one that inspired > this refactor. > >> The comment at lines: >>> "The only scenario where it is not already dirty is if the VM was removed…" >> >> This phrasing could become misleading after future refactors. Can we >> make it more direct like: >> >>> "We must mark the heap buffer dirty before calling visibilitymap_set(), because it may WAL-log the buffer and XLogRegisterBuffer() requires it." > > I see your point about future refactors missing updating comments like > this. But, I don't think we are going to refactor the code such that > we can have PD_ALL_VISIBLE set without the VM bits set more often. > Also, it is common practice in Postgres to describe very specific edge > cases or odd scenarios in order to explain code that may seem > confusing without the comment. It does risk that comment later > becoming stale, but it is better that future developers understand why > the code is there. > > That being said, I take your point that the comment is confusing. I > have updated it in a different way. > >>> "Even if PD_ALL_VISIBLE is already set, we don't need to worry about unnecessarily dirtying the heap buffer, as it must be marked dirty before adding it to the WAL chain. The only scenario where it is not already dirty is if the VM was removed..." >> >> In this test we now call MarkBufferDirty() on the heap page even when >> only setting the VM, so the comments claiming “does not need to modify >> the heap buffer”/“no heap page modification” might be misleading. It >> might be better to say the test doesn’t need to modify heap >> tuples/page contents or doesn’t need to prune/freeze. > > The point I'm trying to make is that we have to dirty the buffer even > if we don't modify the page because of the XLOG sub-system > requirements. And, it may seem like a waste to do that if not > modifying the page, but the page will rarely be clean anyway. I've > tried to make this more clear in attached v29. > > - Melanie > <v29-0001-Combine-visibilitymap_set-cases-in-lazy_scan_pru.patch><v29-0002-Eliminate-use-of-cached-VM-value-in-lazy_scan_pr.patch><v29-0003-Refactor-lazy_scan_prune-VM-clear-logic-into-hel.patch><v29-0004-Set-the-VM-in-heap_page_prune_and_freeze.patch><v29-0005-Move-VM-assert-into-prune-freeze-code.patch><v29-0006-Eliminate-XLOG_HEAP2_VISIBLE-from-vacuum-phase-I.patch><v29-0007-Eliminate-XLOG_HEAP2_VISIBLE-from-empty-page-vac.patch><v29-0008-Remove-XLOG_HEAP2_VISIBLE-entirely.patch><v29-0009-Simplify-heap_page_would_be_all_visible-visibili.patch><v29-0010-Use-GlobalVisState-in-vacuum-to-determine-page-l.patch><v29-0011-Unset-all_visible-sooner-if-not-freezing.patch><v29-0012-Track-which-relations-are-modified-by-a-query.patch><v29-0013-Pass-down-information-on-table-modification-to-s.patch><v29-0014-Allow-on-access-pruning-to-set-pages-all-visible.patch><v29-0015-Set-pd_prune_xid-on-insert.patch> A few more comments on v29: 1 - 0002 - Looks like since 0002, visibilitymap_set()’s return value is no longer used, so do we need to update the function and change return type to void? I remember in some patches, to address Coverity alerts, people had to do “(void) function_with_a_return_value()”. 2 - 0003 ``` + * Helper to correct any corruption detected on an heap page and its ``` Nit: “an” -> “a” 3 - 0003 ``` +static bool +identify_and_fix_vm_corruption(Relation rel, Buffer heap_buffer, + BlockNumber heap_blk, Page heap_page, + int nlpdead_items, + Buffer vmbuffer, + uint8 vmbits) +{ + Assert(visibilitymap_get_status(rel, heap_blk, &vmbuffer) == vmbits); ``` Right before this function is called: ``` old_vmbits = visibilitymap_get_status(vacrel->rel, blkno, &vmbuffer); + if (identify_and_fix_vm_corruption(vacrel->rel, buf, blkno, page, + presult.lpdead_items, vmbuffer, + old_vmbits)) ``` So, the Assert() is checking if old_vmbits is newly returned from visibilitymap_get_status(), in that case, identify_and_fix_vm_corruption() can take vmbits as a pointer , and it calls visibilitymap_get_status() to get vmbits itself and returns vmbits via the pointer, so that we don’t need to call visibilitymap_get_status() twice. 4 - 0004 ``` + * These are only set if the HEAP_PAGE_PRUNE_UPDATE_VM option is set and + * we have attempted to update the VM. + */ + uint8 new_vmbits; + uint8 old_vmbits; ``` The comment feels a little confusing to me. "HEAP_PAGE_PRUNE_UPDATE_VM option is set” is a clear indication, but how to decide "we have attempted to update the VM”? By reading the code: ``` + prstate->attempt_update_vm = + (params->options & HEAP_PAGE_PRUNE_UPDATE_VM) != 0; ``` It’s just the result of HEAP_PAGE_PRUNE_UPDATE_VM being set. So, maybe we don’t the “and” part. 5 - 0004 ``` + * Returns true if one or both VM bits should be set, along with returning the + * current value of the VM bits in *old_vmbits and the desired new value of + * the VM bits in *new_vmbits. + */ +static bool +heap_page_will_set_vm(PruneState *prstate, + Relation relation, + BlockNumber heap_blk, Buffer heap_buffer, Page heap_page, + Buffer vmbuffer, + int nlpdead_items, + uint8 *old_vmbits, + uint8 *new_vmbits) +{ + if (!prstate->attempt_update_vm) + return false; ``` old_vmbits and new_vmbits are purely output parameters. So, maybe we should set them to 0 inside this function instead of relying on callers to initialize them. I think this is a similar case where I raised a comment earlier about initializing presult to {0} in the callers, and you only wanted to set presult in heap_page_prune_and_freeze(). 6 - 0004 ``` @@ -823,13 +975,19 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, MultiXactId *new_relmin_mxid) { Buffer buffer = params->buffer; + Buffer vmbuffer = params->vmbuffer; Page page = BufferGetPage(buffer); + BlockNumber blockno = BufferGetBlockNumber(buffer); PruneState prstate; bool do_freeze; bool do_prune; bool do_hint_prune; + bool do_set_vm; bool did_tuple_hint_fpi; int64 fpi_before = pgWalUsage.wal_fpi; + uint8 new_vmbits = 0; + uint8 old_vmbits = 0; + /* Initialize prstate */ ``` Nit: an extra empty line is added. 7 - 0005 ``` - * HEAP_PAGE_PRUNE_FREEZE indicates that we will also freeze tuples, and - * will return 'all_visible', 'all_frozen' flags to the caller. + * HEAP_PAGE_PRUNE_FREEZE indicates that we will also freeze tuples ``` Nit: a tailing dot is needed in the end of the comment line. 8 - 0005 ``` @@ -978,6 +1003,7 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, Buffer vmbuffer = params->vmbuffer; Page page = BufferGetPage(buffer); BlockNumber blockno = BufferGetBlockNumber(buffer); + TransactionId vm_conflict_horizon = InvalidTransactionId; ``` I guess the variable name “vm_conflict_horizon” comes from the old "presult->vm_conflict_horizon”. But in the new logic, this variable is used more generic, for example Assert(debug_cutoff == vm_conflict_horizon). I see 0006 has renamed to “conflict_xid”, so it’s up to you if or not rename it. But to make the commit self-contained, I’d suggest renaming it. 9 - 0006 ``` @@ -3537,6 +3537,7 @@ heap_page_would_be_all_visible(Relation rel, Buffer buf, { ItemId itemid; HeapTupleData tuple; + TransactionId dead_after = InvalidTransactionId; ``` This initialization seems to not needed, as HeapTupleSatisfiesVacuumHorizon() will always set a value to it. 10 - 0010 ``` + * there is any snapshot that still consider the newest xid on ``` Nit: consider -> considers 11 - 0011 ``` + * page. If we won't attempt freezing, just unset all-visible now, though. */ + if (!prstate->attempt_freeze) + { + prstate->all_visible = false; + prstate->all_frozen = false; + } ``` The comment says “just unset all-visible”, but the code actually also unset all_frozen. 12 - 0012 ``` + /* + * RT indexes of relations modified by the query either through + * UPDATE/DELETE/INSERT/MERGE or SELECT FOR UPDATE + */ + Bitmapset *es_modified_relids; ``` As we intentionally only want indexes, does it make sense to just name the field es_modified_rtindexes to make it more explicit. 13 - 0012 ``` + /* If it has a rowmark, the relation is modified */ + estate->es_modified_relids = bms_add_member(estate->es_modified_relids, + rc->rti); ``` I think this comment is a little misleading, because SELECT FOR UPDATE/SHARE doesn’t always modify tuples of the relation. If a reader not associating this code with this patch, he may consider the comment is wrong. So, I think we should make the comment more explicit. Maybe rephrase like “If it has a rowmark, the relation may modify or lock heap pages”. 14 - 0015 - commit message ``` Setting pd_prune_xid on insert can cause a page to be dirtied and written out when it previously would not have been, affetcting the ``` Typo: affetcting -> affecting Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-22T17:57:16Z
On Mon, Dec 22, 2025 at 2:20 AM Chao Li <li.evan.chao@gmail.com> wrote: > > A few more comments on v29: Thanks for the continued review! I've attached v30. > 1 - 0002 - Looks like since 0002, visibilitymap_set()’s return value is no longer used, so do we need to update the function and change return type to void? I remember in some patches, to address Coverity alerts, people had to do “(void) function_with_a_return_value()”. I was torn about whether or not to change the return value. Coverity doesn't always warn about unused return values. Usually it warns if it perceives the return value as needed for error checking or if it thinks not using the return value is incorrect. It may still warn in this case, but it's not obvious to me which way it would go. I have changed the function signature as you suggested in v30. My hesitation is that visibilitymap_set() is in a header file and could be used by extensions/forks, etc. Adding more information by changing a return value from void to non-void doesn't have any negative effect on those potential callers. But taking away a return value is more likely to affect them in a potentially negative way. However, I'm significantly changing the signature in this release, so everybody that used it will have to change their code completely anyway. Also, I just added a return value for visibilitymap_set() in the previous release (18). Historically, it returned void. So, I've gone with your suggestion. > +static bool > +identify_and_fix_vm_corruption(Relation rel, Buffer heap_buffer, > + BlockNumber heap_blk, Page heap_page, > + int nlpdead_items, > + Buffer vmbuffer, > + uint8 vmbits) > +{ > + Assert(visibilitymap_get_status(rel, heap_blk, &vmbuffer) == vmbits); > ``` > > Right before this function is called: > ``` > old_vmbits = visibilitymap_get_status(vacrel->rel, blkno, &vmbuffer); > + if (identify_and_fix_vm_corruption(vacrel->rel, buf, blkno, page, > + presult.lpdead_items, vmbuffer, > + old_vmbits)) > ``` > > So, the Assert() is checking if old_vmbits is newly returned from visibilitymap_get_status(), in that case, identify_and_fix_vm_corruption() can take vmbits as a pointer , and it calls visibilitymap_get_status() to get vmbits itself and returns vmbits via the pointer, so that we don’t need to call visibilitymap_get_status() twice. I see what you are saying, and I did consider this. visibilitymap_get_status() is only called the second time in assert builds, and it isn't so expensive to do it that it is worth worrying about. I added the assertion to prevent other callers from calling identify_and_fix_vm_corruption() with random VM bits unassociated with the vmbuffer passed in. The reason I don't think identify_and_fix_vm_corruption() should be the one to call visibilitymap_get_status() and initialize old_vmbits is that it shouldn't be a required step to setting the VM. identify_and_fix_vm_corruption()'s job is to identify and fix corruption -- not get the VM bits for when we set them. In fact, it may make sense someday to check that the VM and PD_ALL_VISIBLE are in sync before pruning and freezing is even started. (Of course, we can't check the number of lpdead items until after). Regarding having *old_vmbits as a return value. I thought about directly returning the result of visibilitymap_clear() from identify_and_fix_vm_corruption(). The reason I didn't is that if PD_ALL_VISIBLE is set and nlpdead_items > 0 but the VM is clear, visibilitymap_clear() will return false -- because it didn't need to clear the VM bits. And I think we want identify_and_fix_vm_corruption() to return true if it cleared corruption at all. I don't think we should have identify_and_fix_vm_corruption() reset old_vmbits to 0 (and pass it by reference), because the caller may want to know the value of old_vmbits before we cleared corruption. > + * These are only set if the HEAP_PAGE_PRUNE_UPDATE_VM option is set and > + * we have attempted to update the VM. > + */ > + uint8 new_vmbits; > + uint8 old_vmbits; > ``` > > The comment feels a little confusing to me. "HEAP_PAGE_PRUNE_UPDATE_VM option is set” is a clear indication, but how to decide "we have attempted to update the VM”? By reading the code: > ``` > + prstate->attempt_update_vm = > + (params->options & HEAP_PAGE_PRUNE_UPDATE_VM) != 0; > > It’s just the result of HEAP_PAGE_PRUNE_UPDATE_VM being set. So, maybe we don’t the “and” part. Good point. Fixed. > +static bool > +heap_page_will_set_vm(PruneState *prstate, > + Relation relation, > + BlockNumber heap_blk, Buffer heap_buffer, Page heap_page, > + Buffer vmbuffer, > + int nlpdead_items, > + uint8 *old_vmbits, > + uint8 *new_vmbits) > +{ > + if (!prstate->attempt_update_vm) > + return false; > ``` > > old_vmbits and new_vmbits are purely output parameters. So, maybe we should set them to 0 inside this function instead of relying on callers to initialize them. > > I think this is a similar case where I raised a comment earlier about initializing presult to {0} in the callers, and you only wanted to set presult in heap_page_prune_and_freeze(). I see your point. It does feel a little bit different to me since they are local variables and coverity may not actually be able to tell they are being unconditionally initialized by heap_page_will_set_vm(). The other local variables that are not initialized at the top are all unconditionally set by helper return values. But my decision to initialize them was more instinct than rationality. I've changed it as you suggested. > - * HEAP_PAGE_PRUNE_FREEZE indicates that we will also freeze tuples, and > - * will return 'all_visible', 'all_frozen' flags to the caller. > + * HEAP_PAGE_PRUNE_FREEZE indicates that we will also freeze tuples > > Nit: a tailing dot is needed in the end of the comment line. I've changed it. One interesting thing is that our "policy" for periods in comments is that we don't put periods at the end of one-line comments and we do put them at the end of mult-line comment sentences. This is a one-line comment inside a comment block, so I wasn't sure what to do. If you noticed it, and it bothered you, it's easy enough to change, though. > @@ -978,6 +1003,7 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > Buffer vmbuffer = params->vmbuffer; > Page page = BufferGetPage(buffer); > BlockNumber blockno = BufferGetBlockNumber(buffer); > + TransactionId vm_conflict_horizon = InvalidTransactionId; > ``` > > I guess the variable name “vm_conflict_horizon” comes from the old "presult->vm_conflict_horizon”. But in the new logic, this variable is used more generic, for example Assert(debug_cutoff == vm_conflict_horizon). I see 0006 has renamed to “conflict_xid”, so it’s up to you if or not rename it. But to make the commit self-contained, I’d suggest renaming it. As of this patch, it is still being exclusively used as the conflict XID for setting the visibility map. And it still is the visibility horizon. I rename it to conflict xid once it includes more than just the visibility horizon for an all-visible page. In that assertion, it is also the visibility horizon for an all-visible page. > 9 - 0006 > > @@ -3537,6 +3537,7 @@ heap_page_would_be_all_visible(Relation rel, Buffer buf, > { > ItemId itemid; > HeapTupleData tuple; > + TransactionId dead_after = InvalidTransactionId; > ``` > > This initialization seems to not needed, as HeapTupleSatisfiesVacuumHorizon() will always set a value to it. I think this is a comment for a later patch in the set (you originally said it was from 0006), but I've changed dead_after to not be initialized like this. > + /* > + * RT indexes of relations modified by the query either through > + * UPDATE/DELETE/INSERT/MERGE or SELECT FOR UPDATE > + */ > + Bitmapset *es_modified_relids; > ``` > > As we intentionally only want indexes, does it make sense to just name the field es_modified_rtindexes to make it more explicit. I'm torn about this. I named it like this partially because the struct member two above it in the estate, es_unpruned_relids, is also a bitmapset of range table indexes and yet is called x_relids. Though the bitmapset is one of indexes into the range table, they are the indexes of relation IDs in that range table. I think this could go either way, so I've left it as is for now and will think more about it once this patch is closer to being committed. > + /* If it has a rowmark, the relation is modified */ > + estate->es_modified_relids = bms_add_member(estate->es_modified_relids, > + rc->rti); > ``` > > I think this comment is a little misleading, because SELECT FOR UPDATE/SHARE doesn’t always modify tuples of the relation. If a reader not associating this code with this patch, he may consider the comment is wrong. So, I think we should make the comment more explicit. Maybe rephrase like “If it has a rowmark, the relation may modify or lock heap pages”. I see what you are saying. It's a good point. However, the reason we don't want to set the VM for SELECT FOR UPDATE is not because the SELECT FOR UPDATE will lock the relation but because it is usually indicating that we intend to modify the relation (when we do the update). As such, I've updated the comment to say "If it has a rowmark, the relation may be modified" -- which leaves it more open. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-22T18:20:59Z
On Sat, Dec 20, 2025 at 7:32 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > Hi! I checked v29-0009, about HeapTupleSatisfiesVacuumHorizon. Origins > of this code track down to fdf9e21196a6 which was committed as part of > [0], at which point > there was no HeapTupleSatisfiesVacuumHorizon function. I guess this is > the reason this optimization was not performed earlier. Thanks for taking a look into this! > I also think this patch is correct, because we do similar things for > HEAPTUPLE_DEAD & HEAPTUPLE_RECENTLY_DEAD, and > HeapTupleSatisfiesVacuumHorizon is just a proxy to > HeapTupleSatisfiesVacuumHorizon with only difference in DEAD VS > RECENTLY_DEAD handling. > > Similar change could be done at heapam_scan_analyze_next_tuple > > ... > case HEAPTUPLE_DEAD: > case HEAPTUPLE_RECENTLY_DEAD: > /* Count dead and recently-dead rows */ > *deadrows += 1; > break; In v30 sent here [1], I did end up making this change in 0010. I just realized that I should have also changed table_scan_analyze_next_tuple() and removed the call to GetOldestRemovableTransactionId(). I've done that in attached v31. I'm not sure we should change the table AM API (by removing OldestXmin), though. I looked for table AMs implementing scan_analyze_next_tuple() to see if they use OldestXmin. I found two: OrioleDB [2] and Citus columnar [3], which both implement scan_analyze_next_tuple() and neither of them use OldestXmin. I couldn't easily find other table AMs implementing scan_analyze_next_tuple(). I don't have a strong sense of whether or not I should make this change. Changing it is churn to a public API and doesn't specifically enable us to do something. I could also just leave it unused by heapam's implementation. I haven't checked what, if any, other table AMs callbacks have parameters completely unused by their heap implementation. So, I'm on the fence about whether or not to make the change at all, and, if I do, whether or not to change the table AM callback. That is done in v31, though, so we can discuss. - Melanie [1] https://www.postgresql.org/message-id/CAAKRu_ZCjHoRPfQ8AbMrFY8TOMCPAvZ0_m9SX7yg0edfTk45-g%40mail.gmail.com [2] https://github.com/orioledb/orioledb/blob/acff65984d106dabf708a179e2c6694297e08c02/src/tableam/handler.c#L978C68-L978C78 [3] https://github.com/citusdata/citus/blob/ee3812d267db3ab007efb6f5f432c82c1f448695/src/backend/columnar/columnar_tableam.c#L1418
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2025-12-23T00:00:57Z
> On Dec 23, 2025, at 01:57, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Mon, Dec 22, 2025 at 2:20 AM Chao Li <li.evan.chao@gmail.com> wrote: >> >> A few more comments on v29: > > Thanks for the continued review! I've attached v30. > >> 1 - 0002 - Looks like since 0002, visibilitymap_set()’s return value is no longer used, so do we need to update the function and change return type to void? I remember in some patches, to address Coverity alerts, people had to do “(void) function_with_a_return_value()”. > > I was torn about whether or not to change the return value. Coverity > doesn't always warn about unused return values. Usually it warns if it > perceives the return value as needed for error checking or if it > thinks not using the return value is incorrect. It may still warn in > this case, but it's not obvious to me which way it would go. > > I have changed the function signature as you suggested in v30. > > My hesitation is that visibilitymap_set() is in a header file and > could be used by extensions/forks, etc. Adding more information by > changing a return value from void to non-void doesn't have any > negative effect on those potential callers. But taking away a return > value is more likely to affect them in a potentially negative way. > > However, I'm significantly changing the signature in this release, so > everybody that used it will have to change their code completely > anyway. Also, I just added a return value for visibilitymap_set() in > the previous release (18). Historically, it returned void. So, I've > gone with your suggestion. From a previous patch, I learned from Peter Eisentraut that “We don't care about ABI changes in major releases.”, see: https://www.postgresql.org/message-id/70913dbd-dadf-4560-9f81-c0df72bf6578%40eisentraut.org >> - * HEAP_PAGE_PRUNE_FREEZE indicates that we will also freeze tuples, and >> - * will return 'all_visible', 'all_frozen' flags to the caller. >> + * HEAP_PAGE_PRUNE_FREEZE indicates that we will also freeze tuples >> >> Nit: a tailing dot is needed in the end of the comment line. > > I've changed it. One interesting thing is that our "policy" for > periods in comments is that we don't put periods at the end of > one-line comments and we do put them at the end of mult-line comment > sentences. This is a one-line comment inside a comment block, so I > wasn't sure what to do. If you noticed it, and it bothered you, it's > easy enough to change, though. If this is a one-line comment, I would have not been caring about the tailing period. The problem is this is a paragraph of a block comment, and the above and below paragraphs all have tailing periods. So, for consistency, I raised the comment. ``` * HEAP_PAGE_PRUNE_MARK_UNUSED_NOW indicates that dead items can be set * LP_UNUSED during pruning. <=== Has a tailing period * - * HEAP_PAGE_PRUNE_FREEZE indicates that we will also freeze tuples, and - * will return 'all_visible', 'all_frozen' flags to the caller. + * HEAP_PAGE_PRUNE_FREEZE indicates that we will also freeze tuples <=== Not a tailing period * * HEAP_PAGE_PRUNE_UPDATE_VM indicates that we will set the page's status * in the VM. <=== Has a tailing period ``` > >> 9 - 0006 >> >> @@ -3537,6 +3537,7 @@ heap_page_would_be_all_visible(Relation rel, Buffer buf, >> { >> ItemId itemid; >> HeapTupleData tuple; >> + TransactionId dead_after = InvalidTransactionId; >> ``` >> >> This initialization seems to not needed, as HeapTupleSatisfiesVacuumHorizon() will always set a value to it. > > I think this is a comment for a later patch in the set (you originally > said it was from 0006), but I've changed dead_after to not be > initialized like this. My bad. This comment was actually for 0009. In v31, I see you have removed the initialization to dead_after. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2025-12-23T01:18:05Z
On Mon, Dec 22, 2025 at 7:01 PM Chao Li <li.evan.chao@gmail.com> wrote: > > > On Dec 23, 2025, at 01:57, Melanie Plageman <melanieplageman@gmail.com> wrote: > > > > My hesitation is that visibilitymap_set() is in a header file and > > could be used by extensions/forks, etc. Adding more information by > > changing a return value from void to non-void doesn't have any > > negative effect on those potential callers. But taking away a return > > value is more likely to affect them in a potentially negative way. > > > > However, I'm significantly changing the signature in this release, so > > everybody that used it will have to change their code completely > > anyway. Also, I just added a return value for visibilitymap_set() in > > the previous release (18). Historically, it returned void. So, I've > > gone with your suggestion. > > From a previous patch, I learned from Peter Eisentraut that “We don't care about ABI changes in major releases.”, see: Right, it is totally okay to change function APIs in a major release. My point was not that it wasn't allowed but that if people are getting useful information returned from that function, or if we think we might want that information again in the future, we should think twice before changing it. But, in this case, I think we don't need to worry about it. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2026-01-03T21:36:25Z
On Tue, 23 Dec 2025 at 06:18, Melanie Plageman <melanieplageman@gmail.com> wrote: > > Right, it is totally okay to change function APIs in a major release. > My point was not that it wasn't allowed but that if people are getting > useful information returned from that function, or if we think we > might want that information again in the future, we should think twice > before changing it. But, in this case, I think we don't need to worry > about it. > > - Melanie At first glance this change looks sane, and I do not find any reason why table_scan_analyze_next_tuple needs knowledge about OldestXid. But I am not aware of this function design discussions, maybe OldestXid is here for a good reason. After thinking about it for a week or so, I would actually suggest moving forward with v31 (Remove OldestXmin from TAM). I think this is a low probability of getting complaints about that. Also, we're breaking ABI, so we will know about any important use-case not long after 19-beta1. Another user of extensible TAM, Cloudberry [0], does not use OldestXmin also. I also did not find any user of scan_analyze_next_tuple other than postgresql itself with http://codesearch.decbian.net/ . Using code-search on github I found [1]. Looks like this does not need OldestXmin either. [0] https://github.com/apache/cloudberry [1] https://github.com/hydradatabase/columnar/blob/main/columnar/src/backend/columnar/columnar_tableam.c#L2080C68-L2080C78 -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-01-05T19:24:54Z
On Sat, Jan 3, 2026 at 4:36 PM Kirill Reshke <reshkekirill@gmail.com> wrote: > > After thinking about it for a week or so, I would actually suggest > moving forward with v31 (Remove OldestXmin from TAM). > I think this is a low probability of getting complaints about that. > Also, we're breaking ABI, so we will know about any important use-case > not long after 19-beta1. > Another user of extensible TAM, Cloudberry [0], does not use > OldestXmin also. I also did not find any user of > scan_analyze_next_tuple other than postgresql itself with > http://codesearch.decbian.net/ . Using code-search on github I found > [1]. Looks like this does not need OldestXmin either. Thanks for doing this research. I've attached v32 which is rebased over a change in master and also resolves the one open issue in the patch set: why the killtuples isolation test heap accesses count changed after setting pd_prune_xid. That test creates a table, inserts tuples, accesses one page, deletes all the data, accesses a single page again (until the table is vacuumed, the pages will still be there and have to be scanned even though the data is deleted). The first time we set the VM on-access, we have to extend the VM. That VM access is an extend and not a hit. Once we set pd_prune_xid on the page, the extend happens during the first access (before the delete), so when we access the VM after the delete step, that is counted as a hit and we end up with more hits in the stats. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andrey Borodin <x4mmm@yandex-team.ru> — 2026-01-06T09:40:03Z
> On 6 Jan 2026, at 00:24, Melanie Plageman <melanieplageman@gmail.com> wrote: > > <v32-0014-Pass-down-information-on-table-modification-to-s.patch> I've tried to take an attempt to review some patches of this patchset. It's huge and mostly polished. In a step "Pass down information on table modification to scan node" you pass SO_HINT_REL_READ_ONLY flag in IndexNext() and BitmapTableScanSetup(), but not in IndexNextWithReorder() and IndexOnlyNext(). Is there a reason why index scans with ordering cannot use on-access VM setting? Also, comment about visibilitymap_set() says "Callers that log VM changes separately should use visibilitymap_set()" as if visibilitymap_set() is some other function. Best regards, Andrey Borodin.
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-01-06T17:31:57Z
On Tue, Jan 6, 2026 at 4:40 AM Andrey Borodin <x4mmm@yandex-team.ru> wrote: > > > <v32-0014-Pass-down-information-on-table-modification-to-s.patch> > > I've tried to take an attempt to review some patches of this patchset. It's huge and mostly polished. I've added attributed your review on the patches you specifically mention here (and from previous emails you sent). Let me know if there are other patches you reviewed that you did not mention. > In a step "Pass down information on table modification to scan node" you pass SO_HINT_REL_READ_ONLY flag in IndexNext() and BitmapTableScanSetup(), but not in IndexNextWithReorder() and IndexOnlyNext(). Is there a reason why index scans with ordering cannot use on-access VM setting? Great point, I simply hadn't tested those cases and didn't think to add them. I've added them in attached v33. While looking at other callers of index_beginscan(), I was wondering if systable_beginscan() and systable_beginscan_ordered() should ever pass SO_HINT_REL_READ_ONLY. I guess we would need to pass if the operation is read-only above the index_beginscan() -- I'm not sure if we always know in the caller of systable_beginscan() whether this operation will modify the catalog. That seems like it could be a separate project, though, so maybe it is better to say this feature is just for regular tables. As for the other cases: We don't have the relation range table index in check_exclusion_or_unique_constraints(), so I don't think we can do it there. And I think that the other index scan cases like in replication code or get_actual_variable_endpoint() are too small to be worth it, don't have the needed info, or don't do on-access pruning (bc of the snapshot type they use). > Also, comment about visibilitymap_set() says "Callers that log VM changes separately should use visibilitymap_set()" as if visibilitymap_set() is some other function. Ah, yes, I forgot to remove that when I removed the old visibilitymap_set() and made visibilitymap_set_vmbits() into visiblitymap_set(). Done in v33. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2026-01-07T05:55:03Z
On Tue, 6 Jan 2026 at 22:32, Melanie Plageman <melanieplageman@gmail.com> wrote: > > Ah, yes, I forgot to remove that when I removed the old > visibilitymap_set() and made visibilitymap_set_vmbits() into > visiblitymap_set(). Done in v33. > > - Melanie I think 0001-0003 and 0009-0010 are ready. > That test creates a table, inserts tuples, accesses one page, deletes all the data, accesses a single page again (until the table is vacuumed, the pages will still be there and have to be scanned even though the data is deleted). The first time we set the VM on-access, we have to extend the VM. That VM access is an extend and not a hit. Once we set pd_prune_xid on the page, the extend happens during the first access (before the delete), so when we access the VM after the delete step, that is counted as a hit and we end up with more hits in the stats. Good -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2026-01-07T08:14:38Z
> On Jan 7, 2026, at 01:31, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Tue, Jan 6, 2026 at 4:40 AM Andrey Borodin <x4mmm@yandex-team.ru> wrote: >> >>> <v32-0014-Pass-down-information-on-table-modification-to-s.patch> >> >> I've tried to take an attempt to review some patches of this patchset. It's huge and mostly polished. > > I've added attributed your review on the patches you specifically > mention here (and from previous emails you sent). Let me know if there > are other patches you reviewed that you did not mention. > >> In a step "Pass down information on table modification to scan node" you pass SO_HINT_REL_READ_ONLY flag in IndexNext() and BitmapTableScanSetup(), but not in IndexNextWithReorder() and IndexOnlyNext(). Is there a reason why index scans with ordering cannot use on-access VM setting? > > Great point, I simply hadn't tested those cases and didn't think to > add them. I've added them in attached v33. > > While looking at other callers of index_beginscan(), I was wondering > if systable_beginscan() and systable_beginscan_ordered() should ever > pass SO_HINT_REL_READ_ONLY. I guess we would need to pass if the > operation is read-only above the index_beginscan() -- I'm not sure if > we always know in the caller of systable_beginscan() whether this > operation will modify the catalog. That seems like it could be a > separate project, though, so maybe it is better to say this feature is > just for regular tables. > > As for the other cases: We don't have the relation range table index > in check_exclusion_or_unique_constraints(), so I don't think we can do > it there. > > And I think that the other index scan cases like in replication code > or get_actual_variable_endpoint() are too small to be worth it, don't > have the needed info, or don't do on-access pruning (bc of the > snapshot type they use). > >> Also, comment about visibilitymap_set() says "Callers that log VM changes separately should use visibilitymap_set()" as if visibilitymap_set() is some other function. > > Ah, yes, I forgot to remove that when I removed the old > visibilitymap_set() and made visibilitymap_set_vmbits() into > visiblitymap_set(). Done in v33. > > - Melanie > <v33-0001-Combine-visibilitymap_set-cases-in-lazy_scan_pru.patch><v33-0002-Eliminate-use-of-cached-VM-value-in-lazy_scan_pr.patch><v33-0003-Refactor-lazy_scan_prune-VM-clear-logic-into-hel.patch><v33-0004-Set-the-VM-in-heap_page_prune_and_freeze.patch><v33-0005-Move-VM-assert-into-prune-freeze-code.patch><v33-0006-Eliminate-XLOG_HEAP2_VISIBLE-from-vacuum-phase-I.patch><v33-0007-Eliminate-XLOG_HEAP2_VISIBLE-from-empty-page-vac.patch><v33-0008-Remove-XLOG_HEAP2_VISIBLE-entirely.patch><v33-0009-Simplify-heap_page_would_be_all_visible-visibili.patch><v33-0010-Remove-table_scan_analyze_next_tuple-unneeded-pa.patch><v33-0011-Use-GlobalVisState-in-vacuum-to-determine-page-l.patch><v33-0012-Unset-all_visible-sooner-if-not-freezing.patch><v33-0013-Track-which-relations-are-modified-by-a-query.patch><v33-0014-Pass-down-information-on-table-modification-to-s.patch><v33-0015-Allow-on-access-pruning-to-set-pages-all-visible.patch><v33-0016-Set-pd_prune_xid-on-insert.patch> I see the same problem in 0009 and 0010: 0009 ``` --- a/src/backend/access/heap/vacuumlazy.c +++ b/src/backend/access/heap/vacuumlazy.c @@ -3570,6 +3570,7 @@ heap_page_would_be_all_visible(Relation rel, Buffer buf, { ItemId itemid; HeapTupleData tuple; + TransactionId dead_after; /* * Set the offset number so that we can display it along with any @@ -3609,12 +3610,14 @@ heap_page_would_be_all_visible(Relation rel, Buffer buf, /* Visibility checks may do IO or allocate memory */ Assert(CritSectionCount == 0); - switch (HeapTupleSatisfiesVacuum(&tuple, OldestXmin, buf)) + switch (HeapTupleSatisfiesVacuumHorizon(&tuple, buf, &dead_after)) { case HEAPTUPLE_LIVE: { TransactionId xmin; + Assert(!TransactionIdIsValid(dead_after)); + /* Check comments in lazy_scan_prune. */ if (!HeapTupleHeaderXminCommitted(tuple.t_data)) { @@ -3647,8 +3650,10 @@ heap_page_would_be_all_visible(Relation rel, Buffer buf, } break; - case HEAPTUPLE_DEAD: case HEAPTUPLE_RECENTLY_DEAD: + Assert(TransactionIdIsValid(dead_after)); + /* FALLTHROUGH */ ``` 0010: ``` static bool -heapam_scan_analyze_next_tuple(TableScanDesc scan, TransactionId OldestXmin, +heapam_scan_analyze_next_tuple(TableScanDesc scan, double *liverows, double *deadrows, TupleTableSlot *slot) { @@ -1047,6 +1047,7 @@ heapam_scan_analyze_next_tuple(TableScanDesc scan, TransactionId OldestXmin, ItemId itemid; HeapTuple targtuple = &hslot->base.tupdata; bool sample_it = false; + TransactionId dead_after; itemid = PageGetItemId(targpage, hscan->rs_cindex); @@ -1069,16 +1070,20 @@ heapam_scan_analyze_next_tuple(TableScanDesc scan, TransactionId OldestXmin, targtuple->t_data = (HeapTupleHeader) PageGetItem(targpage, itemid); targtuple->t_len = ItemIdGetLength(itemid); - switch (HeapTupleSatisfiesVacuum(targtuple, OldestXmin, - hscan->rs_cbuf)) + switch (HeapTupleSatisfiesVacuumHorizon(targtuple, + hscan->rs_cbuf, + &dead_after)) { case HEAPTUPLE_LIVE: sample_it = true; *liverows += 1; break; - case HEAPTUPLE_DEAD: case HEAPTUPLE_RECENTLY_DEAD: + Assert(TransactionIdIsValid(dead_after)); + /* FALLTHROUGH */ ``` I believe the reason why we add Assert(TransactionIdIsValid(dead_after)) under HEAPTUPLE_RECENTLY_DEAD is to ensure that when HeapTupleSatisfiesVacuumHorizon() returns HEAPTUPLE_RECENTLY_DEAD, dead_after must be set. So the goal of the assert is to catch bugs of HeapTupleSatisfiesVacuumHorizon(). From this perspective, I now feel dead_after should be initialized to InvalidTransactionId. Otherwise, say HeapTupleSatisfiesVacuumHorizon() has a bug and miss to set dead_after, then the assert mostly like won’t be fired, because it holds a random value, most likely not be 0. I know this comment conflicts to one of my previous comments, sorry about that. As I read this patch once and again, I am getting more understanding to it. 0014 ``` + /* set if the query doesn't modify the rel */ + SO_HINT_REL_READ_ONLY = 1 << 10, ``` Nit: I think it’s better to replace “rel” to “relation”. For a function comment, if there is a parameter named “rel”, then we can use it to refer to the parameter, without such a context, I guess here a while word is better. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andrey Borodin <x4mmm@yandex-team.ru> — 2026-01-16T13:05:44Z
> >> In a step "Pass down information on table modification to scan node" you pass SO_HINT_REL_READ_ONLY flag in IndexNext() and BitmapTableScanSetup(), but not in IndexNextWithReorder() and IndexOnlyNext(). Is there a reason why index scans with ordering cannot use on-access VM setting? > > Great point, I simply hadn't tested those cases and didn't think to > add them. I've added them in attached v33. > > While looking at other callers of index_beginscan(), I was wondering > if systable_beginscan() and systable_beginscan_ordered() should ever > pass SO_HINT_REL_READ_ONLY. I guess we would need to pass if the > operation is read-only above the index_beginscan() -- I'm not sure if > we always know in the caller of systable_beginscan() whether this > operation will modify the catalog. That seems like it could be a > separate project, though, so maybe it is better to say this feature is > just for regular tables. +1 for excluding catalog. > > As for the other cases: We don't have the relation range table index > in check_exclusion_or_unique_constraints(), so I don't think we can do > it there. > > And I think that the other index scan cases like in replication code > or get_actual_variable_endpoint() are too small to be worth it, don't > have the needed info, or don't do on-access pruning (bc of the > snapshot type they use). Makes sense. I think with cases that you already added the list is complete. > On 6 Jan 2026, at 22:31, Melanie Plageman <melanieplageman@gmail.com> wrote: > > Let me know if there > are other patches you reviewed that you did not mention. I'll review them one by one, starting from first 3. > <v33-0001-Combine-visibilitymap_set-cases-in-lazy_scan_pru.patch> I agree that cases of visibilitymap_set() are merged properly. I'm slightly worried that now if we decided to visibilitymap_set() all_frozen page it goes before corruption warning in block of else if (all_visible_according_to_vm && !PageIsAllVisible(page) && visibilitymap_get_status(vacrel->rel, blkno, &vmbuffer) != 0) { } Perhaps, it's not that important though. We are resetting everything to correct state anyway, just without WARNING. > <v33-0002-Eliminate-use-of-cached-VM-value-in-lazy_scan_pr.patch> LGTM. I'm actually happy with "else if (!PageIsAllVisible(page) &&..)" conversion to "if (!PageIsAllVisible(page) &&..)". > <v33-0003-Refactor-lazy_scan_prune-VM-clear-logic-into-hel.patch> LGTM. I'll proceed with other steps later. Thanks! Best regards, Andrey Borodin. -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2026-01-24T00:28:46Z
Hi, On 2026-01-06 12:31:57 -0500, Melanie Plageman wrote: > Subject: [PATCH v33 01/16] Combine visibilitymap_set() cases in > lazy_scan_prune() > > lazy_scan_prune() previously had two separate cases that called > visibilitymap_set() after pruning and freezing. These branches were > nearly identical except that one attempted to avoid dirtying the heap > buffer. However, that situation can never occur — the heap buffer cannot > be clean at that point (and we would hit an assertion if it were). > > In lazy_scan_prune(), when we change a previously all-visible page to > all-frozen and the page was recorded as all-visible in the visibility > map by find_next_unskippable_block(), the heap buffer will always be > dirty. Either we have just frozen a tuple and already dirtied the > buffer, or the buffer was modified between find_next_unskippable_block() > and heap_page_prune_and_freeze() and then pruned in > heap_page_prune_and_freeze(). > > Additionally, XLogRegisterBuffer() asserts that the buffer is dirty, so > attempting to add a clean heap buffer to the WAL chain would assert out > anyway. > > Since the “clean heap buffer with already set VM” case is impossible, > the two visibilitymap_set() branches in lazy_scan_prune() can be merged. > Doing so makes the intent clearer and emphasizes that the heap buffer > must always be marked dirty before being added to the WAL chain. > > This commit also adds a test case for vacuuming when no heap > modifications are required. Currently this ensures that the heap buffer > is marked dirty before it is added to the WAL chain, but if we later > remove the heap buffer from the VM-set WAL chain or pass it with the > REGBUF_NO_CHANGES flag, this test would guard that behavior. > > Author: Melanie Plageman <melanieplageman@gmail.com> > Reviewed-by: Chao Li <li.evan.chao@gmail.com> > Reviewed-by: Srinath Reddy Sadipiralla <srinath2133@gmail.com> > Reviewed-by: Kirill Reshke <reshkekirill@gmail.com> > Reviewed-by: Xuneng Zhou <xunengzhou@gmail.com> > Discussion: https://postgr.es/m/5CEAA162-67B1-44DA-B60D-8B65717E8B05%40gmail.com > Discussion: https://postgr.es/m/flat/CAAKRu_ZWx5gCbeCf7PWCv8p5%3D%3Db7EEws0VD2wksDxpXCvCyHvQ%40mail.gmail.com > --- > .../pg_visibility/expected/pg_visibility.out | 44 ++++++++++ > contrib/pg_visibility/sql/pg_visibility.sql | 20 +++++ > src/backend/access/heap/vacuumlazy.c | 87 ++++--------------- > 3 files changed, 82 insertions(+), 69 deletions(-) > > diff --git a/contrib/pg_visibility/expected/pg_visibility.out b/contrib/pg_visibility/expected/pg_visibility.out > index 09fa5933a35..e10f1706015 100644 > --- a/contrib/pg_visibility/expected/pg_visibility.out > +++ b/contrib/pg_visibility/expected/pg_visibility.out > @@ -1,4 +1,5 @@ > CREATE EXTENSION pg_visibility; > +CREATE EXTENSION pageinspect; I think this would need a EXTRA_INSTALL = contrib/pageinspect to work reliably in make. You should be able to see a failure without the fix if you remove the tmp_install/ dir in an autoconf build and then run make check for pg_visibility. I'm slightly wary of embedding numerical bitmaks values in the tests, but I don't see a better alternative right now. Other than the EXTRA_INSTALL thing I think this is ready. > From 4d37243f9fa0dc4e264a28bcee448787fb8d7f65 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Thu, 11 Dec 2025 10:48:13 -0500 > Subject: [PATCH v33 02/16] Eliminate use of cached VM value in > lazy_scan_prune() > > lazy_scan_prune() takes a parameter from lazy_scan_heap() indicating > whether the page was marked all-visible in the VM at the time it was > last checked in find_next_unskippable_block(). This behavior is > historical, dating back to commit 608195a3a365, when we did not pin the > VM page until deciding we must read it. Now that the VM page is already > pinned, there is no meaningful benefit to relying on a cached VM status. > > Removing this cached value simplifies the logic in both lazy_scan_heap() > and lazy_scan_prune(). It also clarifies future work that will set the > visibility map on-access: such paths will not have a cached value > available, which would make the logic harder to reason about. And > eliminating it enables us to detect and repair VM corruption on-access. > > Along with removing the cached value and unconditionally checking the > visibility status of the heap page, this commit also moves the VM > corruption handling to occur first. This reordering should have no > performance impact, since the checks are inexpensive and performed only > once per page. It does, however, make the control flow easier to > understand. The new restructuring also makes it possible to set the VM > after fixing corruption (if pruning found the page all-visible). > > Now that no callers of visibilitymap_set() use its return value, change > its (and visibilitymap_set_vmbits()) return type to void. > @@ -1735,7 +1719,6 @@ find_next_unskippable_block(LVRelState *vacrel, bool *skipsallvis) > BlockNumber next_unskippable_block = vacrel->next_unskippable_block + 1; > Buffer next_unskippable_vmbuffer = vacrel->next_unskippable_vmbuffer; > bool next_unskippable_eager_scanned = false; > - bool next_unskippable_allvis; > > *skipsallvis = false; > > @@ -1745,7 +1728,6 @@ find_next_unskippable_block(LVRelState *vacrel, bool *skipsallvis) > next_unskippable_block, > &next_unskippable_vmbuffer); > > - next_unskippable_allvis = (mapbits & VISIBILITYMAP_ALL_VISIBLE) != 0; > > /* > * At the start of each eager scan region, normal vacuums with eager > @@ -1764,7 +1746,7 @@ find_next_unskippable_block(LVRelState *vacrel, bool *skipsallvis) > * A block is unskippable if it is not all visible according to the > * visibility map. > */ > - if (!next_unskippable_allvis) > + if ((mapbits & VISIBILITYMAP_ALL_VISIBLE) == 0) > { > Assert((mapbits & VISIBILITYMAP_ALL_FROZEN) == 0); > break; This feels a bit independent from the rest, but it doesn't matter. > @@ -2225,6 +2144,71 @@ lazy_scan_prune(LVRelState *vacrel, > MarkBufferDirty(buf); > visibilitymap_clear(vacrel->rel, blkno, vmbuffer, > VISIBILITYMAP_VALID_BITS); > + /* VM bits are now clear */ > + old_vmbits = 0; > + } > + > + if (!presult.all_visible) > + return presult.ndeleted; > + > + /* Set the visibility map and page visibility hint */ > + new_vmbits = VISIBILITYMAP_ALL_VISIBLE; > + > + if (presult.all_frozen) > + new_vmbits |= VISIBILITYMAP_ALL_FROZEN; > + > + /* Nothing to do */ > + if (old_vmbits == new_vmbits) > + return presult.ndeleted; > > + Assert(presult.all_visible); Given that there's an explicit return for this case a few lines above, I don't understand what the assert is trying to do? > + /* > + * It should never be the case that the visibility map page is set while > + * the page-level bit is clear I'd perhaps add a parenthetical saying (and if so, we cleared it above) or such. > + visibilitymap_set(vacrel->rel, blkno, buf, > + InvalidXLogRecPtr, > + vmbuffer, presult.vm_conflict_horizon, > + new_vmbits); > + > + /* > + * If the page wasn't already set all-visible and/or all-frozen in the VM, > + * count it as newly set for logging. > + */ > + if ((old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0) > + { > + vacrel->vm_new_visible_pages++; > + if (presult.all_frozen) > + { > + vacrel->vm_new_visible_frozen_pages++; > + *vm_page_frozen = true; Not this patches fault, but I find "vm_new_visible_pages" and "vm_new_visible_frozen_pages" pretty odd names. The concept is all-visible and frozen. The page itself isn't visible or invisible... > Subject: [PATCH v33 03/16] Refactor lazy_scan_prune() VM clear logic into > helper > +/* > + * Helper to correct any corruption detected on a heap page and its > + * corresponding visibility map page after pruning but before setting the > + * visibility map. It examines the heap page, the associated VM page, and the > + * number of dead items previously identified. > + * > + * This function must be called while holding an exclusive lock on the heap > + * buffer, and the dead items must have been discovered under that same lock. > + > + * The provided vmbits must reflect the current state of the VM block > + * referenced by vmbuffer. Although we do not hold a lock on the VM buffer, it > + * is pinned, and the heap buffer is exclusively locked, ensuring that no > + * other backend can update the VM bits corresponding to this heap page. > + * > + * Returns true if it cleared corruption and false otherwise. > + */ I don't love that the caller now has to assume that old_vmbits is zero after this function returns. Somehow that feels like split responsiility. I guess I'd take a pointer to old_vmbits and update it accordingly inside identify_and_fix_vm_corruption() rather than in the caller. > From 5c65e73246b4968ddfa9d3739f53d0d8734b8727 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 2 Dec 2025 15:07:42 -0500 > Subject: [PATCH v33 04/16] Set the VM in heap_page_prune_and_freeze() > > This has no independent benefit. It is meant for ease of review. As of > this commit, there is still a separate WAL record emitted for setting > the VM after pruning and freezing. But it is easier to review if moving > the logic into pruneheap.c is separate from setting the VM in the same > WAL record. It seems a bit noisy to refactor the related code in some of the preceding commits and then refactor it into a slightly different shape as part of this commit (c.f. heap_page_will_set_vm()). It's also a bit odd that a function that sounds rather read-only does stuff like clearing VM/all-visible. Why are we not doing fixing up of the page *before* we prune it? It's a bit insane that we do the WAL logging for pruning, which in turn will often include an FPI, before we do the fixups. The fixes aren't WAL logged, so this actually leads to the standby getting further out of sync. I realize this isn't your mess, but brrr. Do we actually forsee a case where only one of HEAP_PAGE_PRUNE_FREEZE | HEAP_PAGE_PRUNE_UPDATE_VM would be set? > --- a/src/backend/access/heap/vacuumlazy.c > +++ b/src/backend/access/heap/vacuumlazy.c > @@ -424,11 +424,7 @@ static void find_next_unskippable_block(LVRelState *vacrel, bool *skipsallvis); > static bool lazy_scan_new_or_empty(LVRelState *vacrel, Buffer buf, > BlockNumber blkno, Page page, > bool sharelock, Buffer vmbuffer); > -static bool identify_and_fix_vm_corruption(Relation rel, Buffer heap_buffer, > - BlockNumber heap_blk, Page heap_page, > - int nlpdead_items, > - Buffer vmbuffer, > - uint8 vmbits); > + > static int lazy_scan_prune(LVRelState *vacrel, Buffer buf, > BlockNumber blkno, Page page, > Buffer vmbuffer, > @@ -1962,83 +1958,6 @@ cmpOffsetNumbers(const void *a, const void *b) > return pg_cmp_u16(*(const OffsetNumber *) a, *(const OffsetNumber *) b); > } Spurious newline inserted. > /* > * If the page wasn't already set all-visible and/or all-frozen in the VM, > * count it as newly set for logging. > */ > - if ((old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0) > + if ((presult.old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0 && > + (presult.new_vmbits & VISIBILITYMAP_ALL_VISIBLE) != 0) > { > vacrel->vm_new_visible_pages++; > - if (presult.all_frozen) > + if ((presult.new_vmbits & VISIBILITYMAP_ALL_FROZEN) != 0) > { > vacrel->vm_new_visible_frozen_pages++; > *vm_page_frozen = true; > } > } > - else if ((old_vmbits & VISIBILITYMAP_ALL_FROZEN) == 0 && > - presult.all_frozen) > + else if ((presult.old_vmbits & VISIBILITYMAP_ALL_FROZEN) == 0 && > + (presult.new_vmbits & VISIBILITYMAP_ALL_FROZEN) != 0) > { > + Assert((presult.new_vmbits & VISIBILITYMAP_ALL_VISIBLE) != 0); > vacrel->vm_new_frozen_pages++; > *vm_page_frozen = true; > } It's a bit odd that we figure out all of this by inspecting old/new vmbits and have that logic in multiple places. > Subject: [PATCH v33 05/16] Move VM assert into prune/freeze code Feels like a somewhat too narrow description, given that it changes the API for heap_page_prune_and_freeze() by removing variables from PruneFreezeResult. > +#ifdef USE_ASSERT_CHECKING > + > +/* > + * Wrapper for heap_page_would_be_all_visible() which can be used for callers > + * that expect no LP_DEAD on the page. Currently assert-only, but there is no > + * reason not to use it outside of asserts. > + */ If so, why would we want it in pruneheap.c? Seems a bit odd to have heap_page_would_be_all_visible() defined in vacuumlazy.c but defined heap_page_is_all_visible() in pruneheap.c. > From cdf5776fadeae3430c692999b37f8a7ec944bda1 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 2 Dec 2025 16:16:22 -0500 > Subject: [PATCH v33 06/16] Eliminate XLOG_HEAP2_VISIBLE from vacuum phase I > prune/freeze > > Vacuum no longer emits a separate WAL record for each page set > all-visible or all-frozen during phase I. Instead, visibility map > updates are now included in the XLOG_HEAP2_PRUNE_VACUUM_SCAN record that > is already emitted for pruning and freezing. > > Previously, heap_page_prune_and_freeze() determined whether a page was > all-visible, but the corresponding VM bits were only set later in > lazy_scan_prune(). Now the VM is updated immediately in > heap_page_prune_and_freeze(), at the same time as the heap > modifications. > > This change applies only to vacuum phase I, not to pruning performed > during normal page access. > +/* > + * Calculate the conflict horizon for the whole XLOG_HEAP2_PRUNE_VACUUM_SCAN > + * or XLOG_HEAP2_PRUNE_ON_ACCESS record. > + */ > +static TransactionId > +get_conflict_xid(bool do_prune, bool do_freeze, bool do_set_vm, > + uint8 old_vmbits, uint8 new_vmbits, > + TransactionId latest_xid_removed, TransactionId frz_conflict_horizon, > + TransactionId visibility_cutoff_xid) > +{ The logic for horizons is now split between this and "Calculate what the snapshot conflict horizon should be for a record" in heap_page_will_freeze(). Although I guess I don't understand that code: /* * Calculate what the snapshot conflict horizon should be for a record * freezing tuples. We can use the visibility_cutoff_xid as our cutoff * for conflicts when the whole page is eligible to become all-frozen * in the VM once we're done with it. Otherwise, we generate a * conservative cutoff by stepping back from OldestXmin. */ if (prstate->all_frozen) prstate->frz_conflict_horizon = prstate->visibility_cutoff_xid; else { /* Avoids false conflicts when hot_standby_feedback in use */ prstate->frz_conflict_horizon = prstate->cutoffs->OldestXmin; TransactionIdRetreat(prstate->frz_conflict_horizon); } Why does it make sense to use OldestXmin? Consider e.g. the case where there is one very old tuple that needs to be frozen and one new live tuple on a page. Because of the new tuple we can't mark the page all-frozen. But there's also no reason to not use much less aggressive horizon than OldestXmin, namely the newer of xmin,xmax of the old frozen tuple? I also don't understand what the "false conflicts" thing is referencing. > + TransactionId conflict_xid; > + > + /* > + * We can omit the snapshot conflict horizon if we are not pruning or > + * freezing any tuples and are setting an already all-visible page > + * all-frozen in the VM. In this case, all of the tuples on the page must > + * already be visible to all MVCC snapshots on the standby. > + */ The last sentence here is a bit confusing, because they don't just need to already be visible to everyone, they already need to be frozen. Right? > + if (!do_prune && > + !do_freeze && > + do_set_vm && I'm confused by the do_set_vm check here. Doesn't it mean that we will *not* return InvalidTransactionId if !prstate->attempt_update_vm? I don't undestand why that would make sense. I guess we'll compute a bogus cutoff in that cse, but never use it, since we'll also not emit WAL? Or maybe we'll just unnecessarily go through the code below, because the code turns out to do ok regardles? It's confusing either way. > + (old_vmbits & VISIBILITYMAP_ALL_VISIBLE) != 0 && > + (new_vmbits & VISIBILITYMAP_ALL_FROZEN) != 0) I wonder if some of this code would end up cleaner if we tracked the bits we intend to add, rather than the target set of bits is. > + /* > + * The snapshotConflictHorizon for the whole record should be the most > + * conservative of all the horizons calculated for any of the possible > + * modifications. If this record will prune tuples, any transactions on > + * the standby older than the youngest xmax of the most recently removed > + * tuple this record will prune will conflict. If this record will freeze > + * tuples, any transactions on the standby with xids older than the > + * youngest tuple this record will freeze will conflict. > + */ > + conflict_xid = InvalidTransactionId; I'd move this first assignment into an else. > + /* > + * If we are updating the VM, the conflict horizon is almost always the > + * visibility cutoff XID. > + * > + * Separately, if we are freezing any tuples, as an optimization, we can > + * use the visibility_cutoff_xid as the conflict horizon if the page will > + * be all-frozen. What does "as an optimization" mean here? Note that the code actually uses visibility_cutoff_xid even if the page is just marked all-visible, but not all-frozen (due to the do_set_vm check being earlier) > This is true even if there are LP_DEAD line pointers > + * because we ignored those when maintaining the visibility_cutoff_xid. I must just be missing something because I can't follow this at all. I guess it could be correct because we later then add in knowledge of removed xids in via the TransactionIdFollows check below? But if that's it, this is extremely confusingly worded. Sorry, running out of brain power. More another day. Greetings, Andres Freund -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-01-27T22:58:33Z
On Wed, Jan 7, 2026 at 3:15 AM Chao Li <li.evan.chao@gmail.com> wrote: > > I believe the reason why we add Assert(TransactionIdIsValid(dead_after)) under HEAPTUPLE_RECENTLY_DEAD is to ensure that when HeapTupleSatisfiesVacuumHorizon() returns HEAPTUPLE_RECENTLY_DEAD, dead_after must be set. So the goal of the assert is to catch bugs of HeapTupleSatisfiesVacuumHorizon(). > > From this perspective, I now feel dead_after should be initialized to InvalidTransactionId. Otherwise, say HeapTupleSatisfiesVacuumHorizon() has a bug and miss to set dead_after, then the assert mostly like won’t be fired, because it holds a random value, most likely not be 0. Actually, thinking about it more, I decided to remove the assertions on dead_after from those patches entirely. I don't use dead_after and only pass it in because HeapTupleSatisfiesVacuumHorizon requires it. In fact, I don't care if the function correctly sets dead_after since I don't use it. > + /* set if the query doesn't modify the rel */ > + SO_HINT_REL_READ_ONLY = 1 << 10, > ``` > > Nit: I think it’s better to replace “rel” to “relation”. For a function comment, if there is a parameter named “rel”, then we can use it to refer to the parameter, without such a context, I guess here a while word is better. k I'm currently working on a new version that incorporates Andres' review feedback and will post soon. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-01-28T23:16:10Z
Thanks for the review! I pushed v33 0001-0003 after incorporating your feedback. On Fri, Jan 23, 2026 at 7:28 PM Andres Freund <andres@anarazel.de> wrote: > > On 2026-01-06 12:31:57 -0500, Melanie Plageman wrote: > > > + */ > > + if ((old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0) > > + { > > + vacrel->vm_new_visible_pages++; > > + if (presult.all_frozen) > > + { > > + vacrel->vm_new_visible_frozen_pages++; > > + *vm_page_frozen = true; > > Not this patches fault, but I find "vm_new_visible_pages" and > "vm_new_visible_frozen_pages" pretty odd names. The concept is all-visible and > frozen. The page itself isn't visible or invisible... I thought having the extra word "all" in there made it too long. And since "vm" is there, that isn't set unless the page is _all_-visible/all-frozen. But if you think it gives people the wrong idea, I am willing to change it. I can omit vm and make it: new_all_visible_all_frozen_pages new_all_visible_pages new_all_frozen_pages Is that clearer? > > From 5c65e73246b4968ddfa9d3739f53d0d8734b8727 Mon Sep 17 00:00:00 2001 > > From: Melanie Plageman <melanieplageman@gmail.com> > > Date: Tue, 2 Dec 2025 15:07:42 -0500 > > Subject: [PATCH v33 04/16] Set the VM in heap_page_prune_and_freeze() > > > > This has no independent benefit. It is meant for ease of review. As of > > this commit, there is still a separate WAL record emitted for setting > > the VM after pruning and freezing. But it is easier to review if moving > > the logic into pruneheap.c is separate from setting the VM in the same > > WAL record. > > It seems a bit noisy to refactor the related code in some of the preceding > commits and then refactor it into a slightly different shape as part of this > commit (c.f. heap_page_will_set_vm()). I understand what you are saying. I don't see a good way to keep it reviewable otherwise, though. > It's also a bit odd that a function that sounds rather read-only does stuff > like clearing VM/all-visible. I thought about this a lot. Ultimately, I ended up keeping it the way it is. I think the other option is changing from this: do_set_vm = heap_page_will_set_vm(&prstate, params->relation, blockno, buffer, page, vmbuffer, params->reason, do_prune, do_freeze, prstate.lpdead_items, &old_vmbits, &new_vmbits); to this: heap_page_prepare_vm_set(&prstate, params->relation, blockno, buffer, page, vmbuffer, params->reason, do_prune, do_freeze, prstate.lpdead_items, &old_vmbits, &new_vmbits); do_set_vm = (new_vmbits & VISIBILITYMAP_VALID_BITS) != 0; or heap_page_plan_vm_set() heap_page_will_set_vm() has symmetry with heap_page_will_freeze(), the helper that decides whether or not we will freeze tuples. I like that symmetry since heap_page_will_set_vm() decides whether or not to set the VM. Now, heap_page_plan/prepare_vm_set() does indirectly hint that something like clearing VM/all-visible could happen -- if you understand that preparing the VM to have bits set also includes clearing any existing corruption. And "prepare" or "plan" has more symmetry with prune_freeze_plan() -- though that function does not make changes on the page. Ultimately, clearing the VM/page of corruption is pretty anomalous from the rest of the code in heap_page_prune_and_freeze(). All other changes to the page are done in a single critical section at the bottom of the function. I could see an argument for moving identify_and_fix_vm_corruption() out of the helper and into heap_page_prune_and_freeze() but then we'd have to move visibilitymap_get_status() out too. And that takes away a lot of the benefit of encapsulating all that logic. Ultimately, I don't like any of those alternative structures. But if you prefer the names and return value change I have above (heap_page_prepare/plan_vm_set()), I'm fine with going with that. > Why are we not doing fixing up of the page *before* we prune it? It's a bit > insane that we do the WAL logging for pruning, which in turn will often > include an FPI, before we do the fixups. The fixes aren't WAL logged, so this > actually leads to the standby getting further out of sync. > > I realize this isn't your mess, but brrr. Well, after this patch set, clearing the VM does happen before we emit WAL for pruning. It wouldn't be hard to move the corruption fixups to the beginning of heap_page_prune_and_freeze() in the new code structure. But it would split visibility map-related logic into two parts of heap_page_prune_and_freeze(). Would it be worth it? What benefit would we get? Do you just feel that it should logically come first? > Do we actually forsee a case where only one of HEAP_PAGE_PRUNE_FREEZE | > HEAP_PAGE_PRUNE_UPDATE_VM would be set? Yes, when setting the VM on-access, it is too expensive to call heap_prepare_freeze_tuple() on each tuple. I could work on trying to optimize it, but it isn't currently viable. > > - if ((old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0) > > + if ((presult.old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0 && > > + (presult.new_vmbits & VISIBILITYMAP_ALL_VISIBLE) != 0) > > { > > vacrel->vm_new_visible_pages++; > > - if (presult.all_frozen) > > + if ((presult.new_vmbits & VISIBILITYMAP_ALL_FROZEN) != 0) > > { > > vacrel->vm_new_visible_frozen_pages++; > > *vm_page_frozen = true; > > } > > } > > - else if ((old_vmbits & VISIBILITYMAP_ALL_FROZEN) == 0 && > > - presult.all_frozen) > > + else if ((presult.old_vmbits & VISIBILITYMAP_ALL_FROZEN) == 0 && > > + (presult.new_vmbits & VISIBILITYMAP_ALL_FROZEN) != 0) > > { > > + Assert((presult.new_vmbits & VISIBILITYMAP_ALL_VISIBLE) != 0); > > vacrel->vm_new_frozen_pages++; > > *vm_page_frozen = true; > > } > > It's a bit odd that we figure out all of this by inspecting old/new vmbits and > have that logic in multiple places. I changed PruneFreezeResult to have just the counters that have to be reflected in vacrel for logging (vm_new_frozen_pages, etc) instead of passing the bits back. > > Subject: [PATCH v33 05/16] Move VM assert into prune/freeze code > > Feels like a somewhat too narrow description, given that it changes the API > for heap_page_prune_and_freeze() by removing variables from PruneFreezeResult. I've tried to fix this. > > +/* > > + * Wrapper for heap_page_would_be_all_visible() which can be used for callers > > + * that expect no LP_DEAD on the page. Currently assert-only, but there is no > > + * reason not to use it outside of asserts. > > + */ > > If so, why would we want it in pruneheap.c? Seems a bit odd to have > heap_page_would_be_all_visible() defined in vacuumlazy.c but defined > heap_page_is_all_visible() in pruneheap.c. You're right. I've kept them both in vacuumlazy.c > > From cdf5776fadeae3430c692999b37f8a7ec944bda1 Mon Sep 17 00:00:00 2001 > > From: Melanie Plageman <melanieplageman@gmail.com> > > Date: Tue, 2 Dec 2025 16:16:22 -0500 > > +static TransactionId > > +get_conflict_xid(bool do_prune, bool do_freeze, bool do_set_vm, > > + uint8 old_vmbits, uint8 new_vmbits, > > + TransactionId latest_xid_removed, TransactionId frz_conflict_horizon, > > + TransactionId visibility_cutoff_xid) > > +{ > > The logic for horizons is now split between this and "Calculate what the > snapshot conflict horizon should be for a record" in heap_page_will_freeze(). That is true in master too. We determine frz_conflict_horizon in heap_page_will_freeze() and later before emitting the WAL record decide which of the latest_xid_removed and frz_conflict_horizon that we should use as the snapshot conflict horizon for the combined record. All I've done is expand that part (the part before emitting the WAL record) a bit because now we have to consider what the horizon would be if we set the VM. If I really wanted to calculate it only in a single place, I could maintain a new variable, all_frozen_except_dead, and remove the frz_conflict_horizon from heap_page_will_freeze(). Then, in get_conflict_xid(), I could have the following logic: if (do_set_vm) conflict_xid = visibility_cutoff_xid; else if (do_freeze) { if (all_frozen_except_dead) conflict_xid = visibility_cutoff_xid; else { conflict_xid = OldestXmin; TransactionIdRetreat(conflict_xid); } } else conflict_xid = InvalidTransactionId; I think using all_frozen_except_dead while maintaining visibility_cutoff_xid (in heap_prune_record_unchanged_lp_normal()) has the potential to be confusing, though. We'd need to keep updating visibility_cutoff_xid when all_visible is false but all_frozen_except_dead is true as well as when all_visible is true. And because we don't care about all_visible_except_dead, it gets even more confusing to make sure we are maintaining the right variables in the right situations. Alternatively, we could keep maintenance of visibility_cutoff_xid the same and only use all_frozen_except_dead to avoid having the conflict xid calculation in two places. We would just set it after prune_freeze_plan() and use it the way it is in the snippet above. But I don't know if this is better than just having a separate freeze conflict horizon calculated in the will_freeze code. It is just as confusing to understand and just as many variables but in a different place. For now, I've kept it as is. > Although I guess I don't understand that code: > > /* > * Calculate what the snapshot conflict horizon should be for a record > * freezing tuples. We can use the visibility_cutoff_xid as our cutoff > * for conflicts when the whole page is eligible to become all-frozen > * in the VM once we're done with it. Otherwise, we generate a > * conservative cutoff by stepping back from OldestXmin. > */ > if (prstate->all_frozen) > prstate->frz_conflict_horizon = prstate->visibility_cutoff_xid; > else > { > /* Avoids false conflicts when hot_standby_feedback in use */ > prstate->frz_conflict_horizon = prstate->cutoffs->OldestXmin; > TransactionIdRetreat(prstate->frz_conflict_horizon); > } > > Why does it make sense to use OldestXmin? Consider e.g. the case where there > is one very old tuple that needs to be frozen and one new live tuple on a > page. Because of the new tuple we can't mark the page all-frozen. But there's > also no reason to not use much less aggressive horizon than OldestXmin, namely > the newer of xmin,xmax of the old frozen tuple? We don't track the newest frozen xmin right now. Doing so wouldn't be free (i.e. more comparisons which may matter in a query without much other overhead). And we can't get rid of any of the other cutoffs we track. We'd still need to maintain the visibility_cutoff_xid and latest_removed_xid. Which also means it doesn't simplify the code. The only purpose it would serve is to make the snapshot conflict horizon more accurate/more aggressive when we freeze tuples, which would lead to canceling less queries than master -- which is outside the purview of this patch. There's also a set of complications around maintaining this number accurately mentioned by Peter in [1]. I've added a new patch in the series 0001 that expands the comment about this in heap_page_will_freeze() and describes that we are using a coarse cutoff because we don't track anything else. But I don't think changing this behavior is a blocker for this feature. > I also don't understand what the "false conflicts" thing is referencing. Yea, neither do I. I ported that comment over (it's from before I started modifying this code) and have never really understood what it meant -- wouldn't we have more false conflicts if we use a newer cutoff? (because OldestXmin will be newer than visibility_cutoff_xid). If the page isn't all-visible, we won't maintain visibility_cutoff_xid. But if we did actually track the newest live tuple xmin on the page, that could very well be newer than OldestXmin and thus using OldestXmin would cancel less and avoid more false conflicts. But that feels like a _very_ big stretch (in a hypothetical world that doesn't exist). Anyway, I've deleted the comment (in 0001) since it clearly is not adding value. > > + TransactionId conflict_xid; > > + > > + /* > > + * We can omit the snapshot conflict horizon if we are not pruning or > > + * freezing any tuples and are setting an already all-visible page > > + * all-frozen in the VM. In this case, all of the tuples on the page must > > + * already be visible to all MVCC snapshots on the standby. > > + */ > > The last sentence here is a bit confusing, because they don't just need to > already be visible to everyone, they already need to be frozen. Right? Right. Well that also means that all tuples are visible to all MVCC snapshots on the standby too -- I picked that language up from somewhere else in the code and thought it sounded good. But I've edited it to say frozen, which is more accurate. > > + if (!do_prune && > > + !do_freeze && > > + do_set_vm && > > I'm confused by the do_set_vm check here. Doesn't it mean that we will *not* > return InvalidTransactionId if !prstate->attempt_update_vm? I don't undestand > why that would make sense. > > I guess we'll compute a bogus cutoff in that cse, but never use it, since > we'll also not emit WAL? Or maybe we'll just unnecessarily go through the > code below, because the code turns out to do ok regardles? It's confusing > either way. Ah no, that was a relic from when I didn't clear the new vmbits in the event that they were the same as old_vmbits. Now that I do that, I don't need to qualify it with do_set_vm anymore. I've fixed that. > > + (old_vmbits & VISIBILITYMAP_ALL_VISIBLE) != 0 && > > + (new_vmbits & VISIBILITYMAP_ALL_FROZEN) != 0) > > I wonder if some of this code would end up cleaner if we tracked the bits we > intend to add, rather than the target set of bits is. I played around with this idea. Unfortunately, this specific check wouldn't be much simpler, as I'd have to check that _only_ the all-frozen bit is set. And I think that it is probably more clear with explicit old and new vmbits. Those make it clear that we are setting a formerly all-visible page all-frozen. In some of the other places where I use old/new vmbits, I tried only using the delta/bits that need to be newly set. But, for example, in visibilitymap_set(), I need both the all-visible and all-frozen bits to be passed (even if all-visible is already set), because it asserts that if all-frozen is passed both all-visible and all-frozen are passed. If I only keep track of the new bits that need to be set, I would require other logic before visibilitymap_set(). And it makes sense that visibilitymap_set() requires both because you don't ever want people setting just all-frozen -- and you don't want the API to make it easy to do that. > > + /* > > + * If we are updating the VM, the conflict horizon is almost always the > > + * visibility cutoff XID. > > + * > > + * Separately, if we are freezing any tuples, as an optimization, we can > > + * use the visibility_cutoff_xid as the conflict horizon if the page will > > + * be all-frozen. > > What does "as an optimization" mean here? What I meant was that visibility_cutoff_xid is going to be older than OldestXmin for an all-frozen page, so it will lead to canceling fewer queries. So, using it is kind of an "optimization". But I re-read that comment and it was way too confusing. I actually cut that whole paragraph because it should be discussed in heap_page_will_freeze() where we are actually handling it. > Note that the code actually uses visibility_cutoff_xid even if the page is > just marked all-visible, but not all-frozen (due to the do_set_vm check being > earlier) And that is correct. But the comment (which is now removed) was misleading, you are right. > > This is true even if there are LP_DEAD line pointers > > + * because we ignored those when maintaining the visibility_cutoff_xid. > > I must just be missing something because I can't follow this at all. I guess > it could be correct because we later then add in knowledge of removed xids in > via the TransactionIdFollows check below? But if that's it, this is extremely > confusingly worded. Yes, we add in latest_xid_removed which is what makes it correct. But I agree that the wording was terrible. I've cut those details from get_conflict_xid() and kept them where they are relevant in heap_page_will_freeze(). - Melanie [1] https://www.postgresql.org/message-id/CAH2-WzkB-Pt3zPeTXvMik6jcJn%2BdcpUqO-tt_hc13bD6sGRLPg%40mail.gmail.com -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Alexander Lakhin <exclusion@gmail.com> — 2026-01-29T05:00:00Z
Hello Melanie, 29.01.2026 01:16, Melanie Plageman wrote: > Thanks for the review! > I pushed v33 0001-0003 after incorporating your feedback. The buildfarm animal scorpion has detected an instability of the addition to pg_visibility from 21796c267 [1]: 80/82 postgresql:pg_visibility-running / pg_visibility-running/regress ERROR 7.23s exit status 1 diff -U3 /home/bf/bf-build/scorpion/HEAD/pgsql/contrib/pg_visibility/expected/pg_visibility.out /home/bf/bf-build/scorpion/HEAD/pgsql.build/testrun/pg_visibility-running/regress/results/pg_visibility.out --- /home/bf/bf-build/scorpion/HEAD/pgsql/contrib/pg_visibility/expected/pg_visibility.out 2026-01-26 22:07:12.923378464 +0100 +++ /home/bf/bf-build/scorpion/HEAD/pgsql.build/testrun/pg_visibility-running/regress/results/pg_visibility.out 2026-01-28 20:15:13.802517085 +0100 @@ -213,7 +213,7 @@ select pg_visibility_map_summary('test_vac_unmodified_heap'); pg_visibility_map_summary --------------------------- - (1,1) + (0,0) (1 row) -- the checkpoint cleans the buffer dirtied by freezing the sole tuple @@ -237,7 +237,7 @@ FROM page_header(get_raw_page('test_vac_unmodified_heap', 0)); ?column? ---------- - t + f (1 row) -- vacuum sets the VM I've managed to reproduce it locally with the attached and: echo "autovacuum_naptime = 1" > /tmp/temp.config TEMP_CONFIG=/tmp/temp.config make -s check -C contrib/pg_visibility/ ... ok 85 - pg_visibility 30 ms not ok 86 - pg_visibility 165 ms ok 87 - pg_visibility 36 ms ... # 1 of 100 tests failed. Could you please look at this? Probably you'll find [2] helpful. [1] https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=scorpion&dt=2026-01-28%2019%3A07%3A32 [2] https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=1c64d2fcb Best regards, Alexander -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2026-01-29T13:39:07Z
On Thu, 29 Jan 2026 at 10:00, Alexander Lakhin <exclusion@gmail.com> wrote: > > Hello Melanie, > > 29.01.2026 01:16, Melanie Plageman wrote: > > Thanks for the review! > I pushed v33 0001-0003 after incorporating your feedback. > > > The buildfarm animal scorpion has detected an instability of the addition > to pg_visibility from 21796c267 [1]: > > 80/82 postgresql:pg_visibility-running / pg_visibility-running/regress ERROR 7.23s exit status 1 > > diff -U3 /home/bf/bf-build/scorpion/HEAD/pgsql/contrib/pg_visibility/expected/pg_visibility.out /home/bf/bf-build/scorpion/HEAD/pgsql.build/testrun/pg_visibility-running/regress/results/pg_visibility.out > --- /home/bf/bf-build/scorpion/HEAD/pgsql/contrib/pg_visibility/expected/pg_visibility.out 2026-01-26 22:07:12.923378464 +0100 > +++ /home/bf/bf-build/scorpion/HEAD/pgsql.build/testrun/pg_visibility-running/regress/results/pg_visibility.out 2026-01-28 20:15:13.802517085 +0100 > @@ -213,7 +213,7 @@ > select pg_visibility_map_summary('test_vac_unmodified_heap'); > pg_visibility_map_summary > --------------------------- > - (1,1) > + (0,0) > (1 row) > > -- the checkpoint cleans the buffer dirtied by freezing the sole tuple > @@ -237,7 +237,7 @@ > FROM page_header(get_raw_page('test_vac_unmodified_heap', 0)); > ?column? > ---------- > - t > + f > (1 row) > > -- vacuum sets the VM > > I've managed to reproduce it locally with the attached and: > echo "autovacuum_naptime = 1" > /tmp/temp.config > TEMP_CONFIG=/tmp/temp.config make -s check -C contrib/pg_visibility/ > ... > ok 85 - pg_visibility 30 ms > not ok 86 - pg_visibility 165 ms > ok 87 - pg_visibility 36 ms > ... > # 1 of 100 tests failed. > > Could you please look at this? > > Probably you'll find [2] helpful. > > [1] https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=scorpion&dt=2026-01-28%2019%3A07%3A32 > [2] https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=1c64d2fcb > > Best regards, > Alexander Thanks Alexander! This is a good and detailed report, I was able to reproduce this. I have added some logs to my copy of postgres with your patch and I think problem causing this test to fail is this sequence: 1) Autovacuum starts, does its deeds, and acquiring xid = 118518 2) insert into test_vac_unmodified_heap values (1); executes and commits with xid = 118519 (from my log) 3) vacuum freeze starts and computes cutoff xid = 118518, because oldest xmin is 118518 from (1) *and we cannot freeze tuple* ``` 2026-01-29 13:27:44.559 UTC [133670] DEBUG: CommitTransaction(1) name: unnamed; blockState: STARTED; state: INPROGRESS, xid/subid/cid: 118519/1/0 (used) ... 2026-01-29 13:27:44.559 UTC [133672] DEBUG: CommitTransaction(1) name: unnamed; blockState: STARTED; state: INPROGRESS, xid/subid/cid: 118518/1/2 ... 2026-01-29 13:27:44.560 UTC [133670] INFO: finished vacuuming "contrib_regression.public.test_vac_unmodified_heap": index scans: 0 pages: 0 removed, 1 remain, 1 scanned (100.00% of total), 0 eagerly scanned tuples: 0 removed, 1 remain, 0 are dead but not yet removable removable cutoff: 118518, which was 2 XIDs old when operation ended new relfrozenxid: 118518, which is 1 XIDs ahead of previous value frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed avg read rate: 0.000 MB/s, avg write rate: 54.253 MB/s buffer usage: 22 hits, 0 reads, 3 dirtied ``` I did not come up with a fix yet though. -- Best regards, Kirill Reshke -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2026-01-29T13:51:23Z
On Thu, 29 Jan 2026 at 18:39, Kirill Reshke <reshkekirill@gmail.com> wrote: > > On Thu, 29 Jan 2026 at 10:00, Alexander Lakhin <exclusion@gmail.com> wrote: > > > > Hello Melanie, > > > > 29.01.2026 01:16, Melanie Plageman wrote: > > > > Thanks for the review! > > I pushed v33 0001-0003 after incorporating your feedback. > > > > > > The buildfarm animal scorpion has detected an instability of the addition > > to pg_visibility from 21796c267 [1]: > > > > 80/82 postgresql:pg_visibility-running / pg_visibility-running/regress ERROR 7.23s exit status 1 > > > > diff -U3 /home/bf/bf-build/scorpion/HEAD/pgsql/contrib/pg_visibility/expected/pg_visibility.out /home/bf/bf-build/scorpion/HEAD/pgsql.build/testrun/pg_visibility-running/regress/results/pg_visibility.out > > --- /home/bf/bf-build/scorpion/HEAD/pgsql/contrib/pg_visibility/expected/pg_visibility.out 2026-01-26 22:07:12.923378464 +0100 > > +++ /home/bf/bf-build/scorpion/HEAD/pgsql.build/testrun/pg_visibility-running/regress/results/pg_visibility.out 2026-01-28 20:15:13.802517085 +0100 > > @@ -213,7 +213,7 @@ > > select pg_visibility_map_summary('test_vac_unmodified_heap'); > > pg_visibility_map_summary > > --------------------------- > > - (1,1) > > + (0,0) > > (1 row) > > > > -- the checkpoint cleans the buffer dirtied by freezing the sole tuple > > @@ -237,7 +237,7 @@ > > FROM page_header(get_raw_page('test_vac_unmodified_heap', 0)); > > ?column? > > ---------- > > - t > > + f > > (1 row) > > > > -- vacuum sets the VM > > > > I've managed to reproduce it locally with the attached and: > > echo "autovacuum_naptime = 1" > /tmp/temp.config > > TEMP_CONFIG=/tmp/temp.config make -s check -C contrib/pg_visibility/ > > ... > > ok 85 - pg_visibility 30 ms > > not ok 86 - pg_visibility 165 ms > > ok 87 - pg_visibility 36 ms > > ... > > # 1 of 100 tests failed. > > > > Could you please look at this? > > > > Probably you'll find [2] helpful. > > > > [1] https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=scorpion&dt=2026-01-28%2019%3A07%3A32 > > [2] https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=1c64d2fcb > > > > Best regards, > > Alexander > > > Thanks Alexander! > This is a good and detailed report, I was able to reproduce this. > > I have added some logs to my copy of postgres with your patch and I > think problem causing this test to fail is this sequence: > > 1) Autovacuum starts, does its deeds, and acquiring xid = 118518 > 2) insert into test_vac_unmodified_heap values (1); executes and > commits with xid = 118519 (from my log) > 3) vacuum freeze starts and computes cutoff xid = 118518, because > oldest xmin is 118518 from (1) > > *and we cannot freeze tuple* > > > ``` > > 2026-01-29 13:27:44.559 UTC [133670] DEBUG: CommitTransaction(1) > name: unnamed; blockState: STARTED; state: INPROGRESS, xid/subid/cid: > 118519/1/0 (used) > ... > 2026-01-29 13:27:44.559 UTC [133672] DEBUG: CommitTransaction(1) > name: unnamed; blockState: STARTED; state: INPROGRESS, xid/subid/cid: > 118518/1/2 > ... > 2026-01-29 13:27:44.560 UTC [133670] INFO: finished vacuuming > "contrib_regression.public.test_vac_unmodified_heap": index scans: 0 > pages: 0 removed, 1 remain, 1 scanned (100.00% of total), 0 > eagerly scanned > tuples: 0 removed, 1 remain, 0 are dead but not yet removable > removable cutoff: 118518, which was 2 XIDs old when operation ended > new relfrozenxid: 118518, which is 1 XIDs ahead of previous value > frozen: 0 pages from table (0.00% of total) had 0 tuples frozen > visibility map: 0 pages set all-visible, 0 pages set > all-frozen (0 were all-visible) > index scan not needed: 0 pages from table (0.00% of total) had > 0 dead item identifiers removed > avg read rate: 0.000 MB/s, avg write rate: 54.253 MB/s > buffer usage: 22 hits, 0 reads, 3 dirtied > > > ``` > > I did not come up with a fix yet though. > > -- > Best regards, > Kirill Reshke One possible way here is to remove regression test changes made in 21796c267, and rewrite this ad TAP test. In TAP test, we can do akin to 006_singla_autovacuum.pl: ``` # From this point, autovacuum worker will wait at startup. $node->safe_psql('postgres', "SELECT injection_points_attach('autovacuum-worker-start', 'wait');"); ``` So, we can remove autovacuum xid acquirition from the test. Thoughts? -- Best regards, Kirill Reshke -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-01-29T15:16:26Z
On Thu, Jan 29, 2026 at 8:39 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > Thanks Alexander! > This is a good and detailed report, I was able to reproduce this. Thanks to both of you for looking into it! > I have added some logs to my copy of postgres with your patch and I > think problem causing this test to fail is this sequence: > > 1) Autovacuum starts, does its deeds, and acquiring xid = 118518 So, in this scenario, is the issue that autovacuum runs before vacuum freeze? If so, we can change the table DDL to: create table test_vac_unmodified_heap(a int) with (autovacuum_enabled = false); which would prevent the autovacuum from running. Unless there is some other way for one of the other tests to hold OldestXmin back to before the xid of the insert. But I don't see how. > 2) insert into test_vac_unmodified_heap values (1); executes and > commits with xid = 118519 (from my log) > 3) vacuum freeze starts and computes cutoff xid = 118518, because > oldest xmin is 118518 from (1) > > *and we cannot freeze tuple* - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2026-01-30T09:23:07Z
On Thu, 29 Jan 2026 at 20:16, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Thu, Jan 29, 2026 at 8:39 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > > > Thanks Alexander! > > This is a good and detailed report, I was able to reproduce this. > > Thanks to both of you for looking into it! > > > I have added some logs to my copy of postgres with your patch and I > > think problem causing this test to fail is this sequence: > > > > 1) Autovacuum starts, does its deeds, and acquiring xid = 118518 > > So, in this scenario, is the issue that autovacuum runs before vacuum > freeze? If so, we can change the table DDL to: > > create table test_vac_unmodified_heap(a int) with (autovacuum_enabled = false); > > which would prevent the autovacuum from running. Hi! Yes I did try this, but it does not help, because autovacuum runs on catalog relations, still causing fail. > Unless there is some other way for one of the other tests to hold > OldestXmin back to before the xid of the insert. But I don't see how. > > > 2) insert into test_vac_unmodified_heap values (1); executes and > > commits with xid = 118519 (from my log) > > 3) vacuum freeze starts and computes cutoff xid = 118518, because > > oldest xmin is 118518 from (1) > > > > *and we cannot freeze tuple* > > - Melanie -- Best regards, Kirill Reshke On Thu, 29 Jan 2026 at 20:16, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Thu, Jan 29, 2026 at 8:39 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > > > Thanks Alexander! > > This is a good and detailed report, I was able to reproduce this. > > Thanks to both of you for looking into it! > > > I have added some logs to my copy of postgres with your patch and I > > think problem causing this test to fail is this sequence: > > > > 1) Autovacuum starts, does its deeds, and acquiring xid = 118518 > > So, in this scenario, is the issue that autovacuum runs before vacuum > freeze? If so, we can change the table DDL to: > > create table test_vac_unmodified_heap(a int) with (autovacuum_enabled = false); > > which would prevent the autovacuum from running. > > Unless there is some other way for one of the other tests to hold > OldestXmin back to before the xid of the insert. But I don't see how. > > > 2) insert into test_vac_unmodified_heap values (1); executes and > > commits with xid = 118519 (from my log) > > 3) vacuum freeze starts and computes cutoff xid = 118518, because > > oldest xmin is 118518 from (1) > > > > *and we cannot freeze tuple* > > - Melanie -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andrey Borodin <x4mmm@yandex-team.ru> — 2026-01-30T09:25:03Z
> On 29 Jan 2026, at 18:51, Kirill Reshke <reshkekirill@gmail.com> wrote: > > One possible way here is to remove regression test changes made in > 21796c267, and rewrite this ad TAP test. In TAP test, we can do akin > to 006_singla_autovacuum.pl: Maybe we can just disable autovacuum in regress tests of pg_visibility? Best regards, Andrey Borodin.
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2026-01-30T09:25:17Z
On Thu, 29 Jan 2026 at 20:16, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Thu, Jan 29, 2026 at 8:39 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > > > Thanks Alexander! > > This is a good and detailed report, I was able to reproduce this. > > Thanks to both of you for looking into it! > > > I have added some logs to my copy of postgres with your patch and I > > think problem causing this test to fail is this sequence: > > > > 1) Autovacuum starts, does its deeds, and acquiring xid = 118518 > > So, in this scenario, is the issue that autovacuum runs before vacuum > freeze? If so, we can change the table DDL to: > > create table test_vac_unmodified_heap(a int) with (autovacuum_enabled = false); > > which would prevent the autovacuum from running. > > Unless there is some other way for one of the other tests to hold > OldestXmin back to before the xid of the insert. But I don't see how. > > > 2) insert into test_vac_unmodified_heap values (1); executes and > > commits with xid = 118519 (from my log) > > 3) vacuum freeze starts and computes cutoff xid = 118518, because > > oldest xmin is 118518 from (1) > > > > *and we cannot freeze tuple* > > - Melanie Sorry, I messed up my previous email. > create table test_vac_unmodified_heap(a int) with (autovacuum_enabled = false); Yes I did try this, but it does not help, because autovacuum runs on catalog relations, still causing fail. We cannot disable autovac globally in regression suite, so I propose to changes this to TAp test -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2026-01-30T09:59:57Z
On Fri, 30 Jan 2026 at 14:25, Kirill Reshke <reshkekirill@gmail.com> wrote: > > Sorry, I messed up my previous email. > > > create table test_vac_unmodified_heap(a int) with (autovacuum_enabled = false); > > Yes I did try this, but it does not help, because autovacuum runs on > catalog relations, still causing fail. > > We cannot disable autovac globally in regression suite, so I propose > to changes this to TAp test > FPA POC v1 I use 'autovacuum-worker-start' injection point to 'disable' autovacuum until test is done -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andrey Borodin <x4mmm@yandex-team.ru> — 2026-01-30T11:09:34Z
Well, converting to TAP seems feasible to me. This will make test more stable. > On 30 Jan 2026, at 14:59, Kirill Reshke <reshkekirill@gmail.com> wrote: > > I use 'autovacuum-worker-start' injection point to 'disable' > autovacuum until test is done If you just set autovacuum off - the test will be executed on bf animals that has no injection points. Also x4mmm@x4mmm-osx postgres % git am ~/Downloads/v1-0001-Reimplement-regression-tests-from-21796c267-as-TA.patch Applying: Reimplement regression tests from 21796c267 as TAP-test. .git/rebase-apply/patch:148: trailing whitespace. # Check that vacuum phase I does not need to modify the heap buffer. warning: 1 line adds whitespace errors. There are some typos: "flakky", "introduces", "actuvity", "actaully", "hilding". Test descriptions are off: you used 'page_header returned as expected' when it's actually about pg_visibility_map_summary. And, of course, total lack of comments is not good. However, in principle approach seems good to me. (FWIW I'm looking into patches 0003-0005 of v33, I'll post when I'll find some nits) Best regards, Andrey Borodin.
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-01-30T21:36:23Z
On Fri, Jan 30, 2026 at 4:25 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > > create table test_vac_unmodified_heap(a int) with (autovacuum_enabled = false); > > Yes I did try this, but it does not help, because autovacuum runs on > catalog relations, still causing fail. > > We cannot disable autovac globally in regression suite, so I propose > to changes this to TAp test Andres suggested making the table a temp table. He said other sessions vacuuming catalog tables shouldn't affect the temp table horizon. If you try that in your repro does it fix it? - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2026-01-31T05:09:35Z
On Sat, 31 Jan 2026 at 02:36, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Fri, Jan 30, 2026 at 4:25 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > > > > create table test_vac_unmodified_heap(a int) with (autovacuum_enabled = false); > > > > Yes I did try this, but it does not help, because autovacuum runs on > > catalog relations, still causing fail. > > > > We cannot disable autovac globally in regression suite, so I propose > > to changes this to TAp test > > Andres suggested making the table a temp table. He said other sessions > vacuuming catalog tables shouldn't affect the temp table horizon. If > you try that in your repro does it fix it? > > - Melanie Yes indeed, it does. -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-02-02T22:47:23Z
On Sat, Jan 31, 2026 at 12:09 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > Yes indeed, it does. Cool, I pushed the fix. Thanks to everyone who helped with debugging the issue. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2026-02-20T17:59:24Z
Hi, On 2026-01-28 18:16:10 -0500, Melanie Plageman wrote: > > > + */ > > > + if ((old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0) > > > + { > > > + vacrel->vm_new_visible_pages++; > > > + if (presult.all_frozen) > > > + { > > > + vacrel->vm_new_visible_frozen_pages++; > > > + *vm_page_frozen = true; > > > > Not this patches fault, but I find "vm_new_visible_pages" and > > "vm_new_visible_frozen_pages" pretty odd names. The concept is all-visible and > > frozen. The page itself isn't visible or invisible... > > I thought having the extra word "all" in there made it too long. And > since "vm" is there, that isn't set unless the page is > _all_-visible/all-frozen. But if you think it gives people the wrong > idea, I am willing to change it. I can omit vm and make it: > new_all_visible_all_frozen_pages > new_all_visible_pages > new_all_frozen_pages > > Is that clearer? Yes, I think so. > > It's also a bit odd that a function that sounds rather read-only does stuff > > like clearing VM/all-visible. > > I thought about this a lot. Ultimately, I ended up keeping it the way it is. > I think the other option is changing from this: > > do_set_vm = heap_page_will_set_vm(&prstate, > params->relation, > blockno, buffer, page, > vmbuffer, > params->reason, > do_prune, do_freeze, > prstate.lpdead_items, > &old_vmbits, &new_vmbits); > > to this: > > heap_page_prepare_vm_set(&prstate, > params->relation, > blockno, buffer, page, > vmbuffer, > params->reason, > do_prune, do_freeze, > prstate.lpdead_items, > &old_vmbits, &new_vmbits); > > do_set_vm = (new_vmbits & VISIBILITYMAP_VALID_BITS) != 0; > > or heap_page_plan_vm_set() > heap_page_will_set_vm() has symmetry with heap_page_will_freeze(), the > helper that decides whether or not we will freeze tuples. I like that > symmetry since heap_page_will_set_vm() decides whether or not to set > the VM. > > Now, heap_page_plan/prepare_vm_set() does indirectly hint that > something like clearing VM/all-visible could happen -- if you > understand that preparing the VM to have bits set also includes > clearing any existing corruption. And "prepare" or "plan" has more > symmetry with prune_freeze_plan() -- though that function does not > make changes on the page. > > Ultimately, clearing the VM/page of corruption is pretty anomalous > from the rest of the code in heap_page_prune_and_freeze(). All other > changes to the page are done in a single critical section at the > bottom of the function. > > I could see an argument for moving identify_and_fix_vm_corruption() > out of the helper and into heap_page_prune_and_freeze() but then we'd > have to move visibilitymap_get_status() out too. And that takes away a > lot of the benefit of encapsulating all that logic. I was wondering about that option. Relatedly, I also was wondering if we ought to do identify_and_fix_vm_corruption() regardless of ->attempt_update_vm. > > Why are we not doing fixing up of the page *before* we prune it? It's a bit > > insane that we do the WAL logging for pruning, which in turn will often > > include an FPI, before we do the fixups. The fixes aren't WAL logged, so this > > actually leads to the standby getting further out of sync. > > > > I realize this isn't your mess, but brrr. > > Well, after this patch set, clearing the VM does happen before we emit > WAL for pruning. That I think is a substantial improvement, the current (i.e. before your series) placement really is pretty insane due to the guaranteed divergence it causes. I wonder if we actually should just force an FPI whenever we detect such corruption, that way it would reliably fixed on the standby as well. > It wouldn't be hard to move the corruption fixups to the beginning of > heap_page_prune_and_freeze() in the new code structure. As identify_and_fix_vm_corruption() needs lpdead_items, I'm not sure that's true? I wonder if at least the warning for the "(PageIsAllVisible(heap_page) && nlpdead_items > 0)" test should be moved to heap_prune_record_dead_or_unused(). That way the WARNING could include the offset number and it'd also work in the mark_unused_now case. Perhaps it also should trigger for RECENTLY_DEAD, INSERT_IN_PROGRESS, DELETE_IN_PROGRESS? At that point the !page_all_visible && vm_all_visible part could indeed be moved to the start of heap_page_prune_and_freeze() > But it would split visibility map-related logic into two parts of > heap_page_prune_and_freeze(). I'm not convinced that that's an issue. new/old_vmbits area already variables at the level of heap_page_prune_and_freeze(), determining it a bit earlier seems not a problem. And I think doing this check even if !attempt_update_vm might very well make sense. > Would it be worth it? What benefit would we get? Do you just feel that it > should logically come first? One insanity is that right now we will process all frozen pages over and over due to he skip pages threshold, wasting a *lot* of CPU and memory bandwidth. It'd be quite defensible to just skip processing the page once we determined it's already all frozen. But for that we'd probably want to do the "page_all_visible && vm_all_visible" check before returning... > > Do we actually forsee a case where only one of HEAP_PAGE_PRUNE_FREEZE | > > HEAP_PAGE_PRUNE_UPDATE_VM would be set? > > Yes, when setting the VM on-access, it is too expensive to call > heap_prepare_freeze_tuple() on each tuple. I could work on trying to > optimize it, but it isn't currently viable. Is it too expensive to do so even when we already decided to do some pruning? I am not surprised it's too expensive when there's not even a dead tuple on the page. But I am mildly surprised if it's too expensive to do when we'd WAL log anyway? > > > From cdf5776fadeae3430c692999b37f8a7ec944bda1 Mon Sep 17 00:00:00 2001 > > > From: Melanie Plageman <melanieplageman@gmail.com> > > > Date: Tue, 2 Dec 2025 16:16:22 -0500 > > > > +static TransactionId > > > +get_conflict_xid(bool do_prune, bool do_freeze, bool do_set_vm, > > > + uint8 old_vmbits, uint8 new_vmbits, > > > + TransactionId latest_xid_removed, TransactionId frz_conflict_horizon, > > > + TransactionId visibility_cutoff_xid) > > > +{ > > > > The logic for horizons is now split between this and "Calculate what the > > snapshot conflict horizon should be for a record" in heap_page_will_freeze(). > > That is true in master too. We determine frz_conflict_horizon in > heap_page_will_freeze() and later before emitting the WAL record > decide which of the latest_xid_removed and frz_conflict_horizon that > we should use as the snapshot conflict horizon for the combined > record. I'm not sure that confusing code in master is a particularly good reason for anything... > All I've done is expand that part (the part before emitting the WAL > record) a bit because now we have to consider what the horizon would > be if we set the VM. > > If I really wanted to calculate it only in a single place, I could > maintain a new variable, all_frozen_except_dead, and remove the > frz_conflict_horizon from heap_page_will_freeze(). Then, in > get_conflict_xid(), I could have the following logic: > > if (do_set_vm) > conflict_xid = visibility_cutoff_xid; > else if (do_freeze) > { > if (all_frozen_except_dead) > conflict_xid = visibility_cutoff_xid; > else > { > conflict_xid = OldestXmin; > TransactionIdRetreat(conflict_xid); > } > } > else > conflict_xid = InvalidTransactionId; > > I think using all_frozen_except_dead while maintaining > visibility_cutoff_xid (in heap_prune_record_unchanged_lp_normal()) has > the potential to be confusing, though. We'd need to keep updating > visibility_cutoff_xid when all_visible is false but > all_frozen_except_dead is true as well as when all_visible is true. > And because we don't care about all_visible_except_dead, it gets even > more confusing to make sure we are maintaining the right variables in > the right situations. I suspect we should just track all of the horizons/cutoffs all the time. This whole stuff about optimizing out a few conditional assignments complicates the code substantially and feels extremely error prone to me. I probably complained about this before, and it's not this patch's fault, but PruneState->{all_visible,all_frozen} are imo confusingly named, due to sounding like they describe the current state, rather than the possible state after pruning. It's not helped by this comment: * NOTE: all_visible and all_frozen initially don't include LP_DEAD items. * That's convenient for heap_page_prune_and_freeze() to use them to * decide whether to opportunistically freeze the page or not. The * all_visible and all_frozen values ultimately used to set the VM are * adjusted to include LP_DEAD items after we determine whether or not to * opportunistically freeze. "all-visible ... are adjusted to include LP_DEAD" ... - just reading that it's hard to know what it means. > > Although I guess I don't understand that code: > > > > /* > > * Calculate what the snapshot conflict horizon should be for a record > > * freezing tuples. We can use the visibility_cutoff_xid as our cutoff > > * for conflicts when the whole page is eligible to become all-frozen > > * in the VM once we're done with it. Otherwise, we generate a > > * conservative cutoff by stepping back from OldestXmin. > > */ > > if (prstate->all_frozen) > > prstate->frz_conflict_horizon = prstate->visibility_cutoff_xid; > > else > > { > > /* Avoids false conflicts when hot_standby_feedback in use */ > > prstate->frz_conflict_horizon = prstate->cutoffs->OldestXmin; > > TransactionIdRetreat(prstate->frz_conflict_horizon); > > } > > > > Why does it make sense to use OldestXmin? Consider e.g. the case where there > > is one very old tuple that needs to be frozen and one new live tuple on a > > page. Because of the new tuple we can't mark the page all-frozen. But there's > > also no reason to not use much less aggressive horizon than OldestXmin, namely > > the newer of xmin,xmax of the old frozen tuple? > > We don't track the newest frozen xmin right now. Doing so wouldn't be > free (i.e. more comparisons which may matter in a query without much > other overhead). I have an extremely hard time to believe that tracking the Min(xmin) over all rows is going to be noticeable in comparison to all the overheads in pruning (including pruning where we do nothing). There are some checks that would be different, in particular, heap_pre_freeze_checks() is quite expensive, because it forces an access to the SLRU, without using hint bits. But that's a check that we wisely (79d4bf4eff14) do only after we already decided to freeze. The first thing to improve pruning performance that I would do is to introduce a fastpath for pages that a) area already frozen b) do not have dead items (if we're not freezing). Iterating through HOT chains is far from cheap, and if all rows are live, there's not really a point in doing so. This is particulary important for VACUUMs where we end up freezing a ton of pages that are already frozen, due to the silly skip_pages_threshold thing. > The only purpose it would serve is to make the snapshot conflict > horizon more accurate/more aggressive when we freeze tuples, which > would lead to canceling less queries than master -- which is outside > the purview of this patch. I think it'd also serve to actually make the choice of the horizon a heck of a lot easier to understand. Using approximations of the accurate value means you have to think about why that approximation is correct, whether it's too approximate etc. > There's also a set of complications around maintaining this number > accurately mentioned by Peter in [1]. > > [1] https://www.postgresql.org/message-id/CAH2-WzkB-Pt3zPeTXvMik6jcJn%2BdcpUqO-tt_hc13bD6sGRLPg%40mail.gmail.com Maybe I'm confused, but what does that email have to do with determining an accurate horizon? It argues that we can remove / freeze xmax in some situations, but doesn't seem to talk about an accurate horizon, except in an aside about not needing to take aborted xmins into account? > From e591bf061ee673f3750d1180673e1ab48be43bb8 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 27 Jan 2026 16:53:11 -0500 > Subject: [PATCH v34 03/14] Move VM assert into prune/freeze code and simplify > returned values > > After pruning and freezing, we do an assert-only validatation that the > page's visibility status matches what we found during the pruning and > freezing pass over the page. > > There's no reason to wait until lazy_scan_prune() to do this validation, > as all of the VM setting logic has already been moved to > heap_page_prune_and_freeze(). It's a bit funny to say "wait until lazy_scan_prune()", when lazy_scan_prune() is what calls heap_page_prune_and_freeze(). I'd just say that moving it to heap_page_prune_and_freeze() avoids having to pass information around and will, in the future, allow the check to be performed when freezing during on-access. > From a94267babeedec6705fd7f3b43242c6ba0e458c0 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 2 Dec 2025 16:16:22 -0500 > Subject: [PATCH v34 04/14] Eliminate XLOG_HEAP2_VISIBLE from vacuum phase I > prune/freeze > @@ -804,6 +809,62 @@ heap_page_will_freeze(Relation relation, Buffer buffer, > return do_freeze; > } > > +/* > + * Calculate the conflict horizon for the whole XLOG_HEAP2_PRUNE_VACUUM_SCAN > + * or XLOG_HEAP2_PRUNE_ON_ACCESS record. > + */ > +static TransactionId > +get_conflict_xid(bool do_prune, bool do_freeze, bool do_set_vm, > + uint8 old_vmbits, uint8 new_vmbits, > + TransactionId latest_xid_removed, TransactionId frz_conflict_horizon, > + TransactionId visibility_cutoff_xid) > +{ > + TransactionId conflict_xid; > + > + /* > + * We can omit the snapshot conflict horizon if we are not pruning or > + * freezing any tuples and are setting an already all-visible page > + * all-frozen in the VM. Maybe mention when this can happen, because it's not immediately obvious. > In this case, all of the tuples on the page must > + * already be seen as frozen by all MVCC snapshots on the standby. Maybe + " (any conflict would have been handled in reaction to the WAL record for freezing those tuples)" or such? > + */ > + if (!do_prune && > + !do_freeze && > + (old_vmbits & VISIBILITYMAP_ALL_VISIBLE) != 0 && > + (new_vmbits & VISIBILITYMAP_ALL_FROZEN) != 0) > + return InvalidTransactionId; Those != 0 check seem vestigial to me. Just checking for & VISIBILITYMAP_ALL_VISIBLE is sufficient. > + /* > + * The snapshot conflict horizon for the whole record should be the most > + * conservative of all the horizons calculated for any of the possible > + * modifications. If this record will prune tuples, any transactions on > + * the standby older than the youngest xmax of the most recently removed > + * tuple this record will prune will conflict. Why just xmax? You can have cases where xmin is a newer xid than xmax. > If this record will freeze > + * tuples, any transactions on the standby with xids older than the > + * youngest tuple this record will freeze will conflict. Transactions on the standby have no xid. Did you mean xmin? > + * If we are setting the VM, the conflict horizon is almost always the > + * visibility cutoff XID, except in the situation described above. > + * > + * By picking the newest of all of those, we can ensure that all changes > + * in the record have been taken into account. > + */ Comment seems better than before! > if (do_set_vm) > conflict_xid = visibility_cutoff_xid; > else if (do_freeze) > conflict_xid = frz_conflict_horizon; > else > conflict_xid = InvalidTransactionId; Could it be worth checking that if (do_set_vm && do_freeze) the frz_conflict_horizon won't "violated" by using visibility_cutoff_xid instead? > Subject: [PATCH v34 06/14] Remove XLOG_HEAP2_VISIBLE entirely Did not again look at this, as it seems like it ought to not be interesting... LMK if I should have. > From a64707f1f2fa88d7292f7a2f2a760c613eea4950 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 17 Dec 2025 13:57:16 -0500 > Subject: [PATCH v34 07/14] Simplify heap_page_would_be_all_visible visibility > check > > heap_page_would_be_all_visible() doesn't care about the distinction > between HEAPTUPLE_RECENTLY_DEAD and HEAPTUPLE_DEAD tuples -- any tuple > that is not HEAPTUPLE_LIVE means the page is not all-visible and causes > us to return false. > > Therefore, we don't need to call HeapTupleSatisfiesVacuum(), which > includes an extra step to distinguish between dead and recently dead > tuples using OldestXmin. Replace it with the more minimal > HeapTupleSatisfiesVacuumHorizon(). > > This has the added benefit of making it easier to replace uses of > OldestXmin in heap_page_would_be_all_visible() in the future. Seems reasonable. Can probably be pulled forward and just committed? > From 85ab0d4eb681eaba4668ee23602d425c27f56d07 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Mon, 22 Dec 2025 10:46:45 -0500 > Subject: [PATCH v34 08/14] Remove table_scan_analyze_next_tuple unneeded > parameter OldestXmin > > heapam_scan_analyze_next_tuple() doesn't distinguish between dead and > recently dead tuples when counting them, so it doesn't need OldestXmin. This part seems quite obviously the right thing to do, it's quite obviously just wasted effort right now. > Looking at other table AMs implementing table_scan_analyze_next_tuple(), > it appears most do not use OldestXmin either. Does "most" mean some do? Not sure removing a parameter that's unused by heapam is worth breakage... > From 8d350868206456f631883a40a955dff480e408d3 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 17 Dec 2025 16:51:05 -0500 > Subject: [PATCH v34 09/14] Use GlobalVisState in vacuum to determine page > level visibility > > [...] > > Because comparing a transaction ID against GlobalVisState is more > expensive than comparing against a single XID, we defer this check until > after scanning all tuples on the page. Curious, is this a precaution or was this a measurable bottleneck? > * The visibility cutoff xid is the newest xmin of live, committed tuples > - * older than OldestXmin on the page. This field is only kept up-to-date > - * if the page is all-visible. As soon as a tuple is encountered that is > - * not visible to all, this field is unmaintained. As long as it is > - * maintained, it can be used to calculate the snapshot conflict horizon > - * when updating the VM and/or freezing all the tuples on the page. > + * on the page older than the visibility horizon represented in the > + * GlobalVisState. This field is only kept up-to-date if the page is > + * all-visible. It is invalid if there are any tuples on the page that are > + * not visible to all. As long as it is maintained, it can be used to > + * calculate the snapshot conflict horizon when updating the VM and/or > + * freezing all the tuples on the page. > */ > prstate->visibility_cutoff_xid = InvalidTransactionId; > } > @@ -1077,6 +1078,24 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > prune_freeze_plan(RelationGetRelid(params->relation), > buffer, &prstate, off_loc); > > + /* > + * After processing all the live tuples on the page, if the newest xmin > + * amongst them may be considered running by any snapshot, the page cannot > + * be all-visible. > + */ > + if (prstate.all_visible && > + TransactionIdIsNormal(prstate.visibility_cutoff_xid) && Any reason to test IsNormal rather than just IsValid()? There should never be a reason it's a valid but not "normal" xid, right? > @@ -1794,28 +1812,15 @@ heap_prune_record_unchanged_lp_normal(Page page, PruneState *prstate, OffsetNumb > } > > /* > - * The inserter definitely committed. But is it old enough > - * that everyone sees it as committed? A FrozenTransactionId > - * is seen as committed to everyone. Otherwise, we check if > - * there is a snapshot that considers this xid to still be > - * running, and if so, we don't consider the page all-visible. > + * The inserter definitely committed. But we don't know if it > + * is old enough that everyone sees it as committed. Later, > + * after processing all the tuples on the page, we'll check if > + * there is any snapshot that still considers the newest xid > + * on the page to be running. If so, we don't consider the > + * page all-visible. > */ > xmin = HeapTupleHeaderGetXmin(htup); > > - /* > - * For now always use prstate->cutoffs for this test, because > - * we only update 'all_visible' and 'all_frozen' when freezing > - * is requested. We could use GlobalVisTestIsRemovableXid > - * instead, if a non-freezing caller wanted to set the VM bit. > - */ > - Assert(prstate->cutoffs); > - if (!TransactionIdPrecedes(xmin, prstate->cutoffs->OldestXmin)) > - { > - prstate->all_visible = false; > - prstate->all_frozen = false; > - break; > - } > - > /* Track newest xmin on page. */ > if (TransactionIdFollows(xmin, prstate->visibility_cutoff_xid) && > TransactionIdIsNormal(xmin)) Kinda wonder if this cod eshould be in something like heap_prune_record_freezable() or such, rather than be inside heap_prune_record_unchanged_lp_normal(). > Subject: [PATCH v34 10/14] Unset all_visible sooner if not freezing > > In the prune/freeze path, we currently delay clearing all_visible and > all_frozen in the presence of dead items to allow opportunistic > freezing. > > However, if no freezing will be attempted, there’s no need to delay. > Clearing the flags earlier avoids extra bookkeeping in > heap_prune_record_unchanged_lp_normal(). This currently has no runtime > effect because all callers that consider setting the VM also prepare > freeze plans, but upcoming changes will allow on-access pruning to set > the VM without freezing. The extra bookkeeping was noticeable in a > profile of on-access VM setting. What workload was that? Theoretically, even if we don't freeze, the page still may be all-visible or all frozen after the removal of dead items, no? Practically that won't happen, because we don't remove dead items in any of the relevant paths, but from the commit message and comments that's not entirely clear. > Subject: [PATCH v34 11/14] Track which relations are modified by a query > > Save the relids in a bitmap in the estate. A later commit will pass this > information down to scan nodes to control whether or not the scan allows > setting the visibility map while on-access pruning. We don't want to set > the visibility map if the query is just going to modify the page > immediately after. > > Reviewed-by: Chao Li <li.evan.chao@gmail.com> > diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h > index f8053d9e572..1e3cd73cf27 100644 > --- a/src/include/nodes/execnodes.h > +++ b/src/include/nodes/execnodes.h > @@ -678,6 +678,12 @@ typedef struct EState > * ExecDoInitialPruning() */ > const char *es_sourceText; /* Source text from QueryDesc */ > > + /* > + * RT indexes of relations modified by the query through a > + * UPDATE/DELETE/INSERT/MERGE or targeted by a SELECT FOR UPDATE. > + */ > + Bitmapset *es_modified_relids; > + Other EState fields are initialized in CreateExecutorState, this isn't afaict? Wonder if it's worth adding a crosscheck somewhere, verifying that if a relation is modified, it's in es_modified_relids. Otherwise this could very well silently get out of date. Also, there's some overlap between the informtion collected this way, and AcquireExecutorLocks(), ScanQueryForLocks(), which determine the needed lock modes via rte->rellockmode. > From 8205b2d7da0c3ad3cbc5cead336ced677996b37d Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 3 Dec 2025 15:12:18 -0500 > Subject: [PATCH v34 12/14] Pass down information on table modification to scan > node > > Pass down information to sequential scan, index [only] scan, and bitmap > table scan nodes on whether or not the query modifies the relation being > scanned. A later commit will use this information to update the VM > during on-access pruning only if the relation is not modified by the > query. Perhaps worth splitting up, so the addition of the 0 flag is separate from the the read only hint aspect. Unfortunately ran out of time for the last two patches. Greetings, Andres Freund -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2026-02-20T21:34:47Z
Hi, On 2026-01-28 18:16:10 -0500, Melanie Plageman wrote: > Subject: [PATCH v34 13/14] Allow on-access pruning to set pages all-visible > > Many queries do not modify the underlying relation. For such queries, if > on-access pruning occurs during the scan, we can check whether the page > has become all-visible and update the visibility map accordingly. > Previously, only vacuum and COPY FREEZE marked pages as all-visible or > all-frozen. > > This commit implements on-access VM setting for sequential scans as well > as for the underlying heap relation in index scans and bitmap heap > scans. For evaluating this, did you build anything that evaluates the frequency of this succeeding, causing unnecessary un-all-visibling etc during benchmarks? > diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c > index 1a3fa8a76aa..7d22549a290 100644 > --- a/src/backend/access/heap/heapam.c > +++ b/src/backend/access/heap/heapam.c > @@ -617,6 +617,7 @@ heap_prepare_pagescan(TableScanDesc sscan) > Buffer buffer = scan->rs_cbuf; > BlockNumber block = scan->rs_cblock; > Snapshot snapshot; > + Buffer *vmbuffer = NULL; > Page page; > int lines; > bool all_visible; > @@ -631,7 +632,9 @@ heap_prepare_pagescan(TableScanDesc sscan) > /* > * Prune and repair fragmentation for the whole page, if possible. > */ > - heap_page_prune_opt(scan->rs_base.rs_rd, buffer); > + if (sscan->rs_flags & SO_HINT_REL_READ_ONLY) > + vmbuffer = &scan->rs_vmbuffer; > + heap_page_prune_opt(scan->rs_base.rs_rd, buffer, vmbuffer); I don't love that the signalling to heap_page_prune_opt() about this is by passing vmbuffer or NULL. We clearly don't want to actually freeze rows if we're doing an update and might just update the rows again. But it's less clear to me that, if we are pruning dead row versions *and* the page is already all-visible after that (say because only HOT versions were removed), we shouldn't mark the page as such? > @@ -306,6 +312,13 @@ heap_page_prune_opt(Relation relation, Buffer buffer) > .cutoffs = NULL, > }; > > + if (vmbuffer) > + { > + visibilitymap_pin(relation, BufferGetBlockNumber(buffer), vmbuffer); > + params.options |= HEAP_PAGE_PRUNE_UPDATE_VM; > + params.vmbuffer = *vmbuffer; Why do we pin the buffer at this time, rather than deferring that until we actually need it? I guess we just always will access it, but that doesn't seem like it's inherent (c.f. my earlier points about a faster exit when looking at an already all-frozen page or such). It's not clear to me why we are pinning the page in lazy_scan_heap(), before it's clear that we need it, either. But there the cost is often very low, because we have a lot of sequential accesses. But here we might be called from an index scan, with very little locality of access. Greetings, Andres Freund -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-02T23:38:07Z
On Fri, Feb 20, 2026 at 12:59 PM Andres Freund <andres@anarazel.de> wrote: > > On 2026-01-28 18:16:10 -0500, Melanie Plageman wrote: > > > I could see an argument for moving identify_and_fix_vm_corruption() > > out of the helper and into heap_page_prune_and_freeze() but then we'd > > have to move visibilitymap_get_status() out too. And that takes away a > > lot of the benefit of encapsulating all that logic. > > I was wondering about that option. Relatedly, I also was wondering if we ought > to do identify_and_fix_vm_corruption() regardless of ->attempt_update_vm. Attached v35 does this. I always pin the vmbuffer if we are going to prune in heap_page_prune_opt(). In many cases, because it's saved in the scan descriptor, it won't actually need to take a new pin. During pruning, I check for VM corruption even if I am not considering setting the VM. > > Well, after this patch set, clearing the VM does happen before we emit > > WAL for pruning. > > That I think is a substantial improvement, the current (i.e. before your > series) placement really is pretty insane due to the guaranteed divergence it > causes. > > I wonder if we actually should just force an FPI whenever we detect such > corruption, that way it would reliably fixed on the standby as well. Only problem is we would have to do an FPI of the VM page as well if we wanted the corruption to be reliably fixed on the standby. > > It wouldn't be hard to move the corruption fixups to the beginning of > > heap_page_prune_and_freeze() in the new code structure. > > As identify_and_fix_vm_corruption() needs lpdead_items, I'm not sure that's > true? > > I wonder if at least the warning for the "(PageIsAllVisible(heap_page) && > nlpdead_items > 0)" test should be moved to > heap_prune_record_dead_or_unused(). That way the WARNING could include the > offset number and it'd also work in the mark_unused_now case. > > Perhaps it also should trigger for RECENTLY_DEAD, INSERT_IN_PROGRESS, > DELETE_IN_PROGRESS? > > At that point the !page_all_visible && vm_all_visible part could indeed be > moved to the start of heap_page_prune_and_freeze() I've done all this. There is heap page/VM corruption check at the beginning of heap_page_prune_and_freeze() and then checking for corruption during pruning in the previously covered case (lpdead items) as well as the mark_unused_now case, and RECENTLY_DEAD/INSERT_IN_PROGRESS/DELETE_IN_PROGRESS. > > Would it be worth it? What benefit would we get? Do you just feel that it > > should logically come first? > > One insanity is that right now we will process all frozen pages over and over > due to he skip pages threshold, wasting a *lot* of CPU and memory bandwidth. > It'd be quite defensible to just skip processing the page once we determined > it's already all frozen. But for that we'd probably want to do the > "page_all_visible && vm_all_visible" check before returning... I've added a fast path to bypass pruning/freezing when the page is already all-visible. And I check for pg_all_visible && vm_all_visible beforehand. The one downside this has is if there is a page marked all-frozen but has dead tuples on it, we'll never get to fix that corruption nor clean up the dead tuples. But the fast path kind of seems worth it to me. > > > Do we actually forsee a case where only one of HEAP_PAGE_PRUNE_FREEZE | > > > HEAP_PAGE_PRUNE_UPDATE_VM would be set? > > > > Yes, when setting the VM on-access, it is too expensive to call > > heap_prepare_freeze_tuple() on each tuple. I could work on trying to > > optimize it, but it isn't currently viable. > > Is it too expensive to do so even when we already decided to do some pruning? > I am not surprised it's too expensive when there's not even a dead tuple on > the page. But I am mildly surprised if it's too expensive to do when we'd WAL > log anyway? It's not really possible in the current code structure to only call heap_prepare_freeze_tuple() when there are at least some prunable tuples. We go through the line pointers and record them as prunable at the same time we call heap_prepare_freeze_tuple(), so we won't know until we've examined all line pointers that there are no prunable tuples, at which point we will have called heap_prepare_freeze_tuple() for every tuple. > > I think using all_frozen_except_dead while maintaining > > visibility_cutoff_xid (in heap_prune_record_unchanged_lp_normal()) has > > the potential to be confusing, though. We'd need to keep updating > > visibility_cutoff_xid when all_visible is false but > > all_frozen_except_dead is true as well as when all_visible is true. > > And because we don't care about all_visible_except_dead, it gets even > > more confusing to make sure we are maintaining the right variables in > > the right situations. > > I suspect we should just track all of the horizons/cutoffs all the time. This > whole stuff about optimizing out a few conditional assignments complicates the > code substantially and feels extremely error prone to me. I've done this in v35. I posted the freeze horizon tracking patch separately in [1] but it is in v35 as 0004. Tracking the newest live xid is in 0009. This also always tracks all_visible for all callers since I unconditionally pass the vmbuffer now. I still don't set the VM if the query is modifying the relation, though. > I probably complained about this before, and it's not this patch's fault, but > PruneState->{all_visible,all_frozen} are imo confusingly named, due to > sounding like they describe the current state, rather than the possible state > after pruning. It's not helped by this comment: > > * NOTE: all_visible and all_frozen initially don't include LP_DEAD items. > * That's convenient for heap_page_prune_and_freeze() to use them to > * decide whether to opportunistically freeze the page or not. The > * all_visible and all_frozen values ultimately used to set the VM are > * adjusted to include LP_DEAD items after we determine whether or not to > * opportunistically freeze. > > "all-visible ... are adjusted to include LP_DEAD" ... - just reading that it's > hard to know what it means. 0003 does the rename. > The first thing to improve pruning performance that I would do is to introduce > a fastpath for pages that a) area already frozen b) do not have dead items (if > we're not freezing). Iterating through HOT chains is far from cheap, and if > all rows are live, there's not really a point in doing so. This is > particulary important for VACUUMs where we end up freezing a ton of pages that > are already frozen, due to the silly skip_pages_threshold thing. 0007 adds a fast path. > > +static TransactionId > > +get_conflict_xid(bool do_prune, bool do_freeze, bool do_set_vm, > > + uint8 old_vmbits, uint8 new_vmbits, > > + TransactionId latest_xid_removed, TransactionId frz_conflict_horizon, > > + TransactionId visibility_cutoff_xid) > > +{ > > + TransactionId conflict_xid; > > + > > + /* > > + * We can omit the snapshot conflict horizon if we are not pruning or > > + * freezing any tuples and are setting an already all-visible page > > + * all-frozen in the VM. > > Maybe mention when this can happen, because it's not immediately obvious. I've added this to my TODO. I honestly can't think of a scenario where it can happen. But I remember spending quite a bit of time thinking about it on another occasion. The current code (in master) does specifically account for this scenario, which is why I kept the logic, but I'm not sure how it can happen. I made all the other changes to specific comments you mentioned in your mail but I won't bore you with itemization. > > if (do_set_vm) > > conflict_xid = visibility_cutoff_xid; > > else if (do_freeze) > > conflict_xid = frz_conflict_horizon; > > else > > conflict_xid = InvalidTransactionId; > > Could it be worth checking that if (do_set_vm && do_freeze) the > frz_conflict_horizon won't "violated" by using visibility_cutoff_xid instead? Yes, as you mentioned off-list, this wasn't right. New code is like this TransactionId conflict_xid = InvalidTransactionId; ... if (do_set_vm) conflict_xid = newest_live_xid; if (do_freeze && TransactionIdFollows(newest_frozen_xid, conflict_xid)) conflict_xid = newest_frozen_xid; > > From 8d350868206456f631883a40a955dff480e408d3 Mon Sep 17 00:00:00 2001 > > From: Melanie Plageman <melanieplageman@gmail.com> > > Date: Wed, 17 Dec 2025 16:51:05 -0500 > > Subject: [PATCH v34 09/14] Use GlobalVisState in vacuum to determine page > > level visibility > > > > [...] > > > > Because comparing a transaction ID against GlobalVisState is more > > expensive than comparing against a single XID, we defer this check until > > after scanning all tuples on the page. > > Curious, is this a precaution or was this a measurable bottleneck? I did see GlobalVisTestXidMaybeRunning() in a profile I did when it was still called for every HEAPTUPLE_LIVE tuple in heap_prune_record_unchanged_lp_normal(), but I don't have the profile or test case around anymore. However, since I now unconditionally maintain the newest_live_xid, moving GlobalVisTestXidMaybeRunning() back into heap_prune_record_unchanged_lp_normal() wouldn't help us avoid any work. It would just make the values of prstate.set_all_visible and prstate.set_all_frozen more accurate sooner. But I don't think it's worth the extra function call since set_all_frozen and set_all_visible won't be totally "done" until after we decide whether or not to opportunistically freeze anyway. > > @@ -1077,6 +1078,24 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > > prune_freeze_plan(RelationGetRelid(params->relation), > > buffer, &prstate, off_loc); > > > > + /* > > + * After processing all the live tuples on the page, if the newest xmin > > + * amongst them may be considered running by any snapshot, the page cannot > > + * be all-visible. > > + */ > > + if (prstate.all_visible && > > + TransactionIdIsNormal(prstate.visibility_cutoff_xid) && > > Any reason to test IsNormal rather than just IsValid()? There should never be > a reason it's a valid but not "normal" xid, right? Well the reason I did this was that the existing code in master tracking visibility_cutoff_xid only advances it if TransactionIdIsNormal(). I'm a bit confused about it too because it seems like we would still want to do it for bootstrap mode xids. But I see PageSetPrunable() only allows normal xids. > > @@ -1794,28 +1812,15 @@ heap_prune_record_unchanged_lp_normal(Page page, PruneState *prstate, OffsetNumb > > } > > > > /* > > - * The inserter definitely committed. But is it old enough > > - * that everyone sees it as committed? A FrozenTransactionId > > - * is seen as committed to everyone. Otherwise, we check if > > - * there is a snapshot that considers this xid to still be > > - * running, and if so, we don't consider the page all-visible. > > + * The inserter definitely committed. But we don't know if it > > + * is old enough that everyone sees it as committed. Later, > > + * after processing all the tuples on the page, we'll check if > > + * there is any snapshot that still considers the newest xid > > + * on the page to be running. If so, we don't consider the > > + * page all-visible. > > */ > > xmin = HeapTupleHeaderGetXmin(htup); > > > > - /* > > - * For now always use prstate->cutoffs for this test, because > > - * we only update 'all_visible' and 'all_frozen' when freezing > > - * is requested. We could use GlobalVisTestIsRemovableXid > > - * instead, if a non-freezing caller wanted to set the VM bit. > > - */ > > - Assert(prstate->cutoffs); > > - if (!TransactionIdPrecedes(xmin, prstate->cutoffs->OldestXmin)) > > - { > > - prstate->all_visible = false; > > - prstate->all_frozen = false; > > - break; > > - } > > - > > /* Track newest xmin on page. */ > > if (TransactionIdFollows(xmin, prstate->visibility_cutoff_xid) && > > TransactionIdIsNormal(xmin)) > > Kinda wonder if this cod eshould be in something like > heap_prune_record_freezable() or such, rather than be inside > heap_prune_record_unchanged_lp_normal(). I played around with it, but it all felt a bit awkward. I wrote it down for a future enhancement idea. > > Subject: [PATCH v34 10/14] Unset all_visible sooner if not freezing > > > > In the prune/freeze path, we currently delay clearing all_visible and > > all_frozen in the presence of dead items to allow opportunistic > > freezing. > > > > However, if no freezing will be attempted, there’s no need to delay. > > Clearing the flags earlier avoids extra bookkeeping in > > heap_prune_record_unchanged_lp_normal(). This currently has no runtime > > effect because all callers that consider setting the VM also prepare > > freeze plans, but upcoming changes will allow on-access pruning to set > > the VM without freezing. The extra bookkeeping was noticeable in a > > profile of on-access VM setting. > > What workload was that? It was a select * offset all query with a few fat tuples on each page and none of them prunable. I'm planning on digging up the case/creating a new one to see if it is reproducible. This was with an older version of the code that had more conditionals as well. This commit is actually dropped in v35 because I now always keep newest_live_xid up-to-date (0009) which means unsetting set_all_visible sooner has no benefit. > Theoretically, even if we don't freeze, the page still may be all-visible or > all frozen after the removal of dead items, no? Practically that won't happen, > because we don't remove dead items in any of the relevant paths, but from the > commit message and comments that's not entirely clear. Yea, it's clearer with the commit dropped. > > @@ -678,6 +678,12 @@ typedef struct EState > > * ExecDoInitialPruning() */ > > const char *es_sourceText; /* Source text from QueryDesc */ > > > > + /* > > + * RT indexes of relations modified by the query through a > > + * UPDATE/DELETE/INSERT/MERGE or targeted by a SELECT FOR UPDATE. > > + */ > > + Bitmapset *es_modified_relids; > > + > > Other EState fields are initialized in CreateExecutorState, this isn't afaict? Oops, yes. I based it on es_unpruned_relids which wasn't initialized there either. I've added a commit (0013) to initialize a few EState fields that weren't initialized in CreateExecutorState() as well. > Wonder if it's worth adding a crosscheck somewhere, verifying that if a > relation is modified, it's in es_modified_relids. Otherwise this could very > well silently get out of date. Done in v35 (0014). > Also, there's some overlap between the informtion collected this way, and > AcquireExecutorLocks(), ScanQueryForLocks(), which determine the needed lock > modes via rte->rellockmode. Those are in parser/planner, so it doesn't seem like a good fit. I populate es_modified_relids in the executor. I don't know exactly what the overlap would be between RTEs with an exclusive rellockmode and es_modified_relids. It seems like you could have RTEs which don't end up getting modified that have a lock level that would have made you think that they would be modified. But were you imagining a substitution or a cross-check? > > From 8205b2d7da0c3ad3cbc5cead336ced677996b37d Mon Sep 17 00:00:00 2001 > > From: Melanie Plageman <melanieplageman@gmail.com> > > Date: Wed, 3 Dec 2025 15:12:18 -0500 > > Subject: [PATCH v34 12/14] Pass down information on table modification to scan > > node > > Perhaps worth splitting up, so the addition of the 0 flag is separate from the > the read only hint aspect. Done. [1] https://www.postgresql.org/message-id/CAAKRu_bbaUV8OUjAfVa_iALgKnTSfB4gO3jnkfpcFgrxEpSGJQ%40mail.gmail.com -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-03T00:04:24Z
On Fri, Feb 20, 2026 at 4:34 PM Andres Freund <andres@anarazel.de> wrote: > > On 2026-01-28 18:16:10 -0500, Melanie Plageman wrote: > > Subject: [PATCH v34 13/14] Allow on-access pruning to set pages all-visible > > > > Many queries do not modify the underlying relation. For such queries, if > > on-access pruning occurs during the scan, we can check whether the page > > has become all-visible and update the visibility map accordingly. > > Previously, only vacuum and COPY FREEZE marked pages as all-visible or > > all-frozen. > > > > This commit implements on-access VM setting for sequential scans as well > > as for the underlying heap relation in index scans and bitmap heap > > scans. > > For evaluating this, did you build anything that evaluates the frequency of > this succeeding, causing unnecessary un-all-visibling etc during benchmarks? I didn't develop a specific micro-benchmark for this, but I did run some generic pgbenches (which does a single tuple update on accounts followed by a select) because I thought there would be a good amount of un-all-visibling there. I didn't gather stats to confirm though and who knows with a random data distribution (IIRC it was a relatively small working set, but still). I can develop something more targeted, though. > > @@ -631,7 +632,9 @@ heap_prepare_pagescan(TableScanDesc sscan) > > /* > > * Prune and repair fragmentation for the whole page, if possible. > > */ > > - heap_page_prune_opt(scan->rs_base.rs_rd, buffer); > > + if (sscan->rs_flags & SO_HINT_REL_READ_ONLY) > > + vmbuffer = &scan->rs_vmbuffer; > > + heap_page_prune_opt(scan->rs_base.rs_rd, buffer, vmbuffer); > > I don't love that the signalling to heap_page_prune_opt() about this is by > passing vmbuffer or NULL. v35 is more explicit and heap_page_prune_opt() has a rel_read_only flag. > We clearly don't want to actually freeze rows if we're doing an update and > might just update the rows again. But it's less clear to me that, if we are > pruning dead row versions *and* the page is already all-visible after that > (say because only HOT versions were removed), we shouldn't mark the page as > such? If we're doing an update and the new tuple fits on the same page, then the page will not be all-visible by the time the update is over, right? And if the new tuple doesn't fit on the same page as the old tuple, then while it would be nice to mark the old page as all-visible, don't we on-access prune the page before actually updating the tuple? Like we are scanning in the old page to update it and on-access prune then to make space for it and then we make the page modification. > > @@ -306,6 +312,13 @@ heap_page_prune_opt(Relation relation, Buffer buffer) > > .cutoffs = NULL, > > }; > > > > + if (vmbuffer) > > + { > > + visibilitymap_pin(relation, BufferGetBlockNumber(buffer), vmbuffer); > > + params.options |= HEAP_PAGE_PRUNE_UPDATE_VM; > > + params.vmbuffer = *vmbuffer; > > Why do we pin the buffer at this time, rather than deferring that until we > actually need it? I guess we just always will access it, but that doesn't > seem like it's inherent (c.f. my earlier points about a faster exit when > looking at an already all-frozen page or such). We would need to pin the VM to see if it is all-frozen to exit early. For the on-access case, since we won't freeze, we could rely on PD_ALL_VISIBLE to exit early, but that means we wouldn't be able to identify and fix PD_ALL_VISIBLE/VM-all-visible mismatches. > It's not clear to me why we are pinning the page in lazy_scan_heap(), before > it's clear that we need it, either. But there the cost is often very low, > because we have a lot of sequential accesses. But here we might be called > from an index scan, with very little locality of access. Now that, as of v35, we check for VM corruption unconditionally at the start of heap_page_prune_and_freeze() and check the VM to potentially exit early, there's no benefit in deferring pinning the VM in either vacuum or on-access. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2026-03-03T07:32:29Z
> On Mar 3, 2026, at 07:38, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Fri, Feb 20, 2026 at 12:59 PM Andres Freund <andres@anarazel.de> wrote: >> >> On 2026-01-28 18:16:10 -0500, Melanie Plageman wrote: >> >>> I could see an argument for moving identify_and_fix_vm_corruption() >>> out of the helper and into heap_page_prune_and_freeze() but then we'd >>> have to move visibilitymap_get_status() out too. And that takes away a >>> lot of the benefit of encapsulating all that logic. >> >> I was wondering about that option. Relatedly, I also was wondering if we ought >> to do identify_and_fix_vm_corruption() regardless of ->attempt_update_vm. > > Attached v35 does this. I always pin the vmbuffer if we are going to > prune in heap_page_prune_opt(). In many cases, because it's saved in > the scan descriptor, it won't actually need to take a new pin. During > pruning, I check for VM corruption even if I am not considering > setting the VM. > >>> Well, after this patch set, clearing the VM does happen before we emit >>> WAL for pruning. >> >> That I think is a substantial improvement, the current (i.e. before your >> series) placement really is pretty insane due to the guaranteed divergence it >> causes. >> >> I wonder if we actually should just force an FPI whenever we detect such >> corruption, that way it would reliably fixed on the standby as well. > > Only problem is we would have to do an FPI of the VM page as well if > we wanted the corruption to be reliably fixed on the standby. > >>> It wouldn't be hard to move the corruption fixups to the beginning of >>> heap_page_prune_and_freeze() in the new code structure. >> >> As identify_and_fix_vm_corruption() needs lpdead_items, I'm not sure that's >> true? >> >> I wonder if at least the warning for the "(PageIsAllVisible(heap_page) && >> nlpdead_items > 0)" test should be moved to >> heap_prune_record_dead_or_unused(). That way the WARNING could include the >> offset number and it'd also work in the mark_unused_now case. >> >> Perhaps it also should trigger for RECENTLY_DEAD, INSERT_IN_PROGRESS, >> DELETE_IN_PROGRESS? >> >> At that point the !page_all_visible && vm_all_visible part could indeed be >> moved to the start of heap_page_prune_and_freeze() > > I've done all this. There is heap page/VM corruption check at the > beginning of heap_page_prune_and_freeze() and then checking for > corruption during pruning in the previously covered case (lpdead > items) as well as the mark_unused_now case, and > RECENTLY_DEAD/INSERT_IN_PROGRESS/DELETE_IN_PROGRESS. > >>> Would it be worth it? What benefit would we get? Do you just feel that it >>> should logically come first? >> >> One insanity is that right now we will process all frozen pages over and over >> due to he skip pages threshold, wasting a *lot* of CPU and memory bandwidth. >> It'd be quite defensible to just skip processing the page once we determined >> it's already all frozen. But for that we'd probably want to do the >> "page_all_visible && vm_all_visible" check before returning... > > I've added a fast path to bypass pruning/freezing when the page is > already all-visible. And I check for pg_all_visible && vm_all_visible > beforehand. The one downside this has is if there is a page marked > all-frozen but has dead tuples on it, we'll never get to fix that > corruption nor clean up the dead tuples. But the fast path kind of > seems worth it to me. > >>>> Do we actually forsee a case where only one of HEAP_PAGE_PRUNE_FREEZE | >>>> HEAP_PAGE_PRUNE_UPDATE_VM would be set? >>> >>> Yes, when setting the VM on-access, it is too expensive to call >>> heap_prepare_freeze_tuple() on each tuple. I could work on trying to >>> optimize it, but it isn't currently viable. >> >> Is it too expensive to do so even when we already decided to do some pruning? >> I am not surprised it's too expensive when there's not even a dead tuple on >> the page. But I am mildly surprised if it's too expensive to do when we'd WAL >> log anyway? > > It's not really possible in the current code structure to only call > heap_prepare_freeze_tuple() when there are at least some prunable > tuples. We go through the line pointers and record them as prunable at > the same time we call heap_prepare_freeze_tuple(), so we won't know > until we've examined all line pointers that there are no prunable > tuples, at which point we will have called heap_prepare_freeze_tuple() > for every tuple. > >>> I think using all_frozen_except_dead while maintaining >>> visibility_cutoff_xid (in heap_prune_record_unchanged_lp_normal()) has >>> the potential to be confusing, though. We'd need to keep updating >>> visibility_cutoff_xid when all_visible is false but >>> all_frozen_except_dead is true as well as when all_visible is true. >>> And because we don't care about all_visible_except_dead, it gets even >>> more confusing to make sure we are maintaining the right variables in >>> the right situations. >> >> I suspect we should just track all of the horizons/cutoffs all the time. This >> whole stuff about optimizing out a few conditional assignments complicates the >> code substantially and feels extremely error prone to me. > > I've done this in v35. I posted the freeze horizon tracking patch > separately in [1] but it is in v35 as 0004. Tracking the newest live > xid is in 0009. This also always tracks all_visible for all callers > since I unconditionally pass the vmbuffer now. I still don't set the > VM if the query is modifying the relation, though. > >> I probably complained about this before, and it's not this patch's fault, but >> PruneState->{all_visible,all_frozen} are imo confusingly named, due to >> sounding like they describe the current state, rather than the possible state >> after pruning. It's not helped by this comment: >> >> * NOTE: all_visible and all_frozen initially don't include LP_DEAD items. >> * That's convenient for heap_page_prune_and_freeze() to use them to >> * decide whether to opportunistically freeze the page or not. The >> * all_visible and all_frozen values ultimately used to set the VM are >> * adjusted to include LP_DEAD items after we determine whether or not to >> * opportunistically freeze. >> >> "all-visible ... are adjusted to include LP_DEAD" ... - just reading that it's >> hard to know what it means. > > 0003 does the rename. > >> The first thing to improve pruning performance that I would do is to introduce >> a fastpath for pages that a) area already frozen b) do not have dead items (if >> we're not freezing). Iterating through HOT chains is far from cheap, and if >> all rows are live, there's not really a point in doing so. This is >> particulary important for VACUUMs where we end up freezing a ton of pages that >> are already frozen, due to the silly skip_pages_threshold thing. > > 0007 adds a fast path. > >>> +static TransactionId >>> +get_conflict_xid(bool do_prune, bool do_freeze, bool do_set_vm, >>> + uint8 old_vmbits, uint8 new_vmbits, >>> + TransactionId latest_xid_removed, TransactionId frz_conflict_horizon, >>> + TransactionId visibility_cutoff_xid) >>> +{ >>> + TransactionId conflict_xid; >>> + >>> + /* >>> + * We can omit the snapshot conflict horizon if we are not pruning or >>> + * freezing any tuples and are setting an already all-visible page >>> + * all-frozen in the VM. >> >> Maybe mention when this can happen, because it's not immediately obvious. > > I've added this to my TODO. I honestly can't think of a scenario where > it can happen. But I remember spending quite a bit of time thinking > about it on another occasion. The current code (in master) does > specifically account for this scenario, which is why I kept the logic, > but I'm not sure how it can happen. > > I made all the other changes to specific comments you mentioned in > your mail but I won't bore you with itemization. > >>> if (do_set_vm) >>> conflict_xid = visibility_cutoff_xid; >>> else if (do_freeze) >>> conflict_xid = frz_conflict_horizon; >>> else >>> conflict_xid = InvalidTransactionId; >> >> Could it be worth checking that if (do_set_vm && do_freeze) the >> frz_conflict_horizon won't "violated" by using visibility_cutoff_xid instead? > > Yes, as you mentioned off-list, this wasn't right. New code is like this > > TransactionId conflict_xid = InvalidTransactionId; > ... > if (do_set_vm) > conflict_xid = newest_live_xid; > if (do_freeze && TransactionIdFollows(newest_frozen_xid, conflict_xid)) > conflict_xid = newest_frozen_xid; > >>> From 8d350868206456f631883a40a955dff480e408d3 Mon Sep 17 00:00:00 2001 >>> From: Melanie Plageman <melanieplageman@gmail.com> >>> Date: Wed, 17 Dec 2025 16:51:05 -0500 >>> Subject: [PATCH v34 09/14] Use GlobalVisState in vacuum to determine page >>> level visibility >>> >>> [...] >>> >>> Because comparing a transaction ID against GlobalVisState is more >>> expensive than comparing against a single XID, we defer this check until >>> after scanning all tuples on the page. >> >> Curious, is this a precaution or was this a measurable bottleneck? > > I did see GlobalVisTestXidMaybeRunning() in a profile I did when it > was still called for every HEAPTUPLE_LIVE tuple in > heap_prune_record_unchanged_lp_normal(), but I don't have the profile > or test case around anymore. > > However, since I now unconditionally maintain the newest_live_xid, > moving GlobalVisTestXidMaybeRunning() back into > heap_prune_record_unchanged_lp_normal() wouldn't help us avoid any > work. It would just make the values of prstate.set_all_visible and > prstate.set_all_frozen more accurate sooner. But I don't think it's > worth the extra function call since set_all_frozen and set_all_visible > won't be totally "done" until after we decide whether or not to > opportunistically freeze anyway. > >>> @@ -1077,6 +1078,24 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, >>> prune_freeze_plan(RelationGetRelid(params->relation), >>> buffer, &prstate, off_loc); >>> >>> + /* >>> + * After processing all the live tuples on the page, if the newest xmin >>> + * amongst them may be considered running by any snapshot, the page cannot >>> + * be all-visible. >>> + */ >>> + if (prstate.all_visible && >>> + TransactionIdIsNormal(prstate.visibility_cutoff_xid) && >> >> Any reason to test IsNormal rather than just IsValid()? There should never be >> a reason it's a valid but not "normal" xid, right? > > Well the reason I did this was that the existing code in master > tracking visibility_cutoff_xid only advances it if > TransactionIdIsNormal(). I'm a bit confused about it too because it > seems like we would still want to do it for bootstrap mode xids. But I > see PageSetPrunable() only allows normal xids. > >>> @@ -1794,28 +1812,15 @@ heap_prune_record_unchanged_lp_normal(Page page, PruneState *prstate, OffsetNumb >>> } >>> >>> /* >>> - * The inserter definitely committed. But is it old enough >>> - * that everyone sees it as committed? A FrozenTransactionId >>> - * is seen as committed to everyone. Otherwise, we check if >>> - * there is a snapshot that considers this xid to still be >>> - * running, and if so, we don't consider the page all-visible. >>> + * The inserter definitely committed. But we don't know if it >>> + * is old enough that everyone sees it as committed. Later, >>> + * after processing all the tuples on the page, we'll check if >>> + * there is any snapshot that still considers the newest xid >>> + * on the page to be running. If so, we don't consider the >>> + * page all-visible. >>> */ >>> xmin = HeapTupleHeaderGetXmin(htup); >>> >>> - /* >>> - * For now always use prstate->cutoffs for this test, because >>> - * we only update 'all_visible' and 'all_frozen' when freezing >>> - * is requested. We could use GlobalVisTestIsRemovableXid >>> - * instead, if a non-freezing caller wanted to set the VM bit. >>> - */ >>> - Assert(prstate->cutoffs); >>> - if (!TransactionIdPrecedes(xmin, prstate->cutoffs->OldestXmin)) >>> - { >>> - prstate->all_visible = false; >>> - prstate->all_frozen = false; >>> - break; >>> - } >>> - >>> /* Track newest xmin on page. */ >>> if (TransactionIdFollows(xmin, prstate->visibility_cutoff_xid) && >>> TransactionIdIsNormal(xmin)) >> >> Kinda wonder if this cod eshould be in something like >> heap_prune_record_freezable() or such, rather than be inside >> heap_prune_record_unchanged_lp_normal(). > > I played around with it, but it all felt a bit awkward. I wrote it > down for a future enhancement idea. > >>> Subject: [PATCH v34 10/14] Unset all_visible sooner if not freezing >>> >>> In the prune/freeze path, we currently delay clearing all_visible and >>> all_frozen in the presence of dead items to allow opportunistic >>> freezing. >>> >>> However, if no freezing will be attempted, there’s no need to delay. >>> Clearing the flags earlier avoids extra bookkeeping in >>> heap_prune_record_unchanged_lp_normal(). This currently has no runtime >>> effect because all callers that consider setting the VM also prepare >>> freeze plans, but upcoming changes will allow on-access pruning to set >>> the VM without freezing. The extra bookkeeping was noticeable in a >>> profile of on-access VM setting. >> >> What workload was that? > > It was a select * offset all query with a few fat tuples on each page > and none of them prunable. I'm planning on digging up the > case/creating a new one to see if it is reproducible. This was with an > older version of the code that had more conditionals as well. This > commit is actually dropped in v35 because I now always keep > newest_live_xid up-to-date (0009) which means unsetting > set_all_visible sooner has no benefit. > >> Theoretically, even if we don't freeze, the page still may be all-visible or >> all frozen after the removal of dead items, no? Practically that won't happen, >> because we don't remove dead items in any of the relevant paths, but from the >> commit message and comments that's not entirely clear. > > Yea, it's clearer with the commit dropped. > >>> @@ -678,6 +678,12 @@ typedef struct EState >>> * ExecDoInitialPruning() */ >>> const char *es_sourceText; /* Source text from QueryDesc */ >>> >>> + /* >>> + * RT indexes of relations modified by the query through a >>> + * UPDATE/DELETE/INSERT/MERGE or targeted by a SELECT FOR UPDATE. >>> + */ >>> + Bitmapset *es_modified_relids; >>> + >> >> Other EState fields are initialized in CreateExecutorState, this isn't afaict? > > Oops, yes. I based it on es_unpruned_relids which wasn't initialized > there either. I've added a commit (0013) to initialize a few EState > fields that weren't initialized in CreateExecutorState() as well. > >> Wonder if it's worth adding a crosscheck somewhere, verifying that if a >> relation is modified, it's in es_modified_relids. Otherwise this could very >> well silently get out of date. > > Done in v35 (0014). > >> Also, there's some overlap between the informtion collected this way, and >> AcquireExecutorLocks(), ScanQueryForLocks(), which determine the needed lock >> modes via rte->rellockmode. > > Those are in parser/planner, so it doesn't seem like a good fit. I > populate es_modified_relids in the executor. > > I don't know exactly what the overlap would be between RTEs with an > exclusive rellockmode and es_modified_relids. It seems like you could > have RTEs which don't end up getting modified that have a lock level > that would have made you think that they would be modified. > > But were you imagining a substitution or a cross-check? > >>> From 8205b2d7da0c3ad3cbc5cead336ced677996b37d Mon Sep 17 00:00:00 2001 >>> From: Melanie Plageman <melanieplageman@gmail.com> >>> Date: Wed, 3 Dec 2025 15:12:18 -0500 >>> Subject: [PATCH v34 12/14] Pass down information on table modification to scan >>> node >> >> Perhaps worth splitting up, so the addition of the 0 flag is separate from the >> the read only hint aspect. > > Done. > > [1] https://www.postgresql.org/message-id/CAAKRu_bbaUV8OUjAfVa_iALgKnTSfB4gO3jnkfpcFgrxEpSGJQ%40mail.gmail.com > <v35-0001-Move-commonly-used-context-into-PruneState-and-s.patch><v35-0002-Add-PageGetPruneXid-helper.patch><v35-0003-Rename-PruneState-all_visible-all_frozen.patch><v35-0004-Use-the-newest-to-be-frozen-xid-as-the-conflict-.patch><v35-0005-Save-vmbuffer-in-heap-specific-scan-descriptors-.patch><v35-0006-Fix-visibility-map-corruption-in-more-cases.patch><v35-0007-Add-pruning-fast-path-for-all-visible-and-all-fr.patch><v35-0008-Use-GlobalVisState-in-vacuum-to-determine-page-l.patch><v35-0009-Keep-newest-live-XID-up-to-date-even-if-page-not.patch><v35-0010-Eliminate-XLOG_HEAP2_VISIBLE-from-vacuum-phase-I.patch><v35-0011-Eliminate-XLOG_HEAP2_VISIBLE-from-empty-page-vac.patch><v35-0012-Remove-XLOG_HEAP2_VISIBLE-entirely.patch><v35-0013-Initialize-missing-fields-in-CreateExecutorState.patch><v35-0014-Track-which-relations-are-modified-by-a-query.patch><v35-0015-Make-begin_scan-functions-take-a-flags-argument.patch><v35-0016-Pass-down-information-on-table-modification-to-s.patch><v35-0017-Allow-on-access-pruning-to-set-pages-all-visible.patch><v35-0018-Set-pd_prune_xid-on-insert.patch> 1 - 0001 ``` +prune_freeze_plan(PruneState *prstate, OffsetNumber *off_loc) { - Page page = BufferGetPage(buffer); - BlockNumber blockno = BufferGetBlockNumber(buffer); - OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + Page page = prstate->page; + BlockNumber blockno = prstate->block; + OffsetNumber maxoff = PageGetMaxOffsetNumber(prstate->page); ``` As there is a local “page”, maybe just use the local one for PageGetMaxOffsetNumber. 0002 looks good. 2 - 0003 - Does it make sense to also do the same renaming in PruneFreezeResult? 3 - 0004 ``` - - /* - * Calculate what the snapshot conflict horizon should be for a record - * freezing tuples. We can use the visibility_cutoff_xid as our cutoff - * for conflicts when the whole page is eligible to become all-frozen - * in the VM once we're done with it. Otherwise, we generate a - * conservative cutoff by stepping back from OldestXmin. - */ - if (prstate->set_all_frozen) - prstate->frz_conflict_horizon = prstate->visibility_cutoff_xid; - else - { - /* Avoids false conflicts when hot_standby_feedback in use */ - prstate->frz_conflict_horizon = prstate->cutoffs->OldestXmin; - TransactionIdRetreat(prstate->frz_conflict_horizon); - } + Assert(TransactionIdPrecedesOrEquals(prstate->pagefrz.FreezePageConflictXid, + prstate->cutoffs->OldestXmin)); ``` At this point of Assert, can prstate->pagefrz.FreezePageConflictXid be InvalidTransactionId? My understanding is no, in that case, would it make sense to also Assert(prstate->pagefrz.FreezePageConflictXid != InvalidTransactionId)? Otherwise, if prstate->pagefrz.FreezePageConflictXid is still possibly be InvalidTransactionId, then the Assert should be changed to something like: Assert(prstate->pagefrz.FreezePageConflictXid == InvalidTransactionId || TransactionIdPrecedesOrEquals(prstate->pagefrz.FreezePageConflictXid, prstate->cutoffs->OldestXmin) I will continue with 0005 tomorrow. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-03T15:52:34Z
On Tue, Mar 3, 2026 at 2:33 AM Chao Li <li.evan.chao@gmail.com> wrote: > > 2 - 0003 - Does it make sense to also do the same renaming in PruneFreezeResult? I could do that. Later commits remove them, so I thought it didn't make sense. If only this commit goes in though, it would make sense. > - * Calculate what the snapshot conflict horizon should be for a record > - * freezing tuples. We can use the visibility_cutoff_xid as our cutoff > - * for conflicts when the whole page is eligible to become all-frozen > - * in the VM once we're done with it. Otherwise, we generate a > - * conservative cutoff by stepping back from OldestXmin. > - */ > - if (prstate->set_all_frozen) > - prstate->frz_conflict_horizon = prstate->visibility_cutoff_xid; > - else > - { > - /* Avoids false conflicts when hot_standby_feedback in use */ > - prstate->frz_conflict_horizon = prstate->cutoffs->OldestXmin; > - TransactionIdRetreat(prstate->frz_conflict_horizon); > - } > + Assert(TransactionIdPrecedesOrEquals(prstate->pagefrz.FreezePageConflictXid, > + prstate->cutoffs->OldestXmin)); > ``` > > At this point of Assert, can prstate->pagefrz.FreezePageConflictXid be InvalidTransactionId? My understanding is no, in that case, would it make sense to also Assert(prstate->pagefrz.FreezePageConflictXid != InvalidTransactionId)? I think it is possible if we are doing some kind of freezing to a multixact that we reach here and FreezePageConflictXid is InvalidTransactionId. > Otherwise, if prstate->pagefrz.FreezePageConflictXid is still possibly be InvalidTransactionId, then the Assert should be changed to something like: > > Assert(prstate->pagefrz.FreezePageConflictXid == InvalidTransactionId || > TransactionIdPrecedesOrEquals(prstate->pagefrz.FreezePageConflictXid, prstate->cutoffs->OldestXmin) This is covered by TransactionIdPrecedesOrEquals because InvalidTransactionId is 0. We assume that in many places throughout the code. > I will continue with 0005 tomorrow. Thanks for the review! I noticed a serious bug in v35-0017: I pass hscan->modifies_base_rel to heap_page_prune_opt() as rel_read_only, which is the opposite of what I want to do -- it should be !hscan->modifies_base_rel. I'm going to wait to fix it though and post a new v36 once I've batched up more fixups. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2026-03-04T08:59:41Z
> On Mar 3, 2026, at 23:52, Melanie Plageman <melanieplageman@gmail.com> wrote: > > >> Otherwise, if prstate->pagefrz.FreezePageConflictXid is still possibly be InvalidTransactionId, then the Assert should be changed to something like: >> >> Assert(prstate->pagefrz.FreezePageConflictXid == InvalidTransactionId || >> TransactionIdPrecedesOrEquals(prstate->pagefrz.FreezePageConflictXid, prstate->cutoffs->OldestXmin) > > This is covered by TransactionIdPrecedesOrEquals because > InvalidTransactionId is 0. We assume that in many places throughout > the code. > I understood that TransactionIdPrecedesOrEquals(InvalidTransactionId, prstate->cutoffs->OldestXmin) is true, but that would leave an impression to code readers that prstate->pagefrz.FreezePageConflictXid could not be InvalidTransactionId. Thus I think my version explicitly tells that prstate->pagefrz.FreezePageConflictXid could be InvalidTransactionId at the point. >> I will continue with 0005 tomorrow. > 4 - 0005 ``` * Caller must have pin on the buffer, and must *not* have a lock on it. */ void -heap_page_prune_opt(Relation relation, Buffer buffer) +heap_page_prune_opt(Relation relation, Buffer buffer, Buffer *vmbuffer) ``` I don’t see why vmbuffer has to be of pointer type. Buffer type is underlying int, I checked the last commit, vmbuffer only passes in data into the function without passing out anything. As we add the new parameter vmbuffer, though it’s not used in this commit, I think it’d be better to update the header commit to explain what this parameter will do. 5 - 0006 ``` + * + * heap_fix_vm_corruption() makes changes to the VM and, potentially, the heap + * page, but it does not need to be done in a critical section because + * clearing the VM is not WAL-logged. + */ +static void +heap_fix_vm_corruption(PruneState *prstate, OffsetNumber offnum) ``` Nit: why the last paragraph of the header comments uses the function name instead of “this function”? Looks like a copy-pasto. 6 - 0006 ``` + if (prstate->lpdead_items > 0) + { + ereport(WARNING, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("LP_DEAD item found on page marked as all-visible"), + errdetail("relation \"%s\", page %u, tuple %u", + RelationGetRelationName(prstate->relation), + prstate->block, offnum))); + } + else + { + ereport(WARNING, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("tuple not visible to all found on page marked as all-visible"), + errdetail("relation \"%s\", page %u, tuple %u", + RelationGetRelationName(prstate->relation), + prstate->block, offnum))); + } ``` I recently just learned that a detail message should use complete sentences, and end each with a period, and capitalize the first word of sentences. See https://www.postgresql.org/docs/current/error-style-guide.html. 7 - 0006 ``` + else if (prstate->vmbits & VISIBILITYMAP_VALID_BITS) + { + /* + * As of PostgreSQL 9.2, the visibility map bit should never be set if + * the page-level bit is clear. However, it's possible that the bit + * got cleared after heap_vac_scan_next_block() was called, so we must + * recheck with buffer lock before concluding that the VM is corrupt. + */ + ereport(WARNING, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("page %u in \"%s\" is not marked all-visible but visibility map bit is set", + prstate->block, + RelationGetRelationName(prstate->relation)))); + } ``` The comment says “we must recheck with buffer lock before…”, but it only log a warning message. Is the comment stale? 8 - 0007 ``` +static void +heap_page_bypass_prune_freeze(PruneState *prstate, PruneFreezeResult *presult) +{ + OffsetNumber maxoff = PageGetMaxOffsetNumber(prstate->page); + Page page = prstate->page; + + Assert(prstate->vmbits & VISIBILITYMAP_ALL_FROZEN || + (prstate->vmbits & VISIBILITYMAP_ALL_VISIBLE && + !prstate->attempt_freeze)); + + /* We'll fill in presult for the caller */ + memset(presult, 0, sizeof(PruneFreezeResult)); + + /* + * Since the page is all-visible, a count of the normal ItemIds on the + * page should be sufficient for vacuum's live tuple count. + */ + for (OffsetNumber off = FirstOffsetNumber; + off <= maxoff; + off = OffsetNumberNext(off)) + { + if (ItemIdIsNormal(PageGetItemId(page, off))) + prstate->live_tuples++; + } + + presult->live_tuples = prstate->live_tuples; + + /* Clear any stale prune hint */ + if (TransactionIdIsValid(PageGetPruneXid(page))) + { + PageClearPrunable(page); + MarkBufferDirtyHint(prstate->buffer, true); + } + + presult->vmbits = prstate->vmbits; + + if (!PageIsEmpty(page)) + presult->hastup = true; +} ``` * Given this function has done PageIsEmpty(page), that that is true, we don’t need to count live_tuples, right? That could be a tiny optimization. * I see heap_page_bypass_prune_freeze() is only called in one place and immediately after prune_freeze_setup() and heap_fix_vm_corruption(), so prstate->vmbits must be 0, so do we need to do presult->vmbits = prstate->vmbits;? * Do we need to set all_visible and all_frozen to presult? 0008 LGTM I will continue with 0009 tomorrow. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2026-03-05T08:52:45Z
> On Mar 4, 2026, at 16:59, Chao Li <li.evan.chao@gmail.com> wrote: > > > >> On Mar 3, 2026, at 23:52, Melanie Plageman <melanieplageman@gmail.com> wrote: >> >> >>> Otherwise, if prstate->pagefrz.FreezePageConflictXid is still possibly be InvalidTransactionId, then the Assert should be changed to something like: >>> >>> Assert(prstate->pagefrz.FreezePageConflictXid == InvalidTransactionId || >>> TransactionIdPrecedesOrEquals(prstate->pagefrz.FreezePageConflictXid, prstate->cutoffs->OldestXmin) >> >> This is covered by TransactionIdPrecedesOrEquals because >> InvalidTransactionId is 0. We assume that in many places throughout >> the code. >> > > I understood that TransactionIdPrecedesOrEquals(InvalidTransactionId, prstate->cutoffs->OldestXmin) is true, but that would leave an impression to code readers that prstate->pagefrz.FreezePageConflictXid could not be InvalidTransactionId. Thus I think my version explicitly tells that prstate->pagefrz.FreezePageConflictXid could be InvalidTransactionId at the point. > > >>> I will continue with 0005 tomorrow. >> > > 4 - 0005 > ``` > * Caller must have pin on the buffer, and must *not* have a lock on it. > */ > void > -heap_page_prune_opt(Relation relation, Buffer buffer) > +heap_page_prune_opt(Relation relation, Buffer buffer, Buffer *vmbuffer) > ``` > > I don’t see why vmbuffer has to be of pointer type. Buffer type is underlying int, I checked the last commit, vmbuffer only passes in data into the function without passing out anything. > > As we add the new parameter vmbuffer, though it’s not used in this commit, I think it’d be better to update the header commit to explain what this parameter will do. > > 5 - 0006 > ``` > + * > + * heap_fix_vm_corruption() makes changes to the VM and, potentially, the heap > + * page, but it does not need to be done in a critical section because > + * clearing the VM is not WAL-logged. > + */ > +static void > +heap_fix_vm_corruption(PruneState *prstate, OffsetNumber offnum) > ``` > > Nit: why the last paragraph of the header comments uses the function name instead of “this function”? Looks like a copy-pasto. > > 6 - 0006 > ``` > + if (prstate->lpdead_items > 0) > + { > + ereport(WARNING, > + (errcode(ERRCODE_DATA_CORRUPTED), > + errmsg("LP_DEAD item found on page marked as all-visible"), > + errdetail("relation \"%s\", page %u, tuple %u", > + RelationGetRelationName(prstate->relation), > + prstate->block, offnum))); > + } > + else > + { > + ereport(WARNING, > + (errcode(ERRCODE_DATA_CORRUPTED), > + errmsg("tuple not visible to all found on page marked as all-visible"), > + errdetail("relation \"%s\", page %u, tuple %u", > + RelationGetRelationName(prstate->relation), > + prstate->block, offnum))); > + } > ``` > > I recently just learned that a detail message should use complete sentences, and end each with a period, and capitalize the first word of sentences. See https://www.postgresql.org/docs/current/error-style-guide.html. > > 7 - 0006 > ``` > + else if (prstate->vmbits & VISIBILITYMAP_VALID_BITS) > + { > + /* > + * As of PostgreSQL 9.2, the visibility map bit should never be set if > + * the page-level bit is clear. However, it's possible that the bit > + * got cleared after heap_vac_scan_next_block() was called, so we must > + * recheck with buffer lock before concluding that the VM is corrupt. > + */ > + ereport(WARNING, > + (errcode(ERRCODE_DATA_CORRUPTED), > + errmsg("page %u in \"%s\" is not marked all-visible but visibility map bit is set", > + prstate->block, > + RelationGetRelationName(prstate->relation)))); > + } > ``` > > The comment says “we must recheck with buffer lock before…”, but it only log a warning message. Is the comment stale? > > 8 - 0007 > ``` > +static void > +heap_page_bypass_prune_freeze(PruneState *prstate, PruneFreezeResult *presult) > +{ > + OffsetNumber maxoff = PageGetMaxOffsetNumber(prstate->page); > + Page page = prstate->page; > + > + Assert(prstate->vmbits & VISIBILITYMAP_ALL_FROZEN || > + (prstate->vmbits & VISIBILITYMAP_ALL_VISIBLE && > + !prstate->attempt_freeze)); > + > + /* We'll fill in presult for the caller */ > + memset(presult, 0, sizeof(PruneFreezeResult)); > + > + /* > + * Since the page is all-visible, a count of the normal ItemIds on the > + * page should be sufficient for vacuum's live tuple count. > + */ > + for (OffsetNumber off = FirstOffsetNumber; > + off <= maxoff; > + off = OffsetNumberNext(off)) > + { > + if (ItemIdIsNormal(PageGetItemId(page, off))) > + prstate->live_tuples++; > + } > + > + presult->live_tuples = prstate->live_tuples; > + > + /* Clear any stale prune hint */ > + if (TransactionIdIsValid(PageGetPruneXid(page))) > + { > + PageClearPrunable(page); > + MarkBufferDirtyHint(prstate->buffer, true); > + } > + > + presult->vmbits = prstate->vmbits; > + > + if (!PageIsEmpty(page)) > + presult->hastup = true; > +} > ``` > > * Given this function has done PageIsEmpty(page), that that is true, we don’t need to count live_tuples, right? That could be a tiny optimization. > * I see heap_page_bypass_prune_freeze() is only called in one place and immediately after prune_freeze_setup() and heap_fix_vm_corruption(), so prstate->vmbits must be 0, so do we need to do presult->vmbits = prstate->vmbits;? > * Do we need to set all_visible and all_frozen to presult? > > 0008 LGTM > > I will continue with 0009 tomorrow. > 9 - 0009 ··· + * Currently, only VACUUM performs freezing, but other callers may in the + * future. Other callers must initialize prstate.all_frozen to false, ··· Nit: prstate.all_frozen -> prstate.set_all_frozen I saw you have fixed this in 0010, but I think it’s better also fix it here. 10 - 0010 ``` + * Whether or not the page was newly set all-visible and all-frozen during + * phase I of vacuuming. */ - uint8 vmbits; + BlockNumber new_all_visible_pages; + BlockNumber new_all_visible_frozen_pages; + BlockNumber new_all_frozen_pages; ``` These 3 fields are actually counts rather than pointers to blocks, using type BlockNumber are quite confusing, though underlying BlockNumber is uint32. I think they can be just int type. 11 - 0010 ``` + BlockNumber new_all_visible_pages; + BlockNumber new_all_visible_frozen_pages; + BlockNumber new_all_frozen_pages; ``` I don’t see where these 3 fields are initialized. In lazy_scan_prune(), presult is defined as: ``` PruneFreezeResult presult; ``` So, those fields will hold random values. 12 - 0010 ``` + * conflict would ahve been handled in reaction to the WAL record freezing ``` Nit: ahve -> have 0011 LGTM 13 - 0012 - bufmask.c ``` + * we don't mark the page all-visible. See heap_xlog_prune_and_freeze() + * for more details. ``` I don’t find a function named heap_xlog_prune_and_freeze(). 14 - 0012 - heapam_xlog.c ``` + * same approach is taken when replaying XLOG_HEAP2_PRUNE* records (see + * heap_xlog_prune_and_freeze()). ``` Same as 13. 0013 LGTM I will try to finish the rest 5 commits tomorrow. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Chao Li <li.evan.chao@gmail.com> — 2026-03-06T02:40:53Z
> On Mar 5, 2026, at 16:52, Chao Li <li.evan.chao@gmail.com> wrote: > > > >> On Mar 4, 2026, at 16:59, Chao Li <li.evan.chao@gmail.com> wrote: >> >> >> >>> On Mar 3, 2026, at 23:52, Melanie Plageman <melanieplageman@gmail.com> wrote: >>> >>> >>>> Otherwise, if prstate->pagefrz.FreezePageConflictXid is still possibly be InvalidTransactionId, then the Assert should be changed to something like: >>>> >>>> Assert(prstate->pagefrz.FreezePageConflictXid == InvalidTransactionId || >>>> TransactionIdPrecedesOrEquals(prstate->pagefrz.FreezePageConflictXid, prstate->cutoffs->OldestXmin) >>> >>> This is covered by TransactionIdPrecedesOrEquals because >>> InvalidTransactionId is 0. We assume that in many places throughout >>> the code. >>> >> >> I understood that TransactionIdPrecedesOrEquals(InvalidTransactionId, prstate->cutoffs->OldestXmin) is true, but that would leave an impression to code readers that prstate->pagefrz.FreezePageConflictXid could not be InvalidTransactionId. Thus I think my version explicitly tells that prstate->pagefrz.FreezePageConflictXid could be InvalidTransactionId at the point. >> >> >>>> I will continue with 0005 tomorrow. >>> >> >> 4 - 0005 >> ``` >> * Caller must have pin on the buffer, and must *not* have a lock on it. >> */ >> void >> -heap_page_prune_opt(Relation relation, Buffer buffer) >> +heap_page_prune_opt(Relation relation, Buffer buffer, Buffer *vmbuffer) >> ``` >> >> I don’t see why vmbuffer has to be of pointer type. Buffer type is underlying int, I checked the last commit, vmbuffer only passes in data into the function without passing out anything. >> >> As we add the new parameter vmbuffer, though it’s not used in this commit, I think it’d be better to update the header commit to explain what this parameter will do. >> >> 5 - 0006 >> ``` >> + * >> + * heap_fix_vm_corruption() makes changes to the VM and, potentially, the heap >> + * page, but it does not need to be done in a critical section because >> + * clearing the VM is not WAL-logged. >> + */ >> +static void >> +heap_fix_vm_corruption(PruneState *prstate, OffsetNumber offnum) >> ``` >> >> Nit: why the last paragraph of the header comments uses the function name instead of “this function”? Looks like a copy-pasto. >> >> 6 - 0006 >> ``` >> + if (prstate->lpdead_items > 0) >> + { >> + ereport(WARNING, >> + (errcode(ERRCODE_DATA_CORRUPTED), >> + errmsg("LP_DEAD item found on page marked as all-visible"), >> + errdetail("relation \"%s\", page %u, tuple %u", >> + RelationGetRelationName(prstate->relation), >> + prstate->block, offnum))); >> + } >> + else >> + { >> + ereport(WARNING, >> + (errcode(ERRCODE_DATA_CORRUPTED), >> + errmsg("tuple not visible to all found on page marked as all-visible"), >> + errdetail("relation \"%s\", page %u, tuple %u", >> + RelationGetRelationName(prstate->relation), >> + prstate->block, offnum))); >> + } >> ``` >> >> I recently just learned that a detail message should use complete sentences, and end each with a period, and capitalize the first word of sentences. See https://www.postgresql.org/docs/current/error-style-guide.html. >> >> 7 - 0006 >> ``` >> + else if (prstate->vmbits & VISIBILITYMAP_VALID_BITS) >> + { >> + /* >> + * As of PostgreSQL 9.2, the visibility map bit should never be set if >> + * the page-level bit is clear. However, it's possible that the bit >> + * got cleared after heap_vac_scan_next_block() was called, so we must >> + * recheck with buffer lock before concluding that the VM is corrupt. >> + */ >> + ereport(WARNING, >> + (errcode(ERRCODE_DATA_CORRUPTED), >> + errmsg("page %u in \"%s\" is not marked all-visible but visibility map bit is set", >> + prstate->block, >> + RelationGetRelationName(prstate->relation)))); >> + } >> ``` >> >> The comment says “we must recheck with buffer lock before…”, but it only log a warning message. Is the comment stale? >> >> 8 - 0007 >> ``` >> +static void >> +heap_page_bypass_prune_freeze(PruneState *prstate, PruneFreezeResult *presult) >> +{ >> + OffsetNumber maxoff = PageGetMaxOffsetNumber(prstate->page); >> + Page page = prstate->page; >> + >> + Assert(prstate->vmbits & VISIBILITYMAP_ALL_FROZEN || >> + (prstate->vmbits & VISIBILITYMAP_ALL_VISIBLE && >> + !prstate->attempt_freeze)); >> + >> + /* We'll fill in presult for the caller */ >> + memset(presult, 0, sizeof(PruneFreezeResult)); >> + >> + /* >> + * Since the page is all-visible, a count of the normal ItemIds on the >> + * page should be sufficient for vacuum's live tuple count. >> + */ >> + for (OffsetNumber off = FirstOffsetNumber; >> + off <= maxoff; >> + off = OffsetNumberNext(off)) >> + { >> + if (ItemIdIsNormal(PageGetItemId(page, off))) >> + prstate->live_tuples++; >> + } >> + >> + presult->live_tuples = prstate->live_tuples; >> + >> + /* Clear any stale prune hint */ >> + if (TransactionIdIsValid(PageGetPruneXid(page))) >> + { >> + PageClearPrunable(page); >> + MarkBufferDirtyHint(prstate->buffer, true); >> + } >> + >> + presult->vmbits = prstate->vmbits; >> + >> + if (!PageIsEmpty(page)) >> + presult->hastup = true; >> +} >> ``` >> >> * Given this function has done PageIsEmpty(page), that that is true, we don’t need to count live_tuples, right? That could be a tiny optimization. >> * I see heap_page_bypass_prune_freeze() is only called in one place and immediately after prune_freeze_setup() and heap_fix_vm_corruption(), so prstate->vmbits must be 0, so do we need to do presult->vmbits = prstate->vmbits;? >> * Do we need to set all_visible and all_frozen to presult? >> >> 0008 LGTM >> >> I will continue with 0009 tomorrow. >> > > 9 - 0009 > ··· > + * Currently, only VACUUM performs freezing, but other callers may in the > + * future. Other callers must initialize prstate.all_frozen to false, > ··· > > Nit: prstate.all_frozen -> prstate.set_all_frozen > > I saw you have fixed this in 0010, but I think it’s better also fix it here. > > 10 - 0010 > ``` > + * Whether or not the page was newly set all-visible and all-frozen during > + * phase I of vacuuming. > */ > - uint8 vmbits; > + BlockNumber new_all_visible_pages; > + BlockNumber new_all_visible_frozen_pages; > + BlockNumber new_all_frozen_pages; > ``` > > These 3 fields are actually counts rather than pointers to blocks, using type BlockNumber are quite confusing, though underlying BlockNumber is uint32. I think they can be just int type. > > 11 - 0010 > ``` > + BlockNumber new_all_visible_pages; > + BlockNumber new_all_visible_frozen_pages; > + BlockNumber new_all_frozen_pages; > ``` > > I don’t see where these 3 fields are initialized. In lazy_scan_prune(), presult is defined as: > ``` > PruneFreezeResult presult; > ``` > So, those fields will hold random values. > > 12 - 0010 > ``` > + * conflict would ahve been handled in reaction to the WAL record freezing > ``` > > Nit: ahve -> have > > 0011 LGTM > > 13 - 0012 - bufmask.c > ``` > + * we don't mark the page all-visible. See heap_xlog_prune_and_freeze() > + * for more details. > ``` > > I don’t find a function named heap_xlog_prune_and_freeze(). > > 14 - 0012 - heapam_xlog.c > ``` > + * same approach is taken when replaying XLOG_HEAP2_PRUNE* records (see > + * heap_xlog_prune_and_freeze()). > ``` > > Same as 13. > > 0013 LGTM > > I will try to finish the rest 5 commits tomorrow. > 15 - 0014 - execMain.c ``` @@ -3027,6 +3035,7 @@ EvalPlanQualStart(EPQState *epqstate, Plan *planTree) rcestate->es_range_table_size = parentestate->es_range_table_size; rcestate->es_relations = parentestate->es_relations; rcestate->es_rowmarks = parentestate->es_rowmarks; + rcestate->es_modified_relids = parentestate->es_modified_relids; ``` Here it just assigns the BMS pointer to rcestate->es_modified_relids. I am not sure if further bms_add_member() will still happen, if yes, it might be safer to do bms_copy(parentestate->es_modified_relids), because a further bms_add_member() may cause a new memory allocated and the old pointer stale. 16 - 0014 - execUtils.c ``` for (rti = 1; rti <= estate->es_range_table_size; rti++) ``` Nit: I have seen several recent commits that performed cleanups to switch to use for loop var like: ``` for (Index rti = 1; rti <= estate->es_range_table_size; rti++) ``` 17 - 0015 The commit message subject line says “Make begin_scan() functions take a flags argument”, where begin_scan() seems inaccurate, for example, table_index_fetch_begin() is not “begin scan”. Otherwise 0015 LGTM. 18 - 0016 - tableam.h ``` /* unregister snapshot at scan end? */ SO_TEMP_SNAPSHOT = 1 << 9, + /* set if the query doesn't modify the relation */ + SO_HINT_REL_READ_ONLY = 1 << 10, } ScanOptions; ``` Nit: maybe add an empty line before the new flag. 19 - 0017 - heapam_handler.c ``` @@ -147,7 +147,8 @@ heapam_index_fetch_tuple(struct IndexFetchTableData *scan, */ if (prev_buf != hscan->xs_cbuf) heap_page_prune_opt(hscan->xs_base.rel, hscan->xs_cbuf, - &hscan->xs_vmbuffer); + &hscan->xs_vmbuffer, + hscan->modifies_base_rel); ``` This feels like a bug. heap_page_prune_opt takes the first parameter rel_read_only, but hscan->modifies_base_rel means not read-only, so here we should use “!hscan->modifies_base_rel”. Oh, when I read back your previous email, you have found this bug. 20 - 0018 In heap_insert(), you do: ``` + if (TransactionIdIsNormal(xid) && !(options & HEAP_INSERT_FROZEN)) + PageSetPrunable(page, xid); ``` But in heap_multi_insert(), you do: ``` + if (!all_frozen_set && TransactionIdIsNormal(xid)) + PageSetPrunable(page, xid); ``` Is the option check " !(options & HEAP_INSERT_FROZEN))” also needed by heap_multi_insert? ~~ Done of this round review ~~ Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-06T23:33:10Z
Thanks for the review! Attached is v36. I've pushed some of the early patches in the set and this is what is left. I've also done some of the performance evaluation and microbenchmarking of the "worst case" scenario promised in my earlier reply to Andres [1]. I used the following to test the worst-case performance of my patch: pgbench -n -r -t 9 -f - <<'SQL' checkpoint; DROP TABLE IF EXISTS foo; CREATE TABLE foo(a int, cnt int, data text) WITH (autovacuum_enabled = false, fillfactor = 10); ALTER TABLE foo ALTER COLUMN data SET STORAGE PLAIN; CREATE INDEX ON foo(a); INSERT INTO foo SELECT i, 1, repeat(' ', 8192/10) FROM generate_series(1,100000) i; vacuum (freeze) foo; update foo set cnt = cnt + 1; select * from foo offset 100000000; update foo set cnt = cnt + 1; SQL What I see is an expected slowdown for the SELECT * FROM foo OFFSET -- because it emits slightly more WAL and pins and dirties a few more buffers. And a slight slowdown for the UPDATE following the SELECT because it then must clear those VM bits. (This is no different than if you had run a vacuum before doing the update). These slowdowns are expected since this microbenchmark is designed to be a worst case. Every buffer has a single tuple and the SELECT needs to access no tuples because of the OFFSET. This minimizes all other overheads to magnify the overhead of setting and clearing the VM. I also tested if unconditionally pinning the VM even when we don't set it had any impact on performance of on-access pruning for logged tables. I used the setup above but patched the code to not set the VM on-access. I found that there is no negative performance impact to the SELECT * OFFSET. If foo is an unlogged table I do see a very slight overhead for the SELECT * OFFSET. And in all cases, with the patch, the vacuum above is faster because of using the combined WAL record. I believe I've addressed all of your review feedback. Below are combined inline remarks to all three of your emails: On Wed, Mar 4, 2026 at 4:00 AM Chao Li <li.evan.chao@gmail.com> wrote: > > * Caller must have pin on the buffer, and must *not* have a lock on it. > */ > void > -heap_page_prune_opt(Relation relation, Buffer buffer) > +heap_page_prune_opt(Relation relation, Buffer buffer, Buffer *vmbuffer) > ``` > > I don’t see why vmbuffer has to be of pointer type. Buffer type is underlying int, I checked the last commit, vmbuffer only passes in data into the function without passing out anything. We want to save the vmbuffer in the scan descriptor so we can use it across calls to heap_page_prune_opt(). Therefore we have to pass it by reference. We pin the VM in heap_page_prune_opt() and if we don't save a reference to it, we'll have to pin it again on the next call (see visibilitymap_pin() code). > As we add the new parameter vmbuffer, though it’s not used in this commit, I think it’d be better to update the header commit to explain what this parameter will do. Thanks, I've updated the header comment. > + * heap_fix_vm_corruption() makes changes to the VM and, potentially, the heap > + * page, but it does not need to be done in a critical section because > + * clearing the VM is not WAL-logged. > + */ > +static void > +heap_fix_vm_corruption(PruneState *prstate, OffsetNumber offnum) > > Nit: why the last paragraph of the header comments uses the function name instead of “this function”? Looks like a copy-pasto. Fixed. > 6 - 0006 > ``` > + if (prstate->lpdead_items > 0) > + { > + ereport(WARNING, > + (errcode(ERRCODE_DATA_CORRUPTED), > + errmsg("LP_DEAD item found on page marked as all-visible"), > + errdetail("relation \"%s\", page %u, tuple %u", > + RelationGetRelationName(prstate->relation), > + prstate->block, offnum))); > + } > + else > + { > + ereport(WARNING, > + (errcode(ERRCODE_DATA_CORRUPTED), > + errmsg("tuple not visible to all found on page marked as all-visible"), > + errdetail("relation \"%s\", page %u, tuple %u", > + RelationGetRelationName(prstate->relation), > + prstate->block, offnum))); > + } > ``` > > I recently just learned that a detail message should use complete sentences, and end each with a period, and capitalize the first word of sentences. See https://www.postgresql.org/docs/current/error-style-guide.html. Ah thanks for noticing. I've gone ahead and changed them to errcontext instead of errdetail. I think the messages are more compliant now. > + else if (prstate->vmbits & VISIBILITYMAP_VALID_BITS) > + { > + /* > + * As of PostgreSQL 9.2, the visibility map bit should never be set if > + * the page-level bit is clear. However, it's possible that the bit > + * got cleared after heap_vac_scan_next_block() was called, so we must > + * recheck with buffer lock before concluding that the VM is corrupt. > + */ > + ereport(WARNING, > + (errcode(ERRCODE_DATA_CORRUPTED), > + errmsg("page %u in \"%s\" is not marked all-visible but visibility map bit is set", > + prstate->block, > + RelationGetRelationName(prstate->relation)))); > + } > > The comment says “we must recheck with buffer lock before…”, but it only log a warning message. Is the comment stale? We have the buffer lock here. The comment means that we need to check now -- a time when we have the buffer lock because when we checked in heap_vac_scan_next_block() we did not have the buffer lock. I've updated the comment to try to make that more clear. > +static void > +heap_page_bypass_prune_freeze(PruneState *prstate, PruneFreezeResult *presult) > +{ > > * Given this function has done PageIsEmpty(page), that that is true, we don’t need to count live_tuples, right? That could be a tiny optimization. Okay, I've tried this. I didn't want a lot of indentation, so I reorganized the code. I'm not sure if it is more error-prone now, though... > * I see heap_page_bypass_prune_freeze() is only called in one place and immediately after prune_freeze_setup() and heap_fix_vm_corruption(), so prstate->vmbits must be 0, so do we need to do presult->vmbits = prstate->vmbits;? Actually vmbits can't be zero, otherwise we won't reach the fast path code. Or do you mean something else? > * Do we need to set all_visible and all_frozen to presult? I memset to 0 the other fields, so it isn't needed. On Thu, Mar 5, 2026 at 3:53 AM Chao Li <li.evan.chao@gmail.com> wrote: > > 9 - 0009 > ··· > + * Currently, only VACUUM performs freezing, but other callers may in the > + * future. Other callers must initialize prstate.all_frozen to false, > ··· > > Nit: prstate.all_frozen -> prstate.set_all_frozen > > I saw you have fixed this in 0010, but I think it’s better also fix it here. Done. > 10 - 0010 > ``` > + * Whether or not the page was newly set all-visible and all-frozen during > + * phase I of vacuuming. > */ > - uint8 vmbits; > + BlockNumber new_all_visible_pages; > + BlockNumber new_all_visible_frozen_pages; > + BlockNumber new_all_frozen_pages; > ``` > > These 3 fields are actually counts rather than pointers to blocks, using type BlockNumber are quite confusing, though underlying BlockNumber is uint32. I think they can be just int type. Covered in [2]. > + BlockNumber new_all_visible_pages; > + BlockNumber new_all_visible_frozen_pages; > + BlockNumber new_all_frozen_pages; > > I don’t see where these 3 fields are initialized. In lazy_scan_prune(), presult is defined as: > PruneFreezeResult presult; > So, those fields will hold random values. Yes, thank you. I've fixed that. > 13 - 0012 - bufmask.c > ``` > + * we don't mark the page all-visible. See heap_xlog_prune_and_freeze() > + * for more details. > ``` > > I don’t find a function named heap_xlog_prune_and_freeze(). Fixed in both places (-> heap_xlog_prune_freeze()). On Thu, Mar 5, 2026 at 9:41 PM Chao Li <li.evan.chao@gmail.com> wrote: > > 15 - 0014 - execMain.c > ``` > @@ -3027,6 +3035,7 @@ EvalPlanQualStart(EPQState *epqstate, Plan *planTree) > rcestate->es_range_table_size = parentestate->es_range_table_size; > rcestate->es_relations = parentestate->es_relations; > rcestate->es_rowmarks = parentestate->es_rowmarks; > + rcestate->es_modified_relids = parentestate->es_modified_relids; > ``` > > Here it just assigns the BMS pointer to rcestate->es_modified_relids. I am not sure if further bms_add_member() will still happen, if yes, it might be safer to do bms_copy(parentestate->es_modified_relids), because a further bms_add_member() may cause a new memory allocated and the old pointer stale. Yes, it's at least a bit of future proofing. Done in v36. > 16 - 0014 - execUtils.c > for (rti = 1; rti <= estate->es_range_table_size; rti++) > > Nit: I have seen several recent commits that performed cleanups to switch to use for loop var like: > for (Index rti = 1; rti <= estate->es_range_table_size; rti++) Updated. > 17 - 0015 > > The commit message subject line says “Make begin_scan() functions take a flags argument”, where begin_scan() seems inaccurate, for example, table_index_fetch_begin() is not “begin scan”. > > Otherwise 0015 LGTM. I've rewritten the commit message. > 20 - 0018 > In heap_insert(), you do: > + if (TransactionIdIsNormal(xid) && !(options & HEAP_INSERT_FROZEN)) > + PageSetPrunable(page, xid); > > But in heap_multi_insert(), you do: > + if (!all_frozen_set && TransactionIdIsNormal(xid)) > + PageSetPrunable(page, xid); > > Is the option check " !(options & HEAP_INSERT_FROZEN))” also needed by heap_multi_insert? heap_multi_insert() incorporates that into the variable all_frozen_set, so it is not needed. I've now also added setting prune hint for the new page on updates -- which I forgot before. - Melanie [1] https://www.postgresql.org/message-id/CAAKRu_a1V7TUUYM7qO2c5Z-JyTKOsrryQBrk7Eu69ESzhqgd9w%40mail.gmail.com [2] https://www.postgresql.org/message-id/flat/CA%2BFpmFdrM%3DL5f%3De7%2BwqOkFkYK6r_S%3DTdKrHQ5qPbTNaoVG9PUA%40mail.gmail.com#e3420a65f192d59b72a33e56a8c2a50f -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-11T17:01:36Z
On Fri, Mar 6, 2026 at 6:33 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > Thanks for the review! Attached is v36. I've pushed some of the early > patches in the set and this is what is left. I've gone ahead and pushed another of the introductory commits. Attached v37 has the remaining patches. The one change is that I've removed get_conflict_xid(). I determined that in the current code that we cannot end up in the scenario where we didn't prune or freeze and the page was already all-visible but not all-frozen. The closest scenario would be one where the page was all-frozen, we cleared the all-frozen bit because we did a SELECT FOR UPDATE on one of the tuples, then vacuum freezes the page. Even though we are just invalidating the xmax, it still counts as freezing. However, in this case we will not advance the FreezePageConflictXid, so the snapshot conflict horizon will still correctly be InvalidTransactionId. I believe in all cases we will correctly set the conflict horizon to InvalidTransactionId with this much simpler conflict xid calculation: conflict_xid = InvalidTransactionId; if (do_set_vm) conflict_xid = prstate.newest_live_xid; if (do_freeze && TransactionIdFollows(prstate.pagefrz.FreezePageConflictXid, conflict_xid)) conflict_xid = prstate.pagefrz.FreezePageConflictXid; if (do_prune && TransactionIdFollows(prstate.latest_xid_removed, conflict_xid)) conflict_xid = prstate.latest_xid_removed; The only outstanding question I have is about pd_prune_xid on insert: I think we only want to set pd_prune_xid on insert if the transaction ID is normal. Bootstrap mode does call heap_insert(), so we need to check the xid before setting it. The only question is then if we want the same guard on replay. Bootstrap mode won't actually insert a WAL record, so we don't need this check I think. However, I think it is better to have it for consistency with normal mode. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-15T19:10:54Z
On Wed, Mar 11, 2026 at 1:01 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Fri, Mar 6, 2026 at 6:33 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > > Thanks for the review! Attached is v36. I've pushed some of the early > > patches in the set and this is what is left. > > I've gone ahead and pushed another of the introductory commits. > Attached v37 has the remaining patches. I've pushed a few more of the trivial commits in the set. Attached v38 has the remaining patches. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-16T14:53:32Z
On Sun, Mar 15, 2026 at 3:10 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > I've pushed a few more of the trivial commits in the set. Attached v38 > has the remaining patches. Looks like cfbot wasn't able to rebase v38 on its own for some reason. v39 attached. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2026-03-17T09:05:16Z
On Mon, 16 Mar 2026 at 19:53, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Sun, Mar 15, 2026 at 3:10 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > > I've pushed a few more of the trivial commits in the set. Attached v38 > > has the remaining patches. > > Looks like cfbot wasn't able to rebase v38 on its own for some reason. > v39 attached. > > - Melanie Hi! I did take a quick look on v38-v39. 0001 & 0003 looks ok. > From 788860ded375fcf744201347b9dcbf496070bfb5 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 25 Feb 2026 16:48:19 -0500 > Subject: [PATCH v39 02/12] Add pruning fast path for all-visible and all-frozen pages For the record, does this work with DISABLE_PAGE_SKIPPING? I think we don't want the server to "fast-path" in case this option is set by the user... -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-17T14:48:55Z
On Tue, Mar 17, 2026 at 5:05 AM Kirill Reshke <reshkekirill@gmail.com> wrote: > > > From 788860ded375fcf744201347b9dcbf496070bfb5 Mon Sep 17 00:00:00 2001 > > From: Melanie Plageman <melanieplageman@gmail.com> > > Date: Wed, 25 Feb 2026 16:48:19 -0500 > > Subject: [PATCH v39 02/12] Add pruning fast path for all-visible and > all-frozen pages > > For the record, does this work with DISABLE_PAGE_SKIPPING? I think we > don't want the server to "fast-path" in case this option is set by > the user... Hmm. This is a good point. The docs for DISABLE_PAGE_SKIPPING say it is about fixing visibility map corruption and the fast path does detect and fix one type of visibility map corruption. It does not investigate for dead line pointers, though. I suppose DISABLE_PAGE_SKIPPING would want to also do that kind of VM corruption detection. Thanks for thinking of that. Attached v40 adds an option to disable the fast path. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2026-03-18T17:14:53Z
Hi, On 2026-03-17 10:48:55 -0400, Melanie Plageman wrote: > @@ -277,6 +295,16 @@ heap_page_prune_opt(Relation relation, Buffer buffer, Buffer *vmbuffer) > { > OffsetNumber dummy_off_loc; > PruneFreezeResult presult; > + PruneFreezeParams params; > + > + visibilitymap_pin(relation, BufferGetBlockNumber(buffer), vmbuffer); We also do a BufferGetBlockNumber(buffer) in prune_freeze_setup(). It irks me a bit to do that twice, but I don't see a non-ugly way to avoid that. > + params.relation = relation; > + params.buffer = buffer; > + params.vmbuffer = *vmbuffer; > + params.reason = PRUNE_ON_ACCESS; > + params.vistest = vistest; > + params.cutoffs = NULL; > > * We don't pass the HEAP_PAGE_PRUNE_MARK_UNUSED_NOW option > @@ -284,14 +312,7 @@ heap_page_prune_opt(Relation relation, Buffer buffer, Buffer *vmbuffer) > * cannot safely determine that during on-access pruning with the > * current implementation. > */ > - PruneFreezeParams params = { > - .relation = relation, > - .buffer = buffer, > - .reason = PRUNE_ON_ACCESS, > - .options = 0, > - .vistest = vistest, > - .cutoffs = NULL, > - }; > + params.options = 0; > > heap_page_prune_and_freeze(¶ms, &presult, &dummy_off_loc, > NULL, NULL); Why does this change the way the PruneFreezeParams variable is defined? I don't really mind, it's just a bit confusing. > +/* > + * Helper to fix visibility-related corruption on a heap page and its > + * corresponding VM page. An all-visible page cannot have dead items nor can > + * it have tuples that are not visible to all running transactions. It clears > + * the VM corruption as well as resetting the vmbits used during pruning. So this is now only called when we already know there's corruption? I think that could be clearer in the comments. Seems a bit odd that the function then figures out what it should do from the page & VM contents, given that the caller already needs to have known that something is wrong? > + * This function must be called while holding an exclusive lock on the heap > + * buffer, and any dead items must have been discovered under that same lock. > + * Although we do not hold a lock on the VM buffer, it is pinned, and the heap > + * buffer is exclusively locked, ensuring that no other backend can update the > + * VM bits corresponding to this heap page. > + * > + * This function makes changes to the VM and, potentially, the heap page, but > + * it does not need to be done in a critical section. > + */ > +static void > +heap_fix_vm_corruption(PruneState *prstate, OffsetNumber offnum) > +{ > + const char *relname = RelationGetRelationName(prstate->relation); > + > + Assert(BufferIsLockedByMeInMode(prstate->buffer, BUFFER_LOCK_EXCLUSIVE)); > + > + if (PageIsAllVisible(prstate->page)) > + { > + /* > + * It's possible for the value returned by > + * GetOldestNonRemovableTransactionId() to move backwards, so it's not > + * wrong for us to see tuples that appear to not be visible to > + * everyone yet, while PD_ALL_VISIBLE is already set. The real safe > + * xmin value never moves backwards, but > + * GetOldestNonRemovableTransactionId() is conservative and sometimes > + * returns a value that's unnecessarily small, so if we see that > + * contradiction it just means that the tuples that we think are not > + * visible to everyone yet actually are, and the PD_ALL_VISIBLE flag > + * is correct. > + * > + * However, there should never be LP_DEAD items, dead tuple versions, > + * or tuples inserted by an in-progress transaction on a page with > + * PD_ALL_VISIBLE set. > + */ > + if (prstate->lpdead_items > 0) > + { > + ereport(WARNING, > + (errcode(ERRCODE_DATA_CORRUPTED), > + errmsg("dead line pointer found on page marked all-visible"), > + errcontext("relation \"%s\", page %u, tuple %u", > + relname, prstate->block, offnum))); > + } > + else > + { > + ereport(WARNING, > + (errcode(ERRCODE_DATA_CORRUPTED), > + errmsg("tuple not visible to all transactions found on page marked all-visible"), > + errcontext("relation \"%s\", page %u, tuple %u", > + relname, prstate->block, offnum))); > + } Wait, why are we now WARNING about the PageIsAllVisible() && prstate->lpdead_items == 0 case? Seems to run flatly counter to the comment above about GetOldestNonRemovableTransactionId() going backward? > + /* > + * Mark the buffer dirty now in case we make no further changes and > + * therefore would not mark it dirty later. > + */ > + PageClearAllVisible(prstate->page); > + MarkBufferDirtyHint(prstate->buffer, true); > + } > + else if (prstate->vmbits & VISIBILITYMAP_VALID_BITS) > + { > + /* > + * As of PostgreSQL 9.2, the visibility map bit should never be set if > + * the page-level bit is clear. However, for vacuum, it's possible > + * that the bit got cleared after heap_vac_scan_next_block() was > + * called, so we must recheck now that we have the buffer lock before > + * concluding that the VM is corrupt. > + */ > + ereport(WARNING, > + (errcode(ERRCODE_DATA_CORRUPTED), > + errmsg("page is not marked all-visible but visibility map bit is set"), > + errcontext("relation \"%s\", page %u", > + relname, prstate->block))); > + } > + > + visibilitymap_clear(prstate->relation, prstate->block, prstate->vmbuffer, > + VISIBILITYMAP_VALID_BITS); > + prstate->vmbits = 0; So we can end up clearing the VM without emitting any warning? > /* > * Prune and repair fragmentation and potentially freeze tuples on the > @@ -830,6 +941,10 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > new_relfrozen_xid, new_relmin_mxid, > presult, &prstate); > > + if ((prstate.vmbits & VISIBILITYMAP_VALID_BITS) && > + !PageIsAllVisible(prstate.page)) > + heap_fix_vm_corruption(&prstate, InvalidOffsetNumber); > + > /* > * Examine all line pointers and tuple visibility information to determine > * which line pointers should change state and which tuples may be frozen. Feels like there should be an explanation here for why we are clearing the VM? > From a503285e012de12539df384d615675c1e48e5cfd Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 25 Feb 2026 16:48:19 -0500 > Subject: [PATCH v40 02/12] Add pruning fast path for all-visible and > all-frozen pages > > Because of the SKIP_PAGES_THRESHOLD optimization or a stale prune XID, > heap_page_prune_and_freeze() can be invoked for pages with no pruning or > freezing work. To avoid this, if a page is already all-frozen or it is > all-visible and no freezing will be attempted, we exit early. We can't > exit early if vacuum passed DISABLE_PAGE_SKIPPING, though. > > +static void > +heap_page_bypass_prune_freeze(PruneState *prstate, PruneFreezeResult *presult) > +{ > + OffsetNumber maxoff = PageGetMaxOffsetNumber(prstate->page); > + Page page = prstate->page; > + > + Assert(prstate->vmbits & VISIBILITYMAP_ALL_FROZEN || > + (prstate->vmbits & VISIBILITYMAP_ALL_VISIBLE && > + !prstate->attempt_freeze)); > + > + /* We'll fill in presult for the caller */ > + memset(presult, 0, sizeof(PruneFreezeResult)); > + > + presult->vmbits = prstate->vmbits; > + > + /* Clear any stale prune hint */ > + if (TransactionIdIsValid(PageGetPruneXid(page))) > + { > + PageClearPrunable(page); > + MarkBufferDirtyHint(prstate->buffer, true); > + } > + > + if (PageIsEmpty(page)) > + return; > + > + presult->hastup = true; Is that actually a given? Couldn't the page consist solely out of unused items? That'd make PageIsEmpty() return false, but should still allow truncation. > From 255fc9aeb721ba96ee3a7b7c3e675a4ee11087d6 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 17 Dec 2025 16:51:05 -0500 > Subject: [PATCH v40 03/12] Use GlobalVisState in vacuum to determine page > level visibility > > During vacuum's first and third phases, we examine tuples' visibility > to determine if we can set the page all-visible in the visibility map. > > Previously, this check compared tuple xmins against a single XID chosen at > the start of vacuum (OldestXmin). We now use GlobalVisState, which also > enables future work to set the VM during on-access pruning, since ordinary > queries have access to GlobalVisState but not OldestXmin. > > This also benefits vacuum: in some cases, GlobalVisState may advance > during a vacuum, allowing more pages to become considered all-visible. > And, in the future, we could easily add a heuristic to update > GlobalVisState more frequently during vacuums of large tables. > > OldestXmin is still used for freezing and as a backstop to ensure we > don't freeze a dead tuple that wasn't yet prunable according to > GlobalVisState in the rare occurrences where GlobalVisState moves > backwards. > Because comparing a transaction ID against GlobalVisState is more > expensive than comparing against a single XID, we defer this check until > after scanning all tuples on the page. Therefore, we perform the > GlobalVisState check only once per page. This is safe because > visibility_cutoff_xid records the newest live xmin on the page; > if it is globally visible, then the entire page is all-visible. > > Using GlobalVisState means on-access pruning can also maintain > visibility_cutoff_xid. This approach will result in examining more tuple > xmins than before; however, the additional cost should not be > significant. And doing so will enable us to set the visibility map on > access in the future. I wish there were a good way to trigger errors if visibility_cutoff_xid were ever read after prstate->set_all_frozen is set to false... But I guess that'll be moot in a few commits. > diff --git a/src/backend/access/heap/pruneheap.c b/src/backend/access/heap/pruneheap.c > index bf740c37f3d..c85e4172ee8 100644 > --- a/src/backend/access/heap/pruneheap.c > +++ b/src/backend/access/heap/pruneheap.c > @@ -1043,6 +1043,17 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > */ > prune_freeze_plan(&prstate, off_loc); > > + /* > + * After processing all the live tuples on the page, if the newest xmin > + * amongst them may be considered running by any snapshot, the page cannot > + * be all-visible. > + */ > + if (prstate.set_all_visible && > + TransactionIdIsNormal(prstate.visibility_cutoff_xid) && > + GlobalVisTestXidMaybeRunning(prstate.vistest, > + prstate.visibility_cutoff_xid)) > + prstate.set_all_visible = prstate.set_all_frozen = false; > + So the docs for prstate.visibility_cutoff_xid say: * visibility_cutoff_xid is the newest xmin of live tuples on the page. * The caller can use it as the conflict horizon, when setting the VM * bits. It is only valid if we froze some tuples, and set_all_frozen is * true. But here we look at it without checking that we froze some tuples. I guess the comment is outdated? > @@ -3615,7 +3612,7 @@ heap_page_is_all_visible(Relation rel, Buffer buf, > * Returns true if the page is all-visible other than the provided > * deadoffsets and false otherwise. > * > - * OldestXmin is used to determine visibility. > + * vistest is used to determine visibility. > * > * Output parameters: > * Could the "going backward" thing possibly trigger a spurious assert in Assert(heap_page_is_all_visible(vacrel->rel, buf, vacrel->vistest, &debug_all_frozen, &debug_cutoff, &vacrel->offnum)); > From a1d768a8cea8ac13e250188ec96c01d98acda94a Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Sat, 28 Feb 2026 16:06:51 -0500 > Subject: [PATCH v40 04/12] Keep newest live XID up-to-date even if page not > all-visible I guess I'd have expected 03 and 04 to be swapped... But whatever. > + * Currently, only VACUUM performs freezing, but other callers may in the > + * future. Other callers must initialize prstate.set_all_frozen to false, > + * since we will not call heap_prepare_freeze_tuple() for each tuple. What does it mean that other callers need to "initialize prstate.set_all_frozen to false"? It's not like they can do that, because prstate is defined in heap_page_prune_and_freeze(). > + * We only consider opportunistic freezing if the page would become > + * all-frozen, or if it would be all-frozen except for dead tuples that > + * VACUUM will remove. It kinda feels like "opportunistic freezing" is not defined at this point. It wasn't super clear before either, but there was at least this: - * In addition to telling the caller whether it can set the VM bit, we - * also use 'set_all_visible' and 'set_all_frozen' for our own - * decision-making. If the whole page would become frozen, we consider - * opportunistically freezing tuples. We will not be able to freeze the - * whole page if there are tuples present that are not visible to everyone - * or if there are dead tuples which are not yet removable. However, dead - * tuples which will be removed by the end of vacuuming should not - * preclude us from opportunistically freezing. Because of that, we do Which seems to provide a bit more explanation than "We only consider opportunistic freezing"... > From 05dfe8841e4a90dc595775863d58bacce996d70b Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 2 Dec 2025 15:07:42 -0500 > Subject: [PATCH v40 05/12] Eliminate XLOG_HEAP2_VISIBLE from vacuum phase I > prune/freeze "Eliminate" kinda makes me think this just removes WAL logging for visibilitymap sets or such. Perhaps consider rephrasing it as something like "WAL log setting VM as part of XLOG_HEAP2_PRUNE_*" > This change applies only to vacuum phase I, not to pruning performed > during normal page access. Maybe + "For now this ..." > @@ -215,7 +219,7 @@ static void page_verify_redirects(Page page); > > static bool heap_page_will_freeze(bool did_tuple_hint_fpi, bool do_prune, bool do_hint_prune, > PruneState *prstate); > - > +static bool heap_page_will_set_vm(PruneState *prstate, PruneReason reason); > > /* > * Optionally prune and repair fragmentation in the specified page. Previously there were two newlines between the declarations and code, now only one. Intentional? > +/* > + * Decide whether to set the visibility map bits (all-visible and all-frozen) > + * for heap_blk using information from the PruneState and VM. > + * > + * This function does not actually set the VM bits or page-level visibility > + * hint, PD_ALL_VISIBLE. > + * > + * Returns true if one or both VM bits should be set and false otherwise. > + */ > +static bool > +heap_page_will_set_vm(PruneState *prstate, PruneReason reason) > +{ > + /* > + * Though on-access pruning maintains prstate->set_all_visible, we don't > + * consider setting the VM. > + */ > + if (reason == PRUNE_ON_ACCESS) > + return false; Nitpick^2: We kind of are considering based on this comment :). I'd just s/consider setting/set/, maybe with a +for now. > @@ -956,12 +996,10 @@ heap_page_bypass_prune_freeze(PruneState *prstate, PruneFreezeResult *presult) > * tuples if it's required in order to advance relfrozenxid / relminmxid, or > * if it's considered advantageous for overall system performance to do so > * now. The 'params.cutoffs', 'presult', 'new_relfrozen_xid' and > - * 'new_relmin_mxid' arguments are required when freezing. When > - * HEAP_PAGE_PRUNE_FREEZE option is passed, we also set > - * presult->set_all_visible and presult->set_all_frozen after determining > - * whether or not to opportunistically freeze, to indicate if the VM bits can > - * be set. 'all-frozen' is always set to false when the HEAP_PAGE_PRUNE_FREEZE > - * option is not passed. > + * 'new_relmin_mxid' arguments are required when freezing. > + * > + * A vmbuffer corresponding to the heap page is also passed and if the page is > + * found to be all-visible/all-frozen, we will set it in the VM. > * > * presult contains output parameters needed by callers, such as the number of > * tuples removed and the offsets of dead items on the page after pruning. > @@ -989,15 +1027,17 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > bool do_freeze; > bool do_prune; > bool do_hint_prune; > + bool do_set_vm; > bool did_tuple_hint_fpi; > int64 fpi_before = pgWalUsage.wal_fpi; > + TransactionId conflict_xid = InvalidTransactionId; > > /* Initialize prstate */ > prune_freeze_setup(params, > new_relfrozen_xid, new_relmin_mxid, > presult, &prstate); > > - if ((prstate.vmbits & VISIBILITYMAP_VALID_BITS) && > + if ((prstate.old_vmbits & VISIBILITYMAP_VALID_BITS) && > !PageIsAllVisible(prstate.page)) > heap_fix_vm_corruption(&prstate, InvalidOffsetNumber); There are so many changes related to s/vmbits/old_vmbits/. How about naming it old_vmbits from the start? That'll make this commit a lot less noisy. > @@ -1076,6 +1116,30 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > prstate.set_all_visible = prstate.set_all_frozen = false; > > Assert(!prstate.set_all_frozen || prstate.set_all_visible); > + Assert(!prstate.set_all_visible || (prstate.lpdead_items == 0)); Why didn't we have this assert earlier? > + do_set_vm = heap_page_will_set_vm(&prstate, params->reason); Most of the other heap_page_prune_and_freeze() helpers are named heap_prune_xyz(), why not follow that here? I guess this holds for a few other helpers added in earlier commits too. E.g. heap_page_bypass_prune_freeze() should probably be heap_prune_bypass_prune_freeze() or such. > + /* > + * new_vmbits should be 0 regardless of whether or not the page is > + * all-visible if we do not intend to set the VM. > + */ > + Assert(do_set_vm || prstate.new_vmbits == 0); > + > + /* > + * The snapshot conflict horizon for the whole record is the most > + * conservative (newest) horizon required by any change in the record. > + */ > + if (do_set_vm) > + conflict_xid = prstate.newest_live_xid; > + if (do_freeze && TransactionIdFollows(prstate.pagefrz.FreezePageConflictXid, conflict_xid)) > + conflict_xid = prstate.pagefrz.FreezePageConflictXid; > + if (do_prune && TransactionIdFollows(prstate.latest_xid_removed, conflict_xid)) > + conflict_xid = prstate.latest_xid_removed; I guess I'd personally move the initialization of conflict_xid to InvalidTransactionId to just before the if, to make it clearer where we start from if !do_set_vm. > @@ -1097,14 +1161,17 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > > /* > * If that's all we had to do to the page, this is a non-WAL-logged > - * hint. If we are going to freeze or prune the page, we will mark > - * the buffer dirty below. > + * hint. If we are going to freeze or prune the page or set > + * PD_ALL_VISIBLE, we will mark the buffer dirty below. > + * > + * Setting PD_ALL_VISIBLE is fully WAL-logged because it is forbidden > + * for the VM to be set and PD_ALL_VISIBLE to be clear. > */ > - if (!do_freeze && !do_prune) > + if (!do_freeze && !do_prune && !do_set_vm) > MarkBufferDirtyHint(prstate.buffer, true); > } This block is gated by if (do_hint_prune) which is computed as: /* * Even if we don't prune anything, if we found a new value for the * pd_prune_xid field or the page was marked full, we will update the hint * bit. */ do_hint_prune = PageGetPruneXid(prstate.page) != prstate.new_prune_xid || PageIsFull(prstate.page); It's not really related to this change, but I'm just confused a bit by the "|| PageIsFull(prstate.page)". What is that about? Why do we want to mark the buffer DirtyHint if the page is full? It very well might already have been marked as such, no? > - if (do_prune || do_freeze) > + if (do_prune || do_freeze || do_set_vm) > { > /* Apply the planned item changes and repair page fragmentation. */ > if (do_prune) > @@ -1118,6 +1185,27 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > if (do_freeze) > heap_freeze_prepared_tuples(prstate.buffer, prstate.frozen, prstate.nfrozen); > > + /* Set the visibility map and page visibility hint */ > + if (do_set_vm) > + { > + /* > + * While it is valid for PD_ALL_VISIBLE to be set when the > + * corresponding VM bit is clear, we strongly prefer to keep them > + * in sync. > + * > + * The heap buffer must be marked dirty before adding it to the > + * WAL chain when setting the VM. We don't worry about > + * unnecessarily dirtying the heap buffer if PD_ALL_VISIBLE is > + * already set, though. It is extremely rare to have a clean heap > + * buffer with PD_ALL_VISIBLE already set and the VM bits clear, > + * so there is no point in optimizing it. > + */ > + PageSetAllVisible(prstate.page); > + PageClearPrunable(prstate.page); Idle thought, not to be acted on now: Eventually it could make sense to not do PageClearPrunable() if we are not marking the page frozen, but instead replace the prune xid with something triggering on-access pruning when freezing is reasonable. > + /* > + * During its second pass over the heap, VACUUM calls > + * heap_page_would_be_all_visible() to determine whether a page is > + * all-visible and all-frozen. The logic here is similar. After completing > + * pruning and freezing, use an assertion to verify that our results > + * remain consistent with heap_page_would_be_all_visible(). > + */ > +#ifdef USE_ASSERT_CHECKING > + if (prstate.set_all_visible) > + { > + TransactionId debug_cutoff; > + bool debug_all_frozen; > + > + Assert(prstate.lpdead_items == 0); > + > + Assert(heap_page_is_all_visible(prstate.relation, prstate.buffer, > + prstate.vistest, > + &debug_all_frozen, > + &debug_cutoff, off_loc)); > + > + /* > + * It's possible the page is composed entirely of frozen tuples but is > + * not set all-frozen in the VM and did not pass > + * HEAP_PAGE_PRUNE_FREEZE. In this case, it's possible > + * heap_page_is_all_visible() finds the page completely frozen, even > + * though prstate.set_all_frozen is false. > + */ > + Assert(!prstate.set_all_frozen || debug_all_frozen); Seems like we could verify that debug_cutoff isn't newer than conflict_xid? > + } > +#endif Hm. I guess aborting after we did incorrect pruning/freezing/VMing is better than not, but it'd be even better if we did it before corrupting things. But I guess it'd be not trivial to add something like the debug_cutoff assertion I suggest above, when freezing of tuples is only executed after heap_page_is_all_visible() (for dead tuples heap_page_would_be_all_visible() already has provisions). It's probably more a theoretical concern than a real worry. > + presult->new_all_visible_pages = 0; > + presult->new_all_frozen_pages = 0; > + presult->new_all_visible_frozen_pages = 0; Isn't it odd to talk about pages here? Given that heap_page_prune_and_freeze() only ever operates on exactly one page. Is that just so you can do > + vacrel->new_all_visible_pages += presult.new_all_visible_pages; etc? > + if (do_set_vm) > + { > + if ((prstate.old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0) > + { > + presult->new_all_visible_pages = 1; > + if (prstate.set_all_frozen) > + presult->new_all_visible_frozen_pages = 1; > + } > + else if ((prstate.old_vmbits & VISIBILITYMAP_ALL_FROZEN) == 0 && > + prstate.set_all_frozen) > + presult->new_all_frozen_pages = 1; > + } > + > if (prstate.attempt_freeze) > { > if (presult->nfrozen > 0) Feels like this is kinda redoing what heap_page_will_set_vm already did. > @@ -472,7 +458,13 @@ extern void log_heap_prune_and_freeze(Relation relation, Buffer buffer, > /* in heap/vacuumlazy.c */ > extern void heap_vacuum_rel(Relation rel, > const VacuumParams params, BufferAccessStrategy bstrategy); > - > +#ifdef USE_ASSERT_CHECKING > +extern bool heap_page_is_all_visible(Relation rel, Buffer buf, > + GlobalVisState *vistest, > + bool *all_frozen, > + TransactionId *visibility_cutoff_xid, > + OffsetNumber *logging_offnum); > +#endif > /* in heap/heapam_visibility.c */ > extern bool HeapTupleSatisfiesVisibility(HeapTuple htup, Snapshot snapshot, > Buffer buffer); I'd not remove the newline before "/* in heap/heapam_visibility.c */". Other "sections" do have that newline before the "/* in $filename */" comment too. > From c47a6270a0a0045347cdb4597b957798d21db4aa Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Sat, 27 Sep 2025 11:55:21 -0400 > Subject: [PATCH v40 06/12] Eliminate XLOG_HEAP2_VISIBLE from empty-page vacuum Same comment about Eliminate as in the prior commit. Perhaps worth mentioning more explicitly that this doesn't really have an advantage other than getting rid of the last user of XLOG_HEAP2_VISIBLE? > @@ -1923,13 +1926,33 @@ lazy_scan_new_or_empty(LVRelState *vacrel, Buffer buf, BlockNumber blkno, > PageSetAllVisible(page); > PageClearPrunable(page); > - visibilitymap_set(vacrel->rel, blkno, buf, > - InvalidXLogRecPtr, > - vmbuffer, InvalidTransactionId, > - VISIBILITYMAP_ALL_VISIBLE | > - VISIBILITYMAP_ALL_FROZEN); > + visibilitymap_set_vmbits(blkno, > + vmbuffer, > + VISIBILITYMAP_ALL_VISIBLE | > + VISIBILITYMAP_ALL_FROZEN, > + vacrel->rel->rd_locator); > + > + /* > + * Emit WAL for setting PD_ALL_VISIBLE on the heap page and > + * setting the VM. > + */ > + if (RelationNeedsWAL(vacrel->rel)) > + log_heap_prune_and_freeze(vacrel->rel, buf, > + vmbuffer, > + VISIBILITYMAP_ALL_VISIBLE | > + VISIBILITYMAP_ALL_FROZEN, > + InvalidTransactionId, /* conflict xid */ > + false, /* cleanup lock */ > + PRUNE_VACUUM_SCAN, /* reason */ > + NULL, 0, > + NULL, 0, > + NULL, 0, > + NULL, 0); > + > END_CRIT_SECTION(); It's a tad odd that we do: /* * It's possible that another backend has extended the heap, * initialized the page, and then failed to WAL-log the page due * to an ERROR. Since heap extension is not WAL-logged, recovery * might try to replay our record setting the page all-visible and * find that the page isn't initialized, which will cause a PANIC. * To prevent that, check whether the page has been previously * WAL-logged, and if not, do that now. */ if (RelationNeedsWAL(vacrel->rel) && !XLogRecPtrIsValid(PageGetLSN(page))) log_newpage_buffer(buf, true); if we then immediately afterwards emit a WAL record that could just as well have included in FPI of the heap page. > From 181c83f0652bfebe0db2f11983ad08b52c8c780b Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Sat, 27 Sep 2025 11:55:36 -0400 > Subject: [PATCH v40 07/12] Remove XLOG_HEAP2_VISIBLE entirely > > There are no remaining users that emit XLOG_HEAP2_VISIBLE records, so it > can be removed. This includes deleting the xl_heap_visible struct and > all functions responsible for emitting or replaying XLOG_HEAP2_VISIBLE > records. > This changes the visibility map API, so any external users/consumers of > the VM-only WAL record will need to change. I hope there aren't any. Not sure I can really see scenarios in which that'd be a safe thing to do from an external user... > diff --git a/src/include/access/heapam_xlog.h b/src/include/access/heapam_xlog.h > index ce3566ba949..5eed567a8e5 100644 > --- a/src/include/access/heapam_xlog.h > +++ b/src/include/access/heapam_xlog.h > @@ -60,7 +60,6 @@ > #define XLOG_HEAP2_PRUNE_ON_ACCESS 0x10 > #define XLOG_HEAP2_PRUNE_VACUUM_SCAN 0x20 > #define XLOG_HEAP2_PRUNE_VACUUM_CLEANUP 0x30 > -#define XLOG_HEAP2_VISIBLE 0x40 > #define XLOG_HEAP2_MULTI_INSERT 0x50 > #define XLOG_HEAP2_LOCK_UPDATED 0x60 > #define XLOG_HEAP2_NEW_CID 0x70 > @@ -443,20 +442,6 @@ typedef struct xl_heap_inplace I think other places with a gap in the "actions" mention that some value is now unused. > diff --git a/src/backend/access/common/bufmask.c b/src/backend/access/common/bufmask.c > index 8a67bfa1aff..d9042e1f91d 100644 > --- a/src/backend/access/common/bufmask.c > +++ b/src/backend/access/common/bufmask.c > @@ -56,8 +56,8 @@ mask_page_hint_bits(Page page) > > /* > * During replay, if the page LSN has advanced past our XLOG record's LSN, > - * we don't mark the page all-visible. See heap_xlog_visible() for > - * details. > + * we don't mark the page all-visible. See heap_xlog_prune_freeze() for > + * more details. > */ > PageClearAllVisible(page); > } Not introduced by your change, but isn't it rather terrifying that the wal_consistency_checking infrastructure doesn't verify whether the page is marked all-visible? Wasn't aware of this. Seems bonkers to me. I don't even know what specifically in heap_xlog_visible() that comment is referring to? Just that we only do PageSetAllVisible() if BLK_NEEDS_REDO? But uh, what does that have to do with anything? > diff --git a/src/backend/access/heap/visibilitymap.c b/src/backend/access/heap/visibilitymap.c > index e21b96281a6..f1da52b2069 100644 > --- a/src/backend/access/heap/visibilitymap.c > +++ b/src/backend/access/heap/visibilitymap.c > @@ -14,8 +14,7 @@ > * visibilitymap_clear - clear bits for one page in the visibility map > * visibilitymap_pin - pin a map page for setting a bit > * visibilitymap_pin_ok - check whether correct map page is already pinned > - * visibilitymap_set - set bit(s) in a previously pinned page and log > - * visibilitymap_set_vmbits - set bit(s) in a pinned page > + * visibilitymap_set - set bit(s) in a previously pinned page > * visibilitymap_get_status - get status of bits > * visibilitymap_count - count number of bits set in visibility map > * visibilitymap_prepare_truncate - There's a comment saying: * Clearing visibility map bits is not separately WAL-logged. The callers * must make sure that whenever a bit is cleared, the bit is cleared on WAL * replay of the updating operation as well. Which kinda implies that setting the VM *is* separately WAL logged. But that's not true anymore. Maybe rephrase that ever so slightly? > @@ -477,12 +477,12 @@ ResolveRecoveryConflictWithSnapshot(TransactionId snapshotConflictHorizon, > * If we get passed InvalidTransactionId then we do nothing (no conflict). > * > * This can happen when replaying already-applied WAL records after a > - * standby crash or restart, or when replaying an XLOG_HEAP2_VISIBLE > - * record that marks as frozen a page which was already all-visible. It's > - * also quite common with records generated during index deletion > - * (original execution of the deletion can reason that a recovery conflict > - * which is sufficient for the deletion operation must take place before > - * replay of the deletion record itself). > + * standby crash or restart Again not about your patch: I don't understand how already applied WAL can lead to InvalidTransactionId being passed here. The record doesn't change just because we had already applied the WAL? > From 04b03c1ec3abcee75e464fef994b482df41b35f4 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Wed, 3 Dec 2025 15:07:24 -0500 > Subject: [PATCH v40 08/12] Track which relations are modified by a query > > Save the relids of modified relations in a bitmap in the executor state. > A later commit will pass this information down to scan nodes to control > whether or not on-access pruning is allowed to set the visibility map. > Setting the visibility map during a scan is counterproductive if the > query is going to modify the page immediately after. > > Relations are considered modified if they are the target of INSERT, > UPDATE, DELETE, or MERGE, or if they have any row mark (including SELECT > FOR UPDATE/SHARE). All row mark types are included, even those which > don't actually modify tuples, because this bitmap is only used as a hint > to avoid unnecessary work. You're probably going to hate me for the question, but is there a reason to not compute es_modified_relids at plan time? > @@ -992,6 +996,10 @@ InitPlan(QueryDesc *queryDesc, int eflags) > */ > planstate = ExecInitNode(plan, estate, eflags); > > +#ifdef USE_ASSERT_CHECKING > + CrossCheckModifiedRelids(estate); > +#endif Not sure that buys you much, given it pretty much is just a restatement of the code building estate->es_modified_relids. What about checking against PlannedStmt->{resultRelations, permInfos} or asserting membership at the places that actually lock/modify? > @@ -3048,6 +3056,12 @@ EvalPlanQualStart(EPQState *epqstate, Plan *planTree) > rcestate->es_output_cid = parentestate->es_output_cid; > rcestate->es_queryEnv = parentestate->es_queryEnv; > > + /* > + * Use a deep copy to avoid stale pointers since bms_add_member() may > + * reallocate the bitmap. > + */ > + rcestate->es_modified_relids = bms_copy(parentestate->es_modified_relids); > + > /* > * ResultRelInfos needed by subplans are initialized from scratch when the > * subplans themselves are initialized. Hm. Why copy at all from the parent? Afaict we'll just redo the computation of es_modified_relids from scratch anyway? Not sure about it though. > From 05d736fb5b0600effede5e030d5b929274dabe2c Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Mon, 2 Mar 2026 16:31:17 -0500 > Subject: [PATCH v40 09/12] Thread flags through begin-scan APIs > > Add a flags parameter to the index_fetch_begin() table AM callback and > the begin-scan helpers so the executor can pass context for building > scan descriptors. This introduces an extension point for follow-up work > to mark relations as read-only for the current query, without changing > behavior in this patch. > diff --git a/src/include/access/genam.h b/src/include/access/genam.h > index 1a27bf060b3..db102803eb5 100644 > --- a/src/include/access/genam.h > +++ b/src/include/access/genam.h > @@ -158,7 +158,7 @@ extern IndexScanDesc index_beginscan(Relation heapRelation, > Relation indexRelation, > Snapshot snapshot, > IndexScanInstrumentation *instrument, > - int nkeys, int norderbys); > + int nkeys, int norderbys, uint32 flags); I'd probably put flags in a position where it's not as easily confused with nkeys or norderbys. > diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h > index 4ce63990326..3820bbd7f9f 100644 > --- a/src/include/access/heapam.h > +++ b/src/include/access/heapam.h > @@ -96,8 +96,9 @@ typedef struct HeapScanDescData > ParallelBlockTableScanWorkerData *rs_parallelworkerdata; > > /* > - * For sequential scans and bitmap heap scans. The current heap block's > - * corresponding page in the visibility map. > + * For sequential scans, bitmap heap scans, TID range scans, and sample > + * scans. The current heap block's corresponding page in the visibility > + * map. > */ > Buffer rs_vmbuffer; As you already can see here, exhaustively listing scan types is unlikely to be maintained over time... > @@ -1059,10 +1062,11 @@ table_scan_getnextslot(TableScanDesc sscan, ScanDirection direction, TupleTableS > static inline TableScanDesc > table_beginscan_tidrange(Relation rel, Snapshot snapshot, > ItemPointer mintid, > - ItemPointer maxtid) > + ItemPointer maxtid, uint32 flags) > { > TableScanDesc sscan; > - uint32 flags = SO_TYPE_TIDRANGESCAN | SO_ALLOW_PAGEMODE; > + > + flags |= SO_TYPE_TIDRANGESCAN | SO_ALLOW_PAGEMODE; > > sscan = table_beginscan_common(rel, snapshot, 0, NULL, NULL, flags); Hm. Would it perhaps be a good idea to have an assert as to which flags are specified by the "user"? If e.g. another SO_TYPE_* were specified it might result in some odd behaviour. Perhaps this would be best done by adding an argument to table_beginscan_common() specifying the "internal" flags (i.e. the ones that specified inside table_beginscan_*) and user specified flags? Then table_beginscan_common could check the set of user specified flags being sane. > From 7790c8177ba3aa8a8bd1a216ea77fdfd42efc1bf Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Mon, 2 Mar 2026 16:31:33 -0500 > Subject: [PATCH v40 10/12] Pass down information on table modification to scan > node > diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h > index 3820bbd7f9f..1a7306e2935 100644 > --- a/src/include/access/heapam.h > +++ b/src/include/access/heapam.h > @@ -132,6 +132,12 @@ typedef struct IndexFetchHeapData > > /* Current heap block's corresponding page in the visibility map */ > Buffer xs_vmbuffer; > + > + /* > + * Some optimizations can only be performed if the query does not modify > + * the underlying relation. Track that here. > + */ > + bool modifies_base_rel; > } IndexFetchHeapData; > The other members are prefixed with xs_, I don't see a reason to diverge for this one. Wonder if this should be in the generic IndexFetchTableData? > From 0a16dad7a4ebe224f35629a39619d0feb03f03a3 Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Fri, 27 Feb 2026 16:33:40 -0500 > Subject: [PATCH v40 11/12] Allow on-access pruning to set pages all-visible > > Many queries do not modify the underlying relation. For such queries, if > on-access pruning occurs during the scan, we can check whether the page > has become all-visible and update the visibility map accordingly. > Previously, only vacuum and COPY FREEZE marked pages as all-visible or > all-frozen. > This commit implements on-access VM setting for sequential scans as well > as for the underlying heap relation in index scans and bitmap heap > scans. I'd mention that this often can: - avoid write amplification, due to vacuum later having to PageSetAllVisible() (often triggering another data write and another FPI) - allow index only scans much earlier than before I think those are pretty huge benefits, so they should be mentioned. > diff --git a/src/test/recovery/t/035_standby_logical_decoding.pl b/src/test/recovery/t/035_standby_logical_decoding.pl > index d264a698ff6..a5536ba4ff6 100644 > --- a/src/test/recovery/t/035_standby_logical_decoding.pl > +++ b/src/test/recovery/t/035_standby_logical_decoding.pl > @@ -296,6 +296,7 @@ wal_level = 'logical' > max_replication_slots = 4 > max_wal_senders = 4 > autovacuum = off > +hot_standby_feedback = on > }); > $node_primary->dump_info; > $node_primary->start; > @@ -748,7 +749,7 @@ check_pg_recvlogical_stderr($handle, > $logstart = -s $node_standby->logfile; > > reactive_slots_change_hfs_and_wait_for_xmins('shared_row_removal_', > - 'no_conflict_', 0, 1); > + 'no_conflict_', 1, 0); > > # This should not trigger a conflict > wait_until_vacuum_can_remove( > -- > 2.43.0 Why does this patch need to change anything here? Is the test buggy independently? > From e4c7112d49e650f59dab834d3db6007c69f34f1a Mon Sep 17 00:00:00 2001 > From: Melanie Plageman <melanieplageman@gmail.com> > Date: Tue, 29 Jul 2025 16:12:56 -0400 > Subject: [PATCH v40 12/12] Set pd_prune_xid on insert > MIME-Version: 1.0 > Content-Type: text/plain; charset=UTF-8 > Content-Transfer-Encoding: 8bit > > Now that visibility map (VM) updates can occur during read-only queries, > it makes sense to also set the page’s pd_prune_xid hint during inserts > and on the new page during updates. > > This enables heap_page_prune_and_freeze() to run and set the VM > all-visible after a page is filled with newly inserted tuples the first > time it is read. > > This change also addresses a long-standing note in heap_insert() and > heap_multi_insert(), which observed that setting pd_prune_xid would > help clean up aborted insertions sooner. Without it, such tuples might > linger until VACUUM, whereas now they can be pruned earlier. I think this commit message should also mention more what the benefits of doing this are (i.e. a good potential for reduced write amplicifation and increased IOS potential). > The index killtuples test had to be updated to reflect a larger number > of hits by some accesses. Since the prune_xid is set by the fill/insert > step, on-access pruning can happen during the first access step (before > the DELETE). This is when the VM is extended. After the DELETE, the next > access hits the VM block instead of extending it. Thus, an additional > buffer hit is counted for the table. I think that may since already have been solved by f5eb854ab6d. > --- a/src/backend/access/heap/heapam.c > +++ b/src/backend/access/heap/heapam.c > @@ -2156,6 +2156,7 @@ heap_insert(Relation relation, HeapTuple tup, CommandId cid, > TransactionId xid = GetCurrentTransactionId(); > HeapTuple heaptup; > Buffer buffer; > + Page page; > Buffer vmbuffer = InvalidBuffer; > bool all_visible_cleared = false; > > @@ -2182,6 +2183,8 @@ heap_insert(Relation relation, HeapTuple tup, CommandId cid, > &vmbuffer, NULL, > 0); > > + page = BufferGetPage(buffer); > + > /* > * We're about to do the actual insert -- but check for conflict first, to > * avoid possibly having to roll back work we've just done. > @@ -2205,25 +2208,30 @@ heap_insert(Relation relation, HeapTuple tup, CommandId cid, > RelationPutHeapTuple(relation, buffer, heaptup, > (options & HEAP_INSERT_SPECULATIVE) != 0); > > - if (PageIsAllVisible(BufferGetPage(buffer))) > + if (PageIsAllVisible(page)) > { > all_visible_cleared = true; > - PageClearAllVisible(BufferGetPage(buffer)); > + PageClearAllVisible(page); > visibilitymap_clear(relation, > ItemPointerGetBlockNumber(&(heaptup->t_self)), > vmbuffer, VISIBILITYMAP_VALID_BITS); > } The repeated BufferGetPage()s have been bothering me, good :) > /* > - * XXX Should we set PageSetPrunable on this page ? > + * Set pd_prune_xid to trigger heap_page_prune_and_freeze() once the page > + * is full so that we can set the page all-visible in the VM on the next > + * page access. > * > - * The inserting transaction may eventually abort thus making this tuple > - * DEAD and hence available for pruning. Though we don't want to optimize > - * for aborts, if no other tuple in this page is UPDATEd/DELETEd, the > - * aborted tuple will never be pruned until next vacuum is triggered. > + * Setting pd_prune_xid is also handy if the inserting transaction > + * eventually aborts making this tuple DEAD and hence available for > + * pruning. If no other tuple in this page is UPDATEd/DELETEd, the aborted > + * tuple would never otherwise be pruned until next vacuum is triggered. > * > - * If you do add PageSetPrunable here, add it in heap_xlog_insert too. > + * Don't set it if we are in bootstrap mode or we are inserting a frozen > + * tuple. > */ > + if (TransactionIdIsNormal(xid) && !(options & HEAP_INSERT_FROZEN)) > + PageSetPrunable(page, xid); > > MarkBufferDirty(buffer); > Perhaps add "as neither of those can be pruned anyway." or such to the last sentence? > @@ -1863,16 +1864,14 @@ heap_prune_record_unchanged_lp_normal(PruneState *prstate, OffsetNumber offnum) > prstate->set_all_visible = false; > prstate->set_all_frozen = false; > > - /* The page should not be marked all-visible */ > - if (PageIsAllVisible(page)) > - heap_fix_vm_corruption(prstate, offnum); > - Huh? Getting close, I think. Greetings, Andres Freund -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-20T02:38:47Z
Thanks for the detailed review! Unless otherwise specified, attached v41 includes all of your straightforward review points. On Wed, Mar 18, 2026 at 1:14 PM Andres Freund <andres@anarazel.de> wrote: > > > + params.relation = relation; > > + params.buffer = buffer; > > + params.vmbuffer = *vmbuffer; > > + params.reason = PRUNE_ON_ACCESS; > > + params.vistest = vistest; > > + params.cutoffs = NULL; > > > > > * We don't pass the HEAP_PAGE_PRUNE_MARK_UNUSED_NOW option > > @@ -284,14 +312,7 @@ heap_page_prune_opt(Relation relation, Buffer buffer, Buffer *vmbuffer) > > * cannot safely determine that during on-access pruning with the > > * current implementation. > > */ > > - PruneFreezeParams params = { > > - .relation = relation, > > - .buffer = buffer, > > - .reason = PRUNE_ON_ACCESS, > > - .options = 0, > > - .vistest = vistest, > > - .cutoffs = NULL, > > - }; > > + params.options = 0; > > > > heap_page_prune_and_freeze(¶ms, &presult, &dummy_off_loc, > > NULL, NULL); > > Why does this change the way the PruneFreezeParams variable is defined? I > don't really mind, it's just a bit confusing. I couldn't use the designated initializer after visibilitymap_pin() and I thought it was worse to have the designated initializer nitialize vmbuffer to InvalidBuffer and then have to set vmbuffer to the real vmbuffer after visibilitymap_pin(). > > + * Helper to fix visibility-related corruption on a heap page and its > > + * corresponding VM page. An all-visible page cannot have dead items nor can > > + * it have tuples that are not visible to all running transactions. It clears > > + * the VM corruption as well as resetting the vmbits used during pruning. > > So this is now only called when we already know there's corruption? I think > that could be clearer in the comments. > > Seems a bit odd that the function then figures out what it should do from the > page & VM contents, given that the caller already needs to have known that > something is wrong? Yea, it was all a bit off. I agree. I've tried something new and made a VMCorruptionType enum for the caller to pass in which tells this function what to do (clear PD_ALL_VISIBLE and/or clear VM) and what warning to emit. > > +static void > > +heap_fix_vm_corruption(PruneState *prstate, OffsetNumber offnum) > > +{ > > + { > > + ereport(WARNING, > > + (errcode(ERRCODE_DATA_CORRUPTED), > > + errmsg("tuple not visible to all transactions found on page marked all-visible"), > > + errcontext("relation \"%s\", page %u, tuple %u", > > + relname, prstate->block, offnum))); > > + } > > Wait, why are we now WARNING about the PageIsAllVisible() && > prstate->lpdead_items == 0 case? Seems to run flatly counter to the comment > above about GetOldestNonRemovableTransactionId() going backward? Only if the page has tuples with HTSV_Result HEAPTUPLE_RECENTLY_DEAD/DELETE_IN_PROGRESS/INSERT_IN_PROGRESS. Even if GetOldestNonRemovableTransactionId() goes backwards that should only make it so that xids we previously thought were visible now show as not visible to all. But those have to be HEAPTUPLE_LIVE tuples. We should never thought it was all-visible if there were in-progress deletes/inserts. So, I think it is okay. Now (in v41), the caller would need to pass VM_CORRUPT_TUPLE_VISIBILITY and intend to emit the warning. > > + else if (prstate->vmbits & VISIBILITYMAP_VALID_BITS) > > + { > > + /* > > + * As of PostgreSQL 9.2, the visibility map bit should never be set if > > + * the page-level bit is clear. However, for vacuum, it's possible > > + * that the bit got cleared after heap_vac_scan_next_block() was > > + * called, so we must recheck now that we have the buffer lock before > > + * concluding that the VM is corrupt. > > + */ > > + ereport(WARNING, > > + (errcode(ERRCODE_DATA_CORRUPTED), > > + errmsg("page is not marked all-visible but visibility map bit is set"), > > + errcontext("relation \"%s\", page %u", > > + relname, prstate->block))); > > + } > > + > > + visibilitymap_clear(prstate->relation, prstate->block, prstate->vmbuffer, > > + VISIBILITYMAP_VALID_BITS); > > + prstate->vmbits = 0; > > So we can end up clearing the VM without emitting any warning? This was me trying to avoid duplicating code in the branches. In v41, I error out if the caller doesn't specify a valid corruption type, so anything that clears the VM will have emitted a warning. > > +static void > > +heap_page_bypass_prune_freeze(PruneState *prstate, PruneFreezeResult *presult) > > +{ > > + OffsetNumber maxoff = PageGetMaxOffsetNumber(prstate->page); > > + Page page = prstate->page; > > + > > + Assert(prstate->vmbits & VISIBILITYMAP_ALL_FROZEN || > > + (prstate->vmbits & VISIBILITYMAP_ALL_VISIBLE && > > + !prstate->attempt_freeze)); > > + > > + /* We'll fill in presult for the caller */ > > + memset(presult, 0, sizeof(PruneFreezeResult)); > > + > > + presult->vmbits = prstate->vmbits; > > + > > + /* Clear any stale prune hint */ > > + if (TransactionIdIsValid(PageGetPruneXid(page))) > > + { > > + PageClearPrunable(page); > > + MarkBufferDirtyHint(prstate->buffer, true); > > + } > > + > > + if (PageIsEmpty(page)) > > + return; > > + > > + presult->hastup = true; > > Is that actually a given? Couldn't the page consist solely out of unused > items? That'd make PageIsEmpty() return false, but should still allow > truncation. Good point. I've changed it to set hastup when counting live tuples. But should I set hastup it if I see an LP_REDIRECT pointer? I know I should always see a LP_NORMAL pointer if I see an LP_REDIRECT pointer, but I just wondered if I should explicitly set hastup when I see LP_REDIRECT since heap_prune_record_redirect() sets hastup = true. > > diff --git a/src/backend/access/heap/pruneheap.c b/src/backend/access/heap/pruneheap.c > > > + /* > > + * After processing all the live tuples on the page, if the newest xmin > > + * amongst them may be considered running by any snapshot, the page cannot > > + * be all-visible. > > + */ > > + if (prstate.set_all_visible && > > + TransactionIdIsNormal(prstate.visibility_cutoff_xid) && > > + GlobalVisTestXidMaybeRunning(prstate.vistest, > > + prstate.visibility_cutoff_xid)) > > + prstate.set_all_visible = prstate.set_all_frozen = false; > > + > > So the docs for prstate.visibility_cutoff_xid say: > > * visibility_cutoff_xid is the newest xmin of live tuples on the page. > * The caller can use it as the conflict horizon, when setting the VM > * bits. It is only valid if we froze some tuples, and set_all_frozen is > * true. > > But here we look at it without checking that we froze some tuples. I guess > the comment is outdated? That comment was never correct -- or I have chopped it into unrecognizable bits over the last two years. > Could the "going backward" thing possibly trigger a spurious assert in > > Assert(heap_page_is_all_visible(vacrel->rel, buf, > vacrel->vistest, &debug_all_frozen, > &debug_cutoff, &vacrel->offnum)); I don't think anything (today) updates GlobalVisState between GlobalVisTestXidMaybeRunning() and the heap_page_is_all_visible() assert. I had removed the visibility_cutoff_xid part of the assertion on the intuition that comparing an exact horizon would no longer work when using GlobalVisState. I can't remember if I actually saw failing tests, but I don't see them anymore (so I've put it back). The heap_page_is_all_visible() assertion moves into heap_page_prune_and_freeze() in a later patch in this set, and while it is also in a place where I don't think GlobalVisState can have moved between making the page changes and calling heap_page_is_all_visible(), I suspect it won't be a totally reliable assertion now that it uses a moving target for comparison. What do you think? > > From a1d768a8cea8ac13e250188ec96c01d98acda94a Mon Sep 17 00:00:00 2001 > > From: Melanie Plageman <melanieplageman@gmail.com> > > Date: Sat, 28 Feb 2026 16:06:51 -0500 > > Subject: [PATCH v40 04/12] Keep newest live XID up-to-date even if page not > > all-visible > > I guess I'd have expected 03 and 04 to be swapped... But whatever. It couldn't be because I used GlobalVisState to always keep it up-to-date (even for on-access pruning). > > @@ -1076,6 +1116,30 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > > prstate.set_all_visible = prstate.set_all_frozen = false; > > > > Assert(!prstate.set_all_frozen || prstate.set_all_visible); > > + Assert(!prstate.set_all_visible || (prstate.lpdead_items == 0)); > > Why didn't we have this assert earlier? It was in lazy_scan_prune() as: Assert(!presult.set_all_visible || !(*has_lpdead_items)); > > + do_set_vm = heap_page_will_set_vm(&prstate, params->reason); > > Most of the other heap_page_prune_and_freeze() helpers are named > heap_prune_xyz(), why not follow that here? > > I guess this holds for a few other helpers added in earlier commits > too. E.g. heap_page_bypass_prune_freeze() should probably be > heap_prune_bypass_prune_freeze() or such. Most of the helpers prefixed with "heap_prune" now directly do something related to pruning like recording line pointers and traversing hot chains. heap_page_will_set_vm() and heap_page_will_freeze() have nothing to do with pruning, so I think it makes sense they are named differently. And I don't think we are in any danger of folks using functions not prefixed with heap_prune for other purposes, given that most of them take a PruneState as an argument. I'll do a big rename if you feel strongly about it, though. > > @@ -1097,14 +1161,17 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, > > > > /* > > * If that's all we had to do to the page, this is a non-WAL-logged > > - * hint. If we are going to freeze or prune the page, we will mark > > - * the buffer dirty below. > > + * hint. If we are going to freeze or prune the page or set > > + * PD_ALL_VISIBLE, we will mark the buffer dirty below. > > + * > > + * Setting PD_ALL_VISIBLE is fully WAL-logged because it is forbidden > > + * for the VM to be set and PD_ALL_VISIBLE to be clear. > > */ > > - if (!do_freeze && !do_prune) > > + if (!do_freeze && !do_prune && !do_set_vm) > > MarkBufferDirtyHint(prstate.buffer, true); > > } > > This block is gated by if (do_hint_prune) which is computed as: > > /* > * Even if we don't prune anything, if we found a new value for the > * pd_prune_xid field or the page was marked full, we will update the hint > * bit. > */ > do_hint_prune = PageGetPruneXid(prstate.page) != prstate.new_prune_xid || > PageIsFull(prstate.page); > > It's not really related to this change, but I'm just confused a bit by the > "|| PageIsFull(prstate.page)". What is that about? Why do we want to mark the > buffer DirtyHint if the page is full? It very well might already have been > marked as such, no? Because if the page is marked full, we clear that hint, and, if that's the only change we make to the page, we need to do MarkBufferDirtyHint(). > > +#ifdef USE_ASSERT_CHECKING > > + if (prstate.set_all_visible) > > + { > > + TransactionId debug_cutoff; > > + bool debug_all_frozen; > > + > > + Assert(prstate.lpdead_items == 0); > > + > > + Assert(heap_page_is_all_visible(prstate.relation, prstate.buffer, > > + prstate.vistest, > > + &debug_all_frozen, > > + &debug_cutoff, off_loc)); > > + > > + /* > > + * It's possible the page is composed entirely of frozen tuples but is > > + * not set all-frozen in the VM and did not pass > > + * HEAP_PAGE_PRUNE_FREEZE. In this case, it's possible > > + * heap_page_is_all_visible() finds the page completely frozen, even > > + * though prstate.set_all_frozen is false. > > + */ > > + Assert(!prstate.set_all_frozen || debug_all_frozen); > > Seems like we could verify that debug_cutoff isn't newer than conflict_xid? Well, not conflict_xid, but newest_xid, yes. > Hm. I guess aborting after we did incorrect pruning/freezing/VMing is better > than not, but it'd be even better if we did it before corrupting things. But I > guess it'd be not trivial to add something like the debug_cutoff assertion I > suggest above, when freezing of tuples is only executed after > heap_page_is_all_visible() (for dead tuples heap_page_would_be_all_visible() > already has provisions). > > It's probably more a theoretical concern than a real worry. Yea, I think the work it would take to make heap_page_would_be_all_visible() work for frozen tuples wouldn't be worth it just to get it to assert out before executing the page changes. > > + presult->new_all_visible_pages = 0; > > + presult->new_all_frozen_pages = 0; > > + presult->new_all_visible_frozen_pages = 0; > > Isn't it odd to talk about pages here? Given that heap_page_prune_and_freeze() > only ever operates on exactly one page. Is that just so you can do > > > + vacrel->new_all_visible_pages += presult.new_all_visible_pages; I made this change because you didn't like it when I passed old_vmbits and new_vmbits back out to lazy_scan_prune() to derive these counters. FWIW I think it's better not to have lazy_scan_prune() compare new and old vmbits to increment counters, because lazy_scan_prune() shouldn't have to know about the VM anymore once it is not setting it. > > + if (do_set_vm) > > + { > > + if ((prstate.old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0) > > + { > > + presult->new_all_visible_pages = 1; > > + if (prstate.set_all_frozen) > > + presult->new_all_visible_frozen_pages = 1; > > + } > > + else if ((prstate.old_vmbits & VISIBILITYMAP_ALL_FROZEN) == 0 && > > + prstate.set_all_frozen) > > + presult->new_all_frozen_pages = 1; > > + } > > + > > if (prstate.attempt_freeze) > > { > > if (presult->nfrozen > 0) > > Feels like this is kinda redoing what heap_page_will_set_vm already did. The logic is different than what is in heap_page_will_set_vm() because there we don't care about what old_vmbits is. We are simply concerned with whether we should set new_vmbits to something. So we need to have logic somewhere that is figuring out if the vmbits were set before and whether we newly set them. That can either go in heap_page_prune_and_freeze() and we can use that to set the counters in the LVRelState or it can go in lazy_scan_prune(). I think it makes more sense in heap_page_prune_and_freeze() so that lazy_scan_prune() doesn't have to know about the VM's new/old state, which it otherwise no longer deals with. > > @@ -1923,13 +1926,33 @@ lazy_scan_new_or_empty(LVRelState *vacrel, Buffer buf, BlockNumber blkno, > > PageSetAllVisible(page); > > PageClearPrunable(page); > > - visibilitymap_set(vacrel->rel, blkno, buf, > > - InvalidXLogRecPtr, > > - vmbuffer, InvalidTransactionId, > > - VISIBILITYMAP_ALL_VISIBLE | > > - VISIBILITYMAP_ALL_FROZEN); > > + visibilitymap_set_vmbits(blkno, > > + vmbuffer, > > + VISIBILITYMAP_ALL_VISIBLE | > > + VISIBILITYMAP_ALL_FROZEN, > > + vacrel->rel->rd_locator); > > + > > + /* > > + * Emit WAL for setting PD_ALL_VISIBLE on the heap page and > > + * setting the VM. > > + */ > > + if (RelationNeedsWAL(vacrel->rel)) > > + log_heap_prune_and_freeze(vacrel->rel, buf, > > + vmbuffer, > > + VISIBILITYMAP_ALL_VISIBLE | > > + VISIBILITYMAP_ALL_FROZEN, > > + InvalidTransactionId, /* conflict xid */ > > + false, /* cleanup lock */ > > + PRUNE_VACUUM_SCAN, /* reason */ > > + NULL, 0, > > + NULL, 0, > > + NULL, 0, > > + NULL, 0); > > + > > END_CRIT_SECTION(); > > It's a tad odd that we do: > > /* > * It's possible that another backend has extended the heap, > * initialized the page, and then failed to WAL-log the page due > * to an ERROR. Since heap extension is not WAL-logged, recovery > * might try to replay our record setting the page all-visible and > * find that the page isn't initialized, which will cause a PANIC. > * To prevent that, check whether the page has been previously > * WAL-logged, and if not, do that now. > */ > if (RelationNeedsWAL(vacrel->rel) && > !XLogRecPtrIsValid(PageGetLSN(page))) > log_newpage_buffer(buf, true); > > if we then immediately afterwards emit a WAL record that could just as well > have included in FPI of the heap page. I originally added a flag to log_heap_prune_and_freeze() that could force an FPI but Robert disliked it, saying he found it more confusing. He said: > 0004. It is not clear to me why you need to get > log_heap_prune_and_freeze to do the work here. Why can't > log_newpage_buffer get the job done already? I can put it back that way, I don't have strong feelings either way. Though I imagine if I add another argument to log_heap_prune_and_freeze(), you'll bring up creating a struct for its arguments again... > > diff --git a/src/backend/access/common/bufmask.c b/src/backend/access/common/bufmask.c > > index 8a67bfa1aff..d9042e1f91d 100644 > > --- a/src/backend/access/common/bufmask.c > > +++ b/src/backend/access/common/bufmask.c > > @@ -56,8 +56,8 @@ mask_page_hint_bits(Page page) > > > > /* > > * During replay, if the page LSN has advanced past our XLOG record's LSN, > > - * we don't mark the page all-visible. See heap_xlog_visible() for > > - * details. > > + * we don't mark the page all-visible. See heap_xlog_prune_freeze() for > > + * more details. > > */ > > PageClearAllVisible(page); > > } > > Not introduced by your change, but isn't it rather terrifying that the > wal_consistency_checking infrastructure doesn't verify whether the page is > marked all-visible? Wasn't aware of this. Seems bonkers to me. Agreed. I wonder what it would take to start. > I don't even know what specifically in heap_xlog_visible() that comment is > referring to? Just that we only do PageSetAllVisible() if BLK_NEEDS_REDO? But > uh, what does that have to do with anything? Yea, this comment doesn't make sense. I think we should remove it. But regarding why we mask PD_ALL_VISIBLE in wal consistency checking, I wonder if this is the scenario: Record 1 sets the VM and PD_ALL_VISIBLE Record 2 inserts a tuple and clears PD_ALL_VISIBLE the heap page is flushed to disk, but the VM page is not crash replay R1; skip setting PD_ALL_VISIBLE because the page has R2's LSN; set the bits on the VM page Even though we'll clear the VM when we replay R2, if we cross-check the page and VM after replaying only R1, the VM will be set and PD_ALL_VISIBLE will be clear. I think this is okay because no one should see them at this time. But it might not work with wal consistency checking. > > @@ -477,12 +477,12 @@ ResolveRecoveryConflictWithSnapshot(TransactionId snapshotConflictHorizon, > > * If we get passed InvalidTransactionId then we do nothing (no conflict). > > * > > * This can happen when replaying already-applied WAL records after a > > - * standby crash or restart, or when replaying an XLOG_HEAP2_VISIBLE > > - * record that marks as frozen a page which was already all-visible. It's > > - * also quite common with records generated during index deletion > > - * (original execution of the deletion can reason that a recovery conflict > > - * which is sufficient for the deletion operation must take place before > > - * replay of the deletion record itself). > > + * standby crash or restart > > Again not about your patch: I don't understand how already applied WAL can > lead to InvalidTransactionId being passed here. The record doesn't change just > because we had already applied the WAL? Yea, I think the comment is just wrong. I realized the comment still needed to reference my code, so I've updated it. > > From 04b03c1ec3abcee75e464fef994b482df41b35f4 Mon Sep 17 00:00:00 2001 > > From: Melanie Plageman <melanieplageman@gmail.com> > > Date: Wed, 3 Dec 2025 15:07:24 -0500 > > Subject: [PATCH v40 08/12] Track which relations are modified by a query > > > > Save the relids of modified relations in a bitmap in the executor state. > > A later commit will pass this information down to scan nodes to control > > whether or not on-access pruning is allowed to set the visibility map. > > Setting the visibility map during a scan is counterproductive if the > > query is going to modify the page immediately after. > > > > Relations are considered modified if they are the target of INSERT, > > UPDATE, DELETE, or MERGE, or if they have any row mark (including SELECT > > FOR UPDATE/SHARE). All row mark types are included, even those which > > don't actually modify tuples, because this bitmap is only used as a hint > > to avoid unnecessary work. > > You're probably going to hate me for the question, but is there a reason to > not compute es_modified_relids at plan time? Yea, it probably does make more sense there. The only thing is that by doing it in planner, it could include relids of leaf partitions that get run-time pruned. But we won't scan those, so it is no issue for this feature. I'm just wondering if it dilutes the meaning of "modified relids", though. In v41, I've implemented it in planner (which also made me realize parallel workers previously didn't have es_modified_relids, oops). > > @@ -992,6 +996,10 @@ InitPlan(QueryDesc *queryDesc, int eflags) > > */ > > planstate = ExecInitNode(plan, estate, eflags); > > > > +#ifdef USE_ASSERT_CHECKING > > + CrossCheckModifiedRelids(estate); > > +#endif > > Not sure that buys you much, given it pretty much is just a restatement of the > code building estate->es_modified_relids. Yea, now that I've done it in planner, I cross-check in the executor. > What about checking against PlannedStmt->{resultRelations, permInfos} or I don't think it makes sense to use permInfos because according to expand_single_inheritance_child() there is no permission checking for child RTEs, so I think permInfos won't include everything we need. > asserting membership at the places that actually lock/modify? Are you thinking I should also add some in ExecInsert, ExecDelete, ExecUpdate, and ExecLockRows? Think this might be redundant with the executor cross-check I have now after InitPlan(). (I've done it anyway so we can discuss). > > diff --git a/src/include/access/genam.h b/src/include/access/genam.h > > index 1a27bf060b3..db102803eb5 100644 > > --- a/src/include/access/genam.h > > +++ b/src/include/access/genam.h > > @@ -158,7 +158,7 @@ extern IndexScanDesc index_beginscan(Relation heapRelation, > > Relation indexRelation, > > Snapshot snapshot, > > IndexScanInstrumentation *instrument, > > - int nkeys, int norderbys); > > + int nkeys, int norderbys, uint32 flags); > > I'd probably put flags in a position where it's not as easily confused with > nkeys or norderbys. Do you mean like move it before nkeys and norderbys or move it earlier? I did the latter but not sure if it's weird to have flags before snapshot (especially since the other table am routines pass it last). I think it looks kind of weird when all of the other ones have flags as the last argument. > > @@ -1059,10 +1062,11 @@ table_scan_getnextslot(TableScanDesc sscan, ScanDirection direction, TupleTableS > > static inline TableScanDesc > > table_beginscan_tidrange(Relation rel, Snapshot snapshot, > > ItemPointer mintid, > > - ItemPointer maxtid) > > + ItemPointer maxtid, uint32 flags) > > { > > TableScanDesc sscan; > > - uint32 flags = SO_TYPE_TIDRANGESCAN | SO_ALLOW_PAGEMODE; > > + > > + flags |= SO_TYPE_TIDRANGESCAN | SO_ALLOW_PAGEMODE; > > > > sscan = table_beginscan_common(rel, snapshot, 0, NULL, NULL, flags); > > Hm. Would it perhaps be a good idea to have an assert as to which flags are > specified by the "user"? If e.g. another SO_TYPE_* were specified it might > result in some odd behaviour. > > Perhaps this would be best done by adding an argument to > table_beginscan_common() specifying the "internal" flags (i.e. the ones that > specified inside table_beginscan_*) and user specified flags? Then > table_beginscan_common could check the set of user specified flags being sane. Yes, good idea. Done in attached v41. It's unclear to me which flags should be considered internal though. I think it makes sense that the SO_TYPE* flags are considered internal because you can only specify one. But all of the other current ScanOptions are specified inside table_beginscan_* so do you mean that we should consider all of those internal flags? > > + * Some optimizations can only be performed if the query does not modify > > + * the underlying relation. Track that here. > > + */ > > + bool modifies_base_rel; > > } IndexFetchHeapData; > > Wonder if this should be in the generic IndexFetchTableData? I added flags to the IndexFetchTableData in much the same way as the regular table scan descriptor has them. > > diff --git a/src/test/recovery/t/035_standby_logical_decoding.pl b/src/test/recovery/t/035_standby_logical_decoding.pl > > index d264a698ff6..a5536ba4ff6 100644 > > --- a/src/test/recovery/t/035_standby_logical_decoding.pl > > +++ b/src/test/recovery/t/035_standby_logical_decoding.pl > > @@ -296,6 +296,7 @@ wal_level = 'logical' > > max_replication_slots = 4 > > max_wal_senders = 4 > > autovacuum = off > > +hot_standby_feedback = on > > }); > > $node_primary->dump_info; > > $node_primary->start; > > @@ -748,7 +749,7 @@ check_pg_recvlogical_stderr($handle, > > $logstart = -s $node_standby->logfile; > > > > reactive_slots_change_hfs_and_wait_for_xmins('shared_row_removal_', > > - 'no_conflict_', 0, 1); > > + 'no_conflict_', 1, 0); > > > > # This should not trigger a conflict > > wait_until_vacuum_can_remove( > > -- > > 2.43.0 > > Why does this patch need to change anything here? Is the test buggy > independently? Nope. I guess that was a mistake during development. No change needed. > > @@ -1863,16 +1864,14 @@ heap_prune_record_unchanged_lp_normal(PruneState *prstate, OffsetNumber offnum) > > prstate->set_all_visible = false; > > prstate->set_all_frozen = false; > > > > - /* The page should not be marked all-visible */ > > - if (PageIsAllVisible(page)) > > - heap_fix_vm_corruption(prstate, offnum); > > - > > Huh? heap_prune_record_prunable() already does the corruption check, so I don't need to do it separately for INSERT_IN_PROGRESS tuples once we call heap_prune_record_prunable() for them. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-20T23:37:08Z
On Thu, Mar 19, 2026 at 10:38 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > Thanks for the detailed review! Unless otherwise specified, attached > v41 includes all of your straightforward review points. I've made several minor updates and two notable updates in attached v42: - no separate log_newpage_buffer() for empty page vacuum. log_heap_prune_and_freeze() now handles pages without a valid LSN on its own - the heap_page_is_all_visible() assertion should be stable even once it uses GlobalVisState because I've updated the GloablVisState functions to avoid updating the GlobalVisState boundaries in this case - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-22T19:58:27Z
On Fri, Mar 20, 2026 at 7:37 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > I've made several minor updates and two notable updates in attached v42: > > - no separate log_newpage_buffer() for empty page vacuum. > log_heap_prune_and_freeze() now handles pages without a valid LSN on > its own > - the heap_page_is_all_visible() assertion should be stable even once > it uses GlobalVisState because I've updated the GloablVisState > functions to avoid updating the GlobalVisState boundaries in this case I've pushed the first two patches. Attached are the remaining 10. No changes were made to those from the previous version. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-23T21:54:13Z
On Sun, Mar 22, 2026 at 3:58 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > I've pushed the first two patches. Attached are the remaining 10. No > changes were made to those from the previous version. I'm planning on pushing 0001-0005 in the morning. I've made some significant changes to 0006 and realized I need some help. 0006 tracks what relations are modified by a query. This new version (v44) uses relation oids instead of rt indexes to handle cases where the same relation appears more than once in the range table (e.g. INSERT INTO foo SELECT * FROM foo; foo appears twice). It computes modifiedRelOids (a list of relation OIDs modified by the query) in the planner and stores them in the PlannedStmt. There is one big issue I'm not sure how to solve: For queries like INSERT INTO ptable SELECT * FROM ptable, where ptable is a partitioned table, though we scan ptable, we don't know when executing that scan that we will then modify ptable with the insert. In my patch, I've added find_all_inheritors() when populating modifiedRelOids, but I realize this probably isn't acceptable to add to planner from a performance perspective. I'm looking for other ways to solve the problem. Now, for my use case (setting the VM), we don't mind setting the VM during the table scan part of the query. Whatever page gets the inserted tuple will clear all-visible -- but that is just one page out of many. However, future users of modifiedRelOids will likely expect it to contain all modified relation oids. I could also check when setting up the scan descriptor if the leaf partition's parents (would have to check full ancestry) are in modifiedRelOids. This also doesn't address the problem of future users thinking modifiedRelOids is complete. Note that it also means partitions that aren't modified will be included in modifiedRelOids if one of the partitions is being modified. I could also just change the name of the modifiedRelOids to something that doesn't make future users think it's exhaustive. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Kirill Reshke <reshkekirill@gmail.com> — 2026-03-24T06:53:44Z
On Tue, 24 Mar 2026 at 02:54, Melanie Plageman <melanieplageman@gmail.com> wrote: > > On Sun, Mar 22, 2026 at 3:58 PM Melanie Plageman > <melanieplageman@gmail.com> wrote: > > > > I've pushed the first two patches. Attached are the remaining 10. No > > changes were made to those from the previous version. > > I'm planning on pushing 0001-0005 in the morning. > Thanks for taking care. I think it would be good to get WAL volume reduction in v19 from 0004 & 0005. lgtm -- Best regards, Kirill Reshke
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2026-03-24T17:53:43Z
Hi, On 2026-03-23 17:54:13 -0400, Melanie Plageman wrote: > I've made some significant changes to 0006 and realized I need some > help. 0006 tracks what relations are modified by a query. This new > version (v44) uses relation oids instead of rt indexes to handle cases > where the same relation appears more than once in the range table > (e.g. INSERT INTO foo SELECT * FROM foo; foo appears twice). It > computes modifiedRelOids (a list of relation OIDs modified by the > query) in the planner and stores them in the PlannedStmt. There is one > big issue I'm not sure how to solve: I'm not entirely sure this is something we need to catch and therefore not sure that modifiedRelOids is worth the trouble over just having the RT indexes. > For queries like INSERT INTO ptable SELECT * FROM ptable, where ptable > is a partitioned table, though we scan ptable, we don't know when > executing that scan that we will then modify ptable with the insert. But does that matter? If such a query inserts a meaningful amount of rows it's going to insert into different pages than the ones you selected from? > In my patch, I've added find_all_inheritors() when populating > modifiedRelOids, but I realize this probably isn't acceptable to add > to planner from a performance perspective. Agreed. Greetings, Andres Freund
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-24T23:44:24Z
Attached v45 is what remains in the patchset (I committed the rest). On Tue, Mar 24, 2026 at 1:53 PM Andres Freund <andres@anarazel.de> wrote: > > On 2026-03-23 17:54:13 -0400, Melanie Plageman wrote: > > I've made some significant changes to 0006 and realized I need some > > help. 0006 tracks what relations are modified by a query. This new > > version (v44) uses relation oids instead of rt indexes to handle cases > > where the same relation appears more than once in the range table > > (e.g. INSERT INTO foo SELECT * FROM foo; foo appears twice). It > > computes modifiedRelOids (a list of relation OIDs modified by the > > query) in the planner and stores them in the PlannedStmt. There is one > > big issue I'm not sure how to solve: > > I'm not entirely sure this is something we need to catch and therefore not > sure that modifiedRelOids is worth the trouble over just having the RT > indexes. Do you see the disadvantage of saving the oids as the space? I guess it is also worse (from a semantic perspective) to use oids if the set is incomplete—for example, because of the insert into leaf partition case. If they are RT indexes, then it is accurate to say that it includes all RT indexes for modified rels. For INSERT INTO foo SELECT * FROM foo, if the pages are mostly full, setting pages all-visible during the scan won't hurt because we will insert at the end of the table. And if there is freespace throughout, we won't do on-access pruning. So, I actually don't think we could end up setting and unsetting the VM for every page. In v45, I've gone back to RT indexes. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Tomas Vondra <tomas@vondra.me> — 2026-03-25T18:02:02Z
Hi, I took a look at the patch series, mostly because passing flags to beginscan would be useful for the explain patches in the index prefetch patch series. Right now there's no convenient way for the executor to tell the TAM to collect instrumentation data (and so it's collected always, but it'd be nice to make that optional). And I think from this point of view it's fine. I have only a couple rather superficial comments: 0001 - no comments 0002 - Don't we usually keep "flags" as the last parameter? It seems a bit weird that it's added in between relation and snapshot. - Do we really want to pass two sets of flags to table_beginscan_common? I realize it's done to ensure "users" don't use internal flags, but then maybe it'd be better to do that check in the places calling the _common? Someone adding a new caller can break this in various ways anyway, e.g. by setting bits in the internal flags, no? If we want to have these checks, should we be more thorough? Should we check the internal flags only set internal flags? 0003 - Half the "beginscan" calls use a ternary operator directly, half sets a variable first (and then uses that). Often mixed in the same file. Shouldn't it be a bit consistent? 0004, 0005 - no comment -- Tomas Vondra
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-25T18:54:16Z
On Wed, Mar 25, 2026 at 2:02 PM Tomas Vondra <tomas@vondra.me> wrote: > > 0002 > > - Don't we usually keep "flags" as the last parameter? It seems a bit > weird that it's added in between relation and snapshot. In an earlier review, Andres said he disliked using flags as the last parameter for index_beginscan() because its current last two parameters are integers (nkeys and norderbys), which could be confusing. Personally, I think you have to look at the function signature before just randomly passing stuff, and so it shouldn't matter -- but I didn't care enough to argue. If you agree with me that they should be last, then it's two against one and I'll change it back :) I can keep the callsite comments naming the flags parameter. > - Do we really want to pass two sets of flags to table_beginscan_common? > I realize it's done to ensure "users" don't use internal flags, but > then maybe it'd be better to do that check in the places calling the > _common? Someone adding a new caller can break this in various ways > anyway, e.g. by setting bits in the internal flags, no? Yes, callers of table_beginscan_common() could pass flags they shouldn't in internal_flags. But I was mostly trying to prevent the case where a user picks a flag that overlaps with an internal flag, conditionally passes it as a user flag, and then when they test for it in their AM-specific code, they aren't actually checking if their own flag is set. Anyway, it's not hard to move: Assert((flags & SO_INTERNAL_FLAGS) == 0); into the table_beginscan_common() callers and then pass the internal flags the caller wants to pass + the user specified flags to table_beginscan_common(). And I think that fixes what you are talking about? > If we want to have these checks, should we be more thorough? Should we > check the internal flags only set internal flags? That's easy enough too. Assert((internal_flags & ~SO_INTERNAL_FLAGS) == 0); I think does the trick. I think this would largely be the same as having table_beginscan_common() callers validate that the user-passed flags are not internal and then OR them together with the internal flags they want to pass to table_beginscan_common(). I'm trying to think of cases where the two approaches would differ so I can decide which to do. > 0003 > > - Half the "beginscan" calls use a ternary operator directly, half sets > a variable first (and then uses that). Often mixed in the same file. > Shouldn't it be a bit consistent? Indeed. - Melanie -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-25T23:14:17Z
On Wed, Mar 25, 2026 at 2:54 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > I'm trying to think of cases where the two approaches would differ so > I can decide which to do. > > > 0003 > > > > - Half the "beginscan" calls use a ternary operator directly, half sets > > a variable first (and then uses that). Often mixed in the same file. > > Shouldn't it be a bit consistent? > > Indeed. Attached v46 addresses your feedback and has a bit of assorted cleanup in it. I started wondering if table_beginscan_strat() is a bit weird now because it has two boolean arguments that are basically just SO_ALLOW_STRAT and SO_ALLOW_SYNC -- so those are kind of letting the user set "internal" flags. Anyway, I'm not sure we should do anything about it, but it got me thinking. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Tomas Vondra <tomas@vondra.me> — 2026-03-25T23:29:03Z
On 3/25/26 19:54, Melanie Plageman wrote: > On Wed, Mar 25, 2026 at 2:02 PM Tomas Vondra <tomas@vondra.me> wrote: >> >> 0002 >> >> - Don't we usually keep "flags" as the last parameter? It seems a bit >> weird that it's added in between relation and snapshot. > > In an earlier review, Andres said he disliked using flags as the last > parameter for index_beginscan() because its current last two > parameters are integers (nkeys and norderbys), which could be > confusing. Personally, I think you have to look at the function > signature before just randomly passing stuff, and so it shouldn't > matter -- but I didn't care enough to argue. If you agree with me that > they should be last, then it's two against one and I'll change it back > :) I can keep the callsite comments naming the flags parameter. > Who am I to argue with Andres? ;-) I'm kinda used to flags being the last argument, but it's not something I'm particularly attached to. >> - Do we really want to pass two sets of flags to table_beginscan_common? >> I realize it's done to ensure "users" don't use internal flags, but >> then maybe it'd be better to do that check in the places calling the >> _common? Someone adding a new caller can break this in various ways >> anyway, e.g. by setting bits in the internal flags, no? > > Yes, callers of table_beginscan_common() could pass flags they > shouldn't in internal_flags. But I was mostly trying to prevent the > case where a user picks a flag that overlaps with an internal flag, > conditionally passes it as a user flag, and then when they test for it > in their AM-specific code, they aren't actually checking if their own > flag is set. > Ah, so we expect people to invent their "own" flags, outside what's in ScanOptions? Or do I misunderstand how it works? (I admit not reading the whole massive thread, as I was only interested in using the flags in my own patch.) > Anyway, it's not hard to move: > Assert((flags & SO_INTERNAL_FLAGS) == 0); > into the table_beginscan_common() callers and then pass the internal > flags the caller wants to pass + the user specified flags to > table_beginscan_common(). And I think that fixes what you are talking > about? > Right. I wouldn't say it "fixes" it, because it wasn't a bug. But it does ensure the two sets do not "overlap", which I assume should never happen. >> If we want to have these checks, should we be more thorough? Should we >> check the internal flags only set internal flags? > > That's easy enough too. > Assert((internal_flags & ~SO_INTERNAL_FLAGS) == 0); I think does the trick. > > I think this would largely be the same as having > table_beginscan_common() callers validate that the user-passed flags > are not internal and then OR them together with the internal flags > they want to pass to table_beginscan_common(). > > I'm trying to think of cases where the two approaches would differ so > I can decide which to do. > OK -- Tomas Vondra
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-26T14:51:25Z
On Wed, Mar 25, 2026 at 7:29 PM Tomas Vondra <tomas@vondra.me> wrote: > > >> - Do we really want to pass two sets of flags to table_beginscan_common? > >> I realize it's done to ensure "users" don't use internal flags, but > >> then maybe it'd be better to do that check in the places calling the > >> _common? Someone adding a new caller can break this in various ways > >> anyway, e.g. by setting bits in the internal flags, no? > > > > Yes, callers of table_beginscan_common() could pass flags they > > shouldn't in internal_flags. But I was mostly trying to prevent the > > case where a user picks a flag that overlaps with an internal flag, > > conditionally passes it as a user flag, and then when they test for it > > in their AM-specific code, they aren't actually checking if their own > > flag is set. > > Ah, so we expect people to invent their "own" flags, outside what's in > ScanOptions? Or do I misunderstand how it works? (I admit not reading > the whole massive thread, as I was only interested in using the flags in > my own patch.) Yes, this isn't really explored in the rest of the thread. I thought since the flags are threaded all the way through and they can set/check the flags in the table AM-specific layer, it would make sense that they could choose flags for their own purposes. They don't have to wait for consensus on getting a new SO type added. I don't know if this is a bad idea. However, changing the table AM wrappers seems more justifiable if we are making them extensible in this way. > >> If we want to have these checks, should we be more thorough? Should we > >> check the internal flags only set internal flags? > > > > That's easy enough too. > > Assert((internal_flags & ~SO_INTERNAL_FLAGS) == 0); I think does the trick. I did this in the previously posted v46. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Tomas Vondra <tomas@vondra.me> — 2026-03-26T16:07:41Z
On 3/26/26 15:51, Melanie Plageman wrote: > On Wed, Mar 25, 2026 at 7:29 PM Tomas Vondra <tomas@vondra.me> wrote: >> >>>> - Do we really want to pass two sets of flags to table_beginscan_common? >>>> I realize it's done to ensure "users" don't use internal flags, but >>>> then maybe it'd be better to do that check in the places calling the >>>> _common? Someone adding a new caller can break this in various ways >>>> anyway, e.g. by setting bits in the internal flags, no? >>> >>> Yes, callers of table_beginscan_common() could pass flags they >>> shouldn't in internal_flags. But I was mostly trying to prevent the >>> case where a user picks a flag that overlaps with an internal flag, >>> conditionally passes it as a user flag, and then when they test for it >>> in their AM-specific code, they aren't actually checking if their own >>> flag is set. >> >> Ah, so we expect people to invent their "own" flags, outside what's in >> ScanOptions? Or do I misunderstand how it works? (I admit not reading >> the whole massive thread, as I was only interested in using the flags in >> my own patch.) > > Yes, this isn't really explored in the rest of the thread. I thought > since the flags are threaded all the way through and they can > set/check the flags in the table AM-specific layer, it would make > sense that they could choose flags for their own purposes. They don't > have to wait for consensus on getting a new SO type added. I don't > know if this is a bad idea. However, changing the table AM wrappers > seems more justifiable if we are making them extensible in this way. > No idea. Do we have an example of a TAM actually needing this? If not, I'd probably advise to remove that and keep the patch simpler. My past attempts to future-proof a patch like this rarely worked. If we want to give TAMs the opportunity to define custom flags, do we already do something like that elsewhere? Is there a precedent how to do that? If we allow the TAM to pick arbitrary flag values, it's easy to end up with collisions later (if we add a new internal flag). Maybe there is a way to prevent that? E.g. we could restrict internal flags to 0x0000FFFF, and custom flags to 0xFFFF0000? regards -- Tomas Vondra
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
David Rowley <dgrowleyml@gmail.com> — 2026-03-26T23:10:15Z
On Thu, 26 Mar 2026 at 12:14, Melanie Plageman <melanieplageman@gmail.com> wrote: > Attached v46 addresses your feedback and has a bit of assorted cleanup in it. (I've not had a chance to process this thread, so apologies if I missed discussion on certain things I'm going to say) I was looking at v46-0001. With: +++ b/src/include/nodes/plannodes.h @@ -112,6 +112,12 @@ typedef struct PlannedStmt */ Bitmapset *unprunableRelids; + /* + * RT indexes of relations modified by the query through + * UPDATE/DELETE/INSERT/MERGE or targeted by SELECT FOR UPDATE/SHARE. + */ + Bitmapset *modifiedRelids; + This doesn't really mention anything about leaf partitions not being mentioned for INSERT queries. You did mention it in standard_planner() here: + * modification. Conversely, leaf partitions whose result relations are + * created at the time of insert are not included here. I think if someone is going to use this field, they're going to look at where the field is defined to find out what it is, not where it gets populated. I'm also wondering about having this combined field. If you were to have a Bitmapset field that mirrors "List *resultRelations;", then have another: /* a list of PlanRowMark's */ List *rowMarks; + /* Relids which have rowMarks */ + Bitmapset *rowMarkRelids; I think they're more likely to be useful for other purposes, and I think the only pain that it causes you is that you have to call bms_is_member() twice in ScanRelIsReadOnly(). Then, as a follow-up, maybe we could consider removing PlannedStmt.resultRelations. (The deprecated) ExecRelationIsTargetRelation() could use the new Bitmapset, which would be more efficient. OverExplain does do: if (es->format != EXPLAIN_FORMAT_TEXT || plannedstmt->resultRelations != NIL) overexplain_intlist("Result RTIs", plannedstmt->resultRelations, es); but maybe Robert is ok with those coming out in ascending numerical order rather than list order. overexplain_bitmapset() would do that. In [1], I didn't see any code actually using the field. Just a couple of projects that have duplicated the copyObject() code. I did quickly look over the remaining patches. I wondered if you might want to add a new ScanOption SO_NONE = 0, or SO_EMPTY_FLAGS. It might make the places where you're passing zero directly easier to read? David [1] https://codesearch.debian.net/search?q=resultRelations&literal=1 -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-27T19:17:45Z
On Thu, Mar 26, 2026 at 7:10 PM David Rowley <dgrowleyml@gmail.com> wrote: > > I was looking at v46-0001. With: Thanks for taking a look! > +++ b/src/include/nodes/plannodes.h > + Bitmapset *modifiedRelids; > + > > This doesn't really mention anything about leaf partitions not being > mentioned for INSERT queries. You did mention it in standard_planner() > here: > > I'm also wondering about having this combined field. If you were to > have a Bitmapset field that mirrors "List *resultRelations;", then > have another: > > /* a list of PlanRowMark's */ > List *rowMarks; > > + /* Relids which have rowMarks */ > + Bitmapset *rowMarkRelids; > > I think they're more likely to be useful for other purposes, and I > think the only pain that it causes you is that you have to call > bms_is_member() twice in ScanRelIsReadOnly(). Yea, outside of the insert into leaf partitions case, I thought of another, perhaps even more compelling reason the combined field might be a bit confusing: Take a table t1 and a table t2. If you do SELECT * FROM t1 JOIN t2 ON t1.id = t2.id FOR UPDATE of t1; t1 will get a ROW_MARK_EXCLUSIVE and t2 would get a ROW_MARK_REFERENCE (that's just how preprocess_rowmarks() works). That means modifiedRelids would contain t2, even though t2 is not being locked for update. For the purposes of setting the VM, it's totally fine that we are more conservative than we need to be and don't consider setting it when scanning t2. But for the purposes of modifiedRelids, it's a bit confusing that t2 is in there. But we can't just exclude ROW_MARK_REFERENCE from modifiedRelids because we rely on ROW_MARK_REFERENCE to avoid setting the VM for a table we are updating or deleting from when it is mentioned more than once in the query (e.g. UPDATE foo SET x = 1 FROM foo f2 WHERE foo.id = f2.id). So, for that reason and because of the missing leaf partitions for inserts, I think making quick reference bitmapsets would be better. I've done this in attached v47. I've also removed the asserts in ExecInsert/Update/Delete because they are a bit tautological now. My one remaining question is whether the two new bitmapsets (rowMarkRelids and resultRelationRelids) should move from the PlannedStmt to the EState. They are determined at plan time and never modified during execution. However, I do notice there are other EState members that seem like just a copy of info from the PlannedStmt that isn't modified during execution (e.g. es_rteperminfos/permInfos). However, putting them in the EState increases the work required to get them to parallel workers and to the child estate for EPQs. I would prefer to keep it in the PlannedStmt but am worried that breaks convention. > Then, as a follow-up, maybe we could consider removing > PlannedStmt.resultRelations. (The deprecated) > ExecRelationIsTargetRelation() could use the new Bitmapset, which > would be more efficient. Yea, I like this and think it makes sense. Done in v47. > I did quickly look over the remaining patches. I wondered if you might > want to add a new ScanOption SO_NONE = 0, or SO_EMPTY_FLAGS. It might > make the places where you're passing zero directly easier to read? That makes sense to me. Done in v47. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-27T19:31:04Z
On Thu, Mar 26, 2026 at 12:07 PM Tomas Vondra <tomas@vondra.me> wrote: > > >> Ah, so we expect people to invent their "own" flags, outside what's in > >> ScanOptions? Or do I misunderstand how it works? (I admit not reading > >> the whole massive thread, as I was only interested in using the flags in > >> my own patch.) > > > > Yes, this isn't really explored in the rest of the thread. I thought > > since the flags are threaded all the way through and they can > > set/check the flags in the table AM-specific layer, it would make > > sense that they could choose flags for their own purposes. They don't > > have to wait for consensus on getting a new SO type added. I don't > > know if this is a bad idea. However, changing the table AM wrappers > > seems more justifiable if we are making them extensible in this way. > > > > No idea. Do we have an example of a TAM actually needing this? If not, > I'd probably advise to remove that and keep the patch simpler. My past > attempts to future-proof a patch like this rarely worked. Yea, not allowing that doesn't really simplify the patch. But, talking to Andres off-list yesterday, he reminded me that users can simply add a new member to their table access method-specific scan descriptor (e.g. HeapScanDescData could get a new member). The value of flags lies in enabling table AM-agnostic executor code to pass flags through the table AM to the scan code. Besides my read-only hint scan option, he gave some examples -- like a hint to the scan that there is a LIMIT on the query. I think that is compelling. While exploring this, I realized that for a few internal flags, such as SO_ALLOW_STRAT and SO_ALLOW_SYNC, we have table scan functions, like table_beginscan_strat(), that accept parameters for setting those flags. They are basically the same as table_beginscan() but give users control over those flags. I think we can use the flags parameter to deprecate some of these specialized table scan functions. I think we can simplify the scan_rescan() callback as well. I don't think it makes sense to do it this late in the 19 release, though. All of those changes require having a flags parameter in the top level scan wrappers first. So, I think it is reasonable to do just that this release. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-29T17:16:39Z
On Fri, Mar 27, 2026 at 3:17 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > Done in v47. Attached v48 does a bit more cleanup. No functional changes. I'm planning to push this soon. I think my remaining question is whether I should move the row marks and result relation bitmaps into the estate. I'm leaning toward not doing that and leaving them in the PlannedStmt. Anyway, If I want to replace the list of result relation RTIs in the PlannedStmt, I have to leave the bitmapset version there. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
David Rowley <dgrowleyml@gmail.com> — 2026-03-31T02:16:59Z
On Mon, 30 Mar 2026 at 06:16, Melanie Plageman <melanieplageman@gmail.com> wrote: > Attached v48 does a bit more cleanup. No functional changes. I'm > planning to push this soon. I think my remaining question is whether I > should move the row marks and result relation bitmaps into the estate. > I'm leaning toward not doing that and leaving them in the PlannedStmt. > Anyway, If I want to replace the list of result relation RTIs in the > PlannedStmt, I have to leave the bitmapset version there. I looked at v48-0001 and it looks fine to me. I've only minor quibbles about you using foreach() instead of foreach_int() and foreach_node() for populating the new Bitmapsets in standard_planner(). I don't see any advantage to adding the fields to EState. There might be if there was some performance reason, but it looks like you're only accessing the fields when scans are initialised. It's hard to imagine an extra pointer deference would matter there. I didn't find any guidance in any comments to understand if there's a best practise here, so I assume what's there today is down to people's taste. For me, I'd say if it's not performance critical and the executor does not modify the field for any purpose, then keeping it in PlannedStmt is fine. If someone thinks I'm wrong on that, then a comment at the top of EState would be helpful. David
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-31T16:19:20Z
Thanks for the reply! I have committed the patches in this thread and marked the CF entry accordingly. On Mon, Mar 30, 2026 at 10:17 PM David Rowley <dgrowleyml@gmail.com> wrote: > > I looked at v48-0001 and it looks fine to me. I've only minor quibbles > about you using foreach() instead of foreach_int() and foreach_node() > for populating the new Bitmapsets in standard_planner(). Good point. I forgot about those. Attached patch fixes that (since the code was already committed). - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
David Rowley <dgrowleyml@gmail.com> — 2026-03-31T22:14:23Z
On Wed, 1 Apr 2026 at 05:19, Melanie Plageman <melanieplageman@gmail.com> wrote: > > Thanks for the reply! I have committed the patches in this thread and > marked the CF entry accordingly. Yeah, realised that after sending the email. > On Mon, Mar 30, 2026 at 10:17 PM David Rowley <dgrowleyml@gmail.com> wrote: > > > > I looked at v48-0001 and it looks fine to me. I've only minor quibbles > > about you using foreach() instead of foreach_int() and foreach_node() > > for populating the new Bitmapsets in standard_planner(). > > Good point. I forgot about those. Attached patch fixes that (since the > code was already committed). Since it's in already, maybe it'd be worth doing something more widespread after the freeze is over, changing just the ones new to v19. git diff 2652835d3efa003439ecc23d5fc3cf089c5952a6.. -- *.c | grep -E "^\+\s+foreach\(" or with a bit more context: git diff 2652835d3efa003439ecc23d5fc3cf089c5952a6.. -- *.c | grep -E "(^\+\s+foreach\(|^---)" The mixed node ones don't qualify, but it shouldn't be too hard to filter those out manually. David -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-03-31T22:55:54Z
On Tue, Mar 31, 2026 at 6:14 PM David Rowley <dgrowleyml@gmail.com> wrote: > > Since it's in already, maybe it'd be worth doing something more > widespread after the freeze is over, changing just the ones new to > v19. That makes sense. I've put it on my todo list for post-freeze. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Alexander Lakhin <exclusion@gmail.com> — 2026-04-03T05:00:00Z
Hello Melanie and Andres, 31.03.2026 19:19, Melanie Plageman wrote: > Thanks for the reply! I have committed the patches in this thread and > marked the CF entry accordingly. I've come across an interesting failure produced starting from 378a21618: when using a build made with CFLAGS="-DRELCACHE_FORCE_RELEASE" and echo "io_method = sync" >/tmp/temp.config, the test run: TEMP_CONFIG=/tmp/temp.config TESTS=temp make -s check-tests fails as below: --- .../src/test/regress/expected/temp.out 2026-02-13 06:15:55.887368624 +0200 +++ .../src/test/regress/results/temp.out 2026-04-03 07:51:36.735504156 +0300 @@ -493,11 +493,7 @@ -- Check that read streams deal with lower number of pins available SELECT count(*), max(a) max_a, min(a) min_a, max(cnt) max_cnt FROM test_temp; - count | max_a | min_a | max_cnt --------+-------+-------+--------- - 10000 | 10000 | 1 | 0 -(1 row) - +ERROR: no empty local buffer available ROLLBACK; Could you look please, if it indicates some regression? Best regards, Alexander
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-04-05T15:42:19Z
On Fri, Apr 3, 2026 at 1:00 AM Alexander Lakhin <exclusion@gmail.com> wrote: > > I've come across an interesting failure produced starting from 378a21618: > when using a build made with CFLAGS="-DRELCACHE_FORCE_RELEASE" and > echo "io_method = sync" >/tmp/temp.config, the test run: > TEMP_CONFIG=/tmp/temp.config TESTS=temp make -s check-tests > > fails as below: > --- .../src/test/regress/expected/temp.out 2026-02-13 06:15:55.887368624 +0200 > +++ .../src/test/regress/results/temp.out 2026-04-03 07:51:36.735504156 +0300 > @@ -493,11 +493,7 @@ > > -- Check that read streams deal with lower number of pins available > SELECT count(*), max(a) max_a, min(a) min_a, max(cnt) max_cnt FROM test_temp; > - count | max_a | min_a | max_cnt > --------+-------+-------+--------- > - 10000 | 10000 | 1 | 0 > -(1 row) > - > +ERROR: no empty local buffer available > ROLLBACK; It has to do with the query needing an additional pin for the VM during on-access pruning and the read stream reading ahead until there is only one remaining buffer pin in the local pin limit (the cursor above is already consuming much of the backend local pin limit). We could perhaps fix this test by decreasing the pages in the relation or increasing the backend local pin limit, but I wonder if we need to do something more invasive to ensure that we can pin at least two buffers. FWIW, I don't think this can be hit with the FSM, because we release the heap buffer pin before pinning it. Though perhaps there are other places in the code where scanning a single buffer with the read stream entails pinning at least one other buffer. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2026-04-05T15:49:15Z
Hi, On 2026-04-05 11:42:19 -0400, Melanie Plageman wrote: > On Fri, Apr 3, 2026 at 1:00 AM Alexander Lakhin <exclusion@gmail.com> wrote: > > > > I've come across an interesting failure produced starting from 378a21618: > > when using a build made with CFLAGS="-DRELCACHE_FORCE_RELEASE" and > > echo "io_method = sync" >/tmp/temp.config, the test run: > > TEMP_CONFIG=/tmp/temp.config TESTS=temp make -s check-tests > > > > fails as below: > > --- .../src/test/regress/expected/temp.out 2026-02-13 06:15:55.887368624 +0200 > > +++ .../src/test/regress/results/temp.out 2026-04-03 07:51:36.735504156 +0300 > > @@ -493,11 +493,7 @@ > > > > -- Check that read streams deal with lower number of pins available > > SELECT count(*), max(a) max_a, min(a) min_a, max(cnt) max_cnt FROM test_temp; > > - count | max_a | min_a | max_cnt > > --------+-------+-------+--------- > > - 10000 | 10000 | 1 | 0 > > -(1 row) > > - > > +ERROR: no empty local buffer available > > ROLLBACK; > > It has to do with the query needing an additional pin for the VM > during on-access pruning and the read stream reading ahead until there > is only one remaining buffer pin in the local pin limit (the cursor > above is already consuming much of the backend local pin limit). We > could perhaps fix this test by decreasing the pages in the relation or > increasing the backend local pin limit, but I wonder if we need to do > something more invasive to ensure that we can pin at least two > buffers. I think we should probably just have GetLocalPinLimit() return something considerably smaller than num_temp_buffers, e.g. num_temp_buffers / 4 or so. There always may be more than one scan going on, so we can't ever promise that there's at least a certain number of pins available. The main goal of the shared pin limit is to prevent one backend from pinning disproportionally much of s_b. And for that eventually scaling down to just needing 1-2 pins per scan is sufficient. Greetings, Andres Freund
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Alexey Makhmutov <a.makhmutov@postgrespro.ru> — 2026-04-06T14:30:51Z
Hi Melanie, Sorry for the late note for the already committed patch, but I have a question on the last part of the 'heap_xlog_prune_freeze' function related to the FSM update (it was committed in add323d -'Eliminate XLOG_HEAP2_VISIBLE from vacuum phase III'). Currently it contains the following logic: ... Size freespace = 0; ... if (BufferIsValid(buffer)) { if ((xlrec.flags & (XLHP_HAS_REDIRECTIONS | XLHP_HAS_DEAD_ITEMS | XLHP_HAS_NOW_UNUSED_ITEMS)) || (vmflags & VISIBILITYMAP_VALID_BITS)) freespace = PageGetHeapFreeSpace(BufferGetPage(buffer)); ... UnlockReleaseBuffer(buffer); } ... if (freespace > 0) XLogRecordPageWithFreeSpace(rlocator, blkno, freespace); ... My question is about the last check ('freespace > 0') - do we really want to call 'XLogRecordPageWithFreeSpace' only if 'freespace' is greater than 0? As I understand, the zero value is a perfectly valid output of the 'PageGetHeapFreeSpace' call (i.e. page has no space or no free line items while we mark all rows as frozen/visible), but with the current implementation we will skip FSM update in such case. Maybe we need to use additional flag (i.e. 'need_fsm_update'), set it before calling 'PageGetHeapFreeSpace' and then check before the 'XLogRecordPageWithFreeSpace' invocation? Thanks, Alexey -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2026-04-06T14:44:33Z
Hi, On 2026-04-06 17:30:51 +0300, Alexey Makhmutov wrote: > Sorry for the late note for the already committed patch, but I have a > question on the last part of the 'heap_xlog_prune_freeze' function related > to the FSM update (it was committed in add323d -'Eliminate > XLOG_HEAP2_VISIBLE from vacuum phase III'). FWIW, I don't think it's ever too late to look at commits, and certainly not when it's a commit from the same release. > Currently it contains the following logic: > > ... > Size freespace = 0; > ... > if (BufferIsValid(buffer)) > { > if ((xlrec.flags & (XLHP_HAS_REDIRECTIONS | > XLHP_HAS_DEAD_ITEMS | > XLHP_HAS_NOW_UNUSED_ITEMS)) || > (vmflags & VISIBILITYMAP_VALID_BITS)) > freespace = PageGetHeapFreeSpace(BufferGetPage(buffer)); > ... > UnlockReleaseBuffer(buffer); > } > ... > if (freespace > 0) > XLogRecordPageWithFreeSpace(rlocator, blkno, freespace); > ... > > My question is about the last check ('freespace > 0') - do we really want to > call 'XLogRecordPageWithFreeSpace' only if 'freespace' is greater than 0? As > I understand, the zero value is a perfectly valid output of the > 'PageGetHeapFreeSpace' call (i.e. page has no space or no free line items > while we mark all rows as frozen/visible), but with the current > implementation we will skip FSM update in such case. I don't have a strong opinion on this, but I think it's pretty defensible to record only when there's free space. The whole goal of updating the FSM during recovery is to make sure that free space can be found fairly quickly after promotion (it's also beneficial in some crash recovery cases, but not that much). If the page filled up during an insert / update / delete, we will have updated the FSM with that information at that point: /* * If the page is running low on free space, update the FSM as well. * Arbitrarily, our definition of "low" is less than 20%. We can't do much * better than that without knowing the fill-factor for the table. * * XXX: Don't do this if the page was restored from full page image. We * don't bother to update the FSM in that case, it doesn't need to be * totally accurate anyway. */ if (action == BLK_NEEDS_REDO && freespace < BLCKSZ / 5) XLogRecordPageWithFreeSpace(target_locator, blkno, freespace); The reason to update the FSM after something pruning / vacuuming related is that there now might be *more* space available than before, it shouldn't shrink. Obviously the FSM is not crashsafe, so updating it with 0 during replay could avoid some unnecessary page reads after a promotion. But I'm not sure that that's particularly worth optimizing for. With all that said, I'm somewhat doubtful that freespace > 0 filters out a meaningful amount of freespace updates, it's rare for pages to be that full. Greetings, Andres Freund -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Alexey Makhmutov <a.makhmutov@postgrespro.ru> — 2026-04-06T15:32:03Z
Hi Andres, On 4/6/26 17:44, Andres Freund wrote: > I don't have a strong opinion on this, but I think it's pretty defensible to > record only when there's free space. The whole goal of updating the FSM during > recovery is to make sure that free space can be found fairly quickly after > promotion (it's also beneficial in some crash recovery cases, but not that > much). > ... > Obviously the FSM is not crashsafe, so updating it with 0 during replay could > avoid some unnecessary page reads after a promotion. But I'm not sure that > that's particularly worth optimizing for. This makes sense. I'd like just to put some context here: I was checking the FSM update case in scope of the thread https://www.postgresql.org/message-id/flat/596c4f1c-f966-4512-b9c9-dd8fbcaf0928@postgrespro.ru, in which I was specifically looking at the case with outdated FSM data (showing lots of free space) on standby causing a significant performance hit after switchover. As example this include case with table having fillfactor<=80 which has prior bulk rows deletes + insertion. In this case (mostly) empty FSM block may be delivered to standby via FPI, but subsequent inserts may be lost due to the 20% heuristic. Moreover, updates to FSM may be lost even for blocks filled for more than 80% due to missing dirty flag as described in that thread. In my understanding the FSM update during processing of the 'heap_xlog_visible' function on standby was kind of 'last line of defense' for any corner case scenario with FSM update (as block would not be visited by the vacuum process once it's marked as 'all visible') and it was introduced in https://www.postgresql.org/message-id/20180802172857.5skoexsilnjvgruk@alvherre.pgsql (ab7dbd681) specifically for this purpose. Now, as logic of 'heap_xlog_visible' is merged into 'heap_xlog_prune_freeze', so this task is carried by this function. I fully agree that having exactly zero-space seems to be a very uncommon situation (and probably not reproducible with tables having fillfactor<=80). I've just noted that such case was processed by the old logic in the 'heap_xlog_visible', while current implementation in 'heap_xlog_prune_freeze' skips it. Thanks, Alexey
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-04-13T16:17:54Z
On Mon, Apr 6, 2026 at 11:32 AM Alexey Makhmutov <a.makhmutov@postgrespro.ru> wrote: > > This makes sense. I'd like just to put some context here: I was checking > the FSM update case in scope of the thread > https://www.postgresql.org/message-id/flat/596c4f1c-f966-4512-b9c9-dd8fbcaf0928@postgrespro.ru, > in which I was specifically looking at the case with outdated FSM data > (showing lots of free space) on standby causing a significant > performance hit after switchover. As example this include case with > table having fillfactor<=80 which has prior bulk rows deletes + > insertion. In this case (mostly) empty FSM block may be delivered to > standby via FPI, but subsequent inserts may be lost due to the 20% > heuristic. Moreover, updates to FSM may be lost even for blocks filled > for more than 80% due to missing dirty flag as described in that thread. > > In my understanding the FSM update during processing of the > 'heap_xlog_visible' function on standby was kind of 'last line of > defense' for any corner case scenario with FSM update (as block would > not be visited by the vacuum process once it's marked as 'all visible') > and it was introduced in > https://www.postgresql.org/message-id/20180802172857.5skoexsilnjvgruk@alvherre.pgsql > (ab7dbd681) specifically for this purpose. Now, as logic of > 'heap_xlog_visible' is merged into 'heap_xlog_prune_freeze', so this > task is carried by this function. > > I fully agree that having exactly zero-space seems to be a very uncommon > situation (and probably not reproducible with tables having > fillfactor<=80). I've just noted that such case was processed by the old > logic in the 'heap_xlog_visible', while current implementation in > 'heap_xlog_prune_freeze' skips it. The scenario causing inaccurate freespace maps after promotion is technically possible though improbable. Moreover, I don't see a downside to changing it. Patch I plan to commit is attached. I don't quite understand why heap_xlog_insert() considers whether the heap page was an FPI before updating the FSM though. I know we need some heuristic to avoid doing it for every record, but the FPI consideration doesn't make sense to me. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-04-16T16:19:32Z
On Mon, Apr 13, 2026 at 12:17 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > The scenario causing inaccurate freespace maps after promotion is > technically possible though improbable. Moreover, I don't see a > downside to changing it. Patch I plan to commit is attached. I've committed this fix in b4c1b2be300. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-04-16T20:21:35Z
On Sun, Apr 5, 2026 at 11:49 AM Andres Freund <andres@anarazel.de> wrote: > > I think we should probably just have GetLocalPinLimit() return something > considerably smaller than num_temp_buffers, e.g. num_temp_buffers / 4 or > so. I think num_temp_buffers / 4 seems reasonable for GetLocalPinLimit(). We'd also need to make this change in GetAdditionalLocalPinLimit(). Making this change fixes the specific case Alexander pointed out. We will likely see an impact on performance impact because this will keep the readahead distance substantially lower for temp table cases with only one read stream. > There always may be more than one scan going on, so we can't ever promise that > there's at least a certain number of pins available. The main goal of the > shared pin limit is to prevent one backend from pinning disproportionally much > of s_b. And for that eventually scaling down to just needing 1-2 pins per > scan is sufficient. With the last sentence "And for that eventually scaling down to just needing 1-2 pins per scan is sufficient." -- how do you mean to relate that to what we will do with local buffers case? - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Alexander Lakhin <exclusion@gmail.com> — 2026-04-18T15:00:00Z
Hello Melanie and Andres, 03.04.2026 08:00, Alexander Lakhin wrote: > > 31.03.2026 19:19, Melanie Plageman wrote: >> Thanks for the reply! I have committed the patches in this thread and >> marked the CF entry accordingly. > > I've come across an interesting failure produced starting from 378a21618: > ... I've discovered one more behaviour change introduced in 378a21618. I investigated a yesterday's skink failure [1]: # --- /home/bf/bf-build/skink-master/HEAD/pgsql/contrib/btree_gist/expected/enum.out 2025-06-23 20:17:56.295775456 +0200 # +++ /home/bf/bf-build/skink-master/HEAD/pgsql.build/testrun/btree_gist/regress/results/enum.out 2026-04-17 22:35:37.212061309 +0200 # @@ -83,12 +83,10 @@ # # EXPLAIN (COSTS OFF) # SELECT count(*) FROM enumtmp WHERE a >= 'g'::rainbow; # - QUERY PLAN # ------------------------------------------------ # + QUERY PLAN # +------------------------------------------------ # Aggregate # - -> Bitmap Heap Scan on enumtmp # - Recheck Cond: (a >= 'g'::rainbow) # - -> Bitmap Index Scan on enumidx # - Index Cond: (a >= 'g'::rainbow) # -(5 rows) # + -> Index Only Scan using enumidx on enumtmp # + Index Cond: (a >= 'g'::rainbow) # +(3 rows) # # 1 of 32 tests failed. pgsql.build/testrun/btree_gist/regress/log/postmaster.log contains 2026-04-17 22:35:36.909 CEST autovacuum worker[4020330] LOG: automatic analyze of table "regression_btree_gist.public.enumtmp" avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s buffer usage: 128 hits, 0 reads, 0 dirtied WAL usage: 2 records, 0 full page images, 322 bytes, 0 full page image bytes, 0 buffers full system usage: CPU: user: 0.05 s, system: 0.00 s, elapsed: 0.11 s and managed to reproduce it locally under Valgrind on a slowed down VM so that the enum test takes ~10 sec: With +select c.relname,c.relpages,c.reltuples,s.autovacuum_count,s.autoanalyze_count +from pg_class c +left join pg_stat_all_tables s on c.oid = s.relid +where c.relname in ('enumtmp', 'enumidx'); added to the test for diagnostics and the test repeated 100 times, I got: ... ok 46 - enum 10635 ms ok 47 - enum 10559 ms # diff -U3 /home/vagrant/postgres/contrib/btree_gist/expected/enum.out /home/vagrant/postgres/contrib/btree_gist/results/enum.out # --- /home/vagrant/postgres/contrib/btree_gist/expected/enum.out 2026-04-18 11:41:17.224063241 +0000 # +++ /home/vagrant/postgres/contrib/btree_gist/results/enum.out 2026-04-18 11:52:43.870049782 +0000 # @@ -91,18 +91,16 @@ # where c.relname in ('enumtmp', 'enumidx'); # relname | relpages | reltuples | autovacuum_count | autoanalyze_count # ---------+----------+-----------+------------------+------------------- # - enumtmp | 3 | 595 | 0 | 0 # + enumtmp | 3 | 595 | 0 | 1 # enumidx | 4 | 595 | | # (2 rows) # # EXPLAIN (COSTS OFF) # SELECT count(*) FROM enumtmp WHERE a >= 'g'::rainbow; # - QUERY PLAN # ------------------------------------------------ # + QUERY PLAN # +------------------------------------------------ # Aggregate # - -> Bitmap Heap Scan on enumtmp # - Recheck Cond: (a >= 'g'::rainbow) # - -> Bitmap Index Scan on enumidx # - Index Cond: (a >= 'g'::rainbow) # -(5 rows) # + -> Index Only Scan using enumidx on enumtmp # + Index Cond: (a >= 'g'::rainbow) # +(3 rows) # not ok 48 - enum 10596 ms ok 49 - enum 11693 ms ok 50 - enum 11098 ms ... # 6 of 131 tests failed. I could also reproduce the same diff with just: --- a/contrib/btree_gist/sql/enum.sql +++ b/contrib/btree_gist/sql/enum.sql @@ -40,2 +40,3 @@ SELECT count(*) FROM enumtmp WHERE a > 'g'::rainbow; +ANALYZE enumtmp; EXPLAIN (COSTS OFF) It's not reproduced at 378a21618~1, though. Could you please look if this can be fixed? [1] https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=skink&dt=2026-04-17%2019%3A10%3A50 Best regards, Alexander -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2026-04-18T16:25:48Z
Hi, On 2026-04-18 18:00:00 +0300, Alexander Lakhin wrote: > Hello Melanie and Andres, > > 03.04.2026 08:00, Alexander Lakhin wrote: > > > > 31.03.2026 19:19, Melanie Plageman wrote: > > > Thanks for the reply! I have committed the patches in this thread and > > > marked the CF entry accordingly. > > > > I've come across an interesting failure produced starting from 378a21618: > > ... > > I've discovered one more behaviour change introduced in 378a21618. I > investigated a yesterday's skink failure [1]: > # --- /home/bf/bf-build/skink-master/HEAD/pgsql/contrib/btree_gist/expected/enum.out 2025-06-23 20:17:56.295775456 +0200 > # +++ /home/bf/bf-build/skink-master/HEAD/pgsql.build/testrun/btree_gist/regress/results/enum.out > 2026-04-17 22:35:37.212061309 +0200 > # @@ -83,12 +83,10 @@ > # > # EXPLAIN (COSTS OFF) > # SELECT count(*) FROM enumtmp WHERE a >= 'g'::rainbow; Random: I wonder if the author if this intended this to be a temp table, based on the name? That'd prevent any concurrent autovacuums/analyzes from changing anything. > # --- /home/vagrant/postgres/contrib/btree_gist/expected/enum.out 2026-04-18 11:41:17.224063241 +0000 > # +++ /home/vagrant/postgres/contrib/btree_gist/results/enum.out 2026-04-18 11:52:43.870049782 +0000 > # @@ -91,18 +91,16 @@ > # where c.relname in ('enumtmp', 'enumidx'); > # relname | relpages | reltuples | autovacuum_count | autoanalyze_count > # ---------+----------+-----------+------------------+------------------- > # - enumtmp | 3 | 595 | 0 | 0 > # + enumtmp | 3 | 595 | 0 | 1 > # enumidx | 4 | 595 | | > # (2 rows) > # > # EXPLAIN (COSTS OFF) > # SELECT count(*) FROM enumtmp WHERE a >= 'g'::rainbow; > # - QUERY PLAN > # ------------------------------------------------ > # + QUERY PLAN > # +------------------------------------------------ > # Aggregate > # - -> Bitmap Heap Scan on enumtmp > # - Recheck Cond: (a >= 'g'::rainbow) > # - -> Bitmap Index Scan on enumidx > # - Index Cond: (a >= 'g'::rainbow) > # -(5 rows) > # + -> Index Only Scan using enumidx on enumtmp > # + Index Cond: (a >= 'g'::rainbow) > # +(3 rows) > # > not ok 48 - enum 10596 ms The interesting column to show here would presumably be relallvisible. What I assume is happening is that occasionally analyze now sees enough all visible pages (due to on-access pruning marking the pages all visible) to consider the index only scan worthwhile, whereas before that wasn't (or only very rarely) happened. Maybe I'm daft, but what would prevent this from happening before? The path for it would be a bit more complicated, you'd have to have an autovacuum instead of just an analyze - but that seems possible. It might require running against a pre-existing install to be likely enough. > It's not reproduced at 378a21618~1, though. > > Could you please look if this can be fixed? When you say fix, I assume you mean address the test instability, rather than actual code changes? Greetings, Andres Freund -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Andres Freund <andres@anarazel.de> — 2026-04-18T16:33:26Z
Hi, On 2026-04-16 16:21:35 -0400, Melanie Plageman wrote: > On Sun, Apr 5, 2026 at 11:49 AM Andres Freund <andres@anarazel.de> wrote: > > > > I think we should probably just have GetLocalPinLimit() return something > > considerably smaller than num_temp_buffers, e.g. num_temp_buffers / 4 or > > so. > > I think num_temp_buffers / 4 seems reasonable for GetLocalPinLimit(). > We'd also need to make this change in GetAdditionalLocalPinLimit(). Right. > We will likely see an impact on performance impact because this will > keep the readahead distance substantially lower for temp table cases > with only one read stream. I don't think it's likely to be practically relevant. Unless you use a toy sized temp_buffers - in which case readahead won't be a relevant performance bottleneck - the pinned limit will often even be higher than with shared buffers (due to the shared buffers pin limit being divided by MaxBackends). Even at the default temp_buffers=1024/8MB, the limit would be 256. That's quite high for a single scan in such a tiny pool. > > There always may be more than one scan going on, so we can't ever promise that > > there's at least a certain number of pins available. The main goal of the > > shared pin limit is to prevent one backend from pinning disproportionally much > > of s_b. And for that eventually scaling down to just needing 1-2 pins per > > scan is sufficient. > > With the last sentence "And for that eventually scaling down to just > needing 1-2 pins per scan is sufficient." -- how do you mean to relate > that to what we will do with local buffers case? Just explaining why we shouldn't see these limits as hard limits that may never be exceeded, but as caps that make problems less likely. I guess I could see an argument for doing something more complicated for temp buffers than num_temp_buffers / 4, e.g. Min(1, (num_temp_buffers - NLocalPinnedBuffers) / 4) so that we get more conservative the more scans are concurrently in progress. But I'd not go there right now, that seems like a more complicated project (and we'd presumably want to do something roughly similar for the s_b case). Greetings, Andres Freund
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Alexander Lakhin <exclusion@gmail.com> — 2026-04-18T18:00:00Z
18.04.2026 19:25, Andres Freund wrote: > The interesting column to show here would presumably be relallvisible. > > What I assume is happening is that occasionally analyze now sees enough all > visible pages (due to on-access pruning marking the pages all visible) to > consider the index only scan worthwhile, whereas before that wasn't (or only > very rarely) happened. Indeed, with c.relallvisible added, I can see: --- .../contrib/btree_gist/expected/enum.out 2026-04-18 19:37:51.041565543 +0300 +++ .../contrib/btree_gist/results/enum.out 2026-04-18 19:40:59.077264981 +0300 @@ -88,18 +88,16 @@ where c.relname in ('enumtmp', 'enumidx'); relname | relpages | reltuples | autovacuum_count | autoanalyze_count | relallvisible ---------+----------+-----------+------------------+-------------------+--------------- - enumtmp | 3 | 595 | 0 | 0 | 0 + enumtmp | 3 | 595 | 0 | 0 | 2 enumidx | 4 | 595 | | | 0 (2 rows) EXPLAIN (COSTS OFF) SELECT count(*) FROM enumtmp WHERE a >= 'g'::rainbow; - QUERY PLAN ------------------------------------------------ + QUERY PLAN +------------------------------------------------ Aggregate - -> Bitmap Heap Scan on enumtmp - Recheck Cond: (a >= 'g'::rainbow) - -> Bitmap Index Scan on enumidx - Index Cond: (a >= 'g'::rainbow) -(5 rows) + -> Index Only Scan using enumidx on enumtmp + Index Cond: (a >= 'g'::rainbow) +(3 rows) At 378a21618~1, it stays zero. > Maybe I'm daft, but what would prevent this from happening before? The path > for it would be a bit more complicated, you'd have to have an autovacuum > instead of just an analyze - but that seems possible. It might require running > against a pre-existing install to be likely enough. Yes, with VACUUM enumtmp; instead of ANALYZE enumtmp; the plan change is reproduced at 378a21618~1: @@ -88,18 +88,16 @@ where c.relname in ('enumtmp', 'enumidx'); relname | relpages | reltuples | autovacuum_count | autoanalyze_count | relallvisible ---------+----------+-----------+------------------+-------------------+--------------- - enumtmp | 3 | 595 | 0 | 0 | 0 + enumtmp | 3 | 595 | 0 | 0 | 3 enumidx | 4 | 595 | | | 0 (2 rows) EXPLAIN (COSTS OFF) SELECT count(*) FROM enumtmp WHERE a >= 'g'::rainbow; - QUERY PLAN ------------------------------------------------ + QUERY PLAN +------------------------------------------------ Aggregate - -> Bitmap Heap Scan on enumtmp - Recheck Cond: (a >= 'g'::rainbow) - -> Bitmap Index Scan on enumidx - Index Cond: (a >= 'g'::rainbow) -(5 rows) + -> Index Only Scan using enumidx on enumtmp + Index Cond: (a >= 'g'::rainbow) +(3 rows) And this diff is produced even at f7946a92 (from 2017-03-21), which added the test case. So, given that this is the only failure of btree_gist in two last years at least, it looks like the probability of vacuuming the table there is much lower than of analyzing. >> Could you please look if this can be fixed? > When you say fix, I assume you mean address the test instability, rather than > actual code changes? Sure, I didn't mean the new behavior is wrong. Probably changing that table to temporary would work, but I wonder if there are other queries, which plans can change due to the same reason. Best regards, Alexander -
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-04-20T15:48:09Z
On Sat, Apr 18, 2026 at 12:33 PM Andres Freund <andres@anarazel.de> wrote: > > I guess I could see an argument for doing something more complicated for temp > buffers than num_temp_buffers / 4, e.g. > Min(1, (num_temp_buffers - NLocalPinnedBuffers) / 4) > so that we get more conservative the more scans are concurrently in progress. > > But I'd not go there right now, that seems like a more complicated project > (and we'd presumably want to do something roughly similar for the s_b case). With shared buffers, while it is true you'd ideally leave the backend headroom for other read streams etc, it won't error out the way the temp table case does unless we've actually pinned all shared buffers. It will simply slow down the read ahead of the competing read streams. Attached is what I'm thinking of committing. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-04-20T16:18:24Z
On Sat, Apr 18, 2026 at 2:00 PM Alexander Lakhin <exclusion@gmail.com> wrote: > > 18.04.2026 19:25, Andres Freund wrote: > > >> Could you please look if this can be fixed? > > When you say fix, I assume you mean address the test instability, rather than > > actual code changes? > > Sure, I didn't mean the new behavior is wrong. Probably changing that > table to temporary would work Yes, I think changing it to a temp table is the easiest fix. We could also do autovacuum_enabled=false, I think, but making it a temp table seems cleanest. I wonder if we should move the EXPLAIN test above the results queries, then throw in a vacuum in between some of them so we exercise btree gist as a bitmap heap scan and as an index only scan. It could provide a little bit more coverage? Or maybe that isn't actually extra coverage. I'm not sure. > but I wonder if there are other queries, > which plans can change due to the same reason. I think we'll have to take this on a case-by-case basis when we see failures. While it is certainly possible other tests just rely on autovacuum not having run and set the page all-visible, many of them probably have already had to account for that. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Alexander Lakhin <exclusion@gmail.com> — 2026-04-20T19:00:00Z
Hello Melanie and Andres, 20.04.2026 19:18, Melanie Plageman wrote: >> but I wonder if there are other queries, >> which plans can change due to the same reason. > I think we'll have to take this on a case-by-case basis when we see > failures. While it is certainly possible other tests just rely on > autovacuum not having run and set the page all-visible, many of them > probably have already had to account for that. Thank you for paying attention to this! I think, I found another test which suffers from autoanalyze with the new behavior: [1], [2]. Initially I reproduced this diff on a slow armv7 device after many iterations of `make check` with: autovacuum_naptime = 1 autovacuum_analyze_threshold = 1 debug_parallel_query = 'regress' But now I see that it can be reproduced on an ordinary machine with just: --- a/src/test/regress/sql/plancache.sql +++ b/src/test/regress/sql/plancache.sql @@ -208,2 +208,3 @@ execute test_mode_pp(1); -- 2x execute test_mode_pp(1); -- 3x +analyze test_mode; execute test_mode_pp(1); -- 4x (and expected/plancache.out updated) and `make check` running in a loop. It failed for me on iterations 5, 4, 10 (as far as I can see, analyze updates relallvisible not every time): # parallel group (18 tests): prepare xml conversion plancache limit returning copy2 polymorphism sequence rowtypes largeobject temp rangefuncs with truncate domain plpgsql alter_table # diff -U3 .../src/test/regress/expected/plancache.out .../src/test/regress/results/plancache.out # --- .../src/test/regress/expected/plancache.out 2026-04-20 21:35:30.677775398 +0300 # +++ .../src/test/regress/results/plancache.out 2026-04-20 21:43:49.324492302 +0300 # @@ -374,11 +374,11 @@ # # -- we should now get a really bad plan # explain (costs off) execute test_mode_pp(2); # - QUERY PLAN # ------------------------------ # + QUERY PLAN # +---------------------------------------------------------- # Aggregate # - -> Seq Scan on test_mode # - Filter: (a = $1) # + -> Index Only Scan using test_mode_a_idx on test_mode # + Index Cond: (a = $1) # (3 rows) # # -- but we can force a custom plan The same modified test survived 50 iterations at 378a21618~1. [1] https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=dodo&dt=2026-04-07%2012%3A45%3A07 [2] https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=dodo&dt=2026-04-12%2022%3A45%3A06 Best regards, Alexander
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-04-20T20:28:03Z
On Mon, Apr 20, 2026 at 3:00 PM Alexander Lakhin <exclusion@gmail.com> wrote: > > I think, I found another test which suffers from autoanalyze with the new > behavior: [1], [2]. Thanks for continuing to look for these! > Initially I reproduced this diff on a slow armv7 device after many > iterations of `make check` with: > autovacuum_naptime = 1 > autovacuum_analyze_threshold = 1 > debug_parallel_query = 'regress' > > But now I see that it can be reproduced on an ordinary machine with just: > --- a/src/test/regress/sql/plancache.sql > +++ b/src/test/regress/sql/plancache.sql > @@ -208,2 +208,3 @@ execute test_mode_pp(1); -- 2x > execute test_mode_pp(1); -- 3x > +analyze test_mode; > execute test_mode_pp(1); -- 4x > (and expected/plancache.out updated) > > and `make check` running in a loop. It failed for me on iterations 5, 4, > 10 (as far as I can see, analyze updates relallvisible not every time): If you do vacuum test_mode instead, it should fail reliably. I think we can avoid having relallvisible updated by doing two things: 1) moving the analyze test_mode above the create index because the create index will scan the table and could set pages all-visible and then the analyze may update the statistics 2) create the table with autovacuum_enabled = false to avoid vacuum and analyze running after any of the other table scans may set some pages all-visible - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-04-21T15:07:25Z
On Mon, Apr 20, 2026 at 11:48 AM Melanie Plageman <melanieplageman@gmail.com> wrote: > > With shared buffers, while it is true you'd ideally leave the backend > headroom for other read streams etc, it won't error out the way the > temp table case does unless we've actually pinned all shared buffers. > It will simply slow down the read ahead of the competing read streams. > > Attached is what I'm thinking of committing. Okay, I committed this in da6874635db. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-04-21T18:41:30Z
On Mon, Apr 20, 2026 at 4:28 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > I think we can avoid having relallvisible updated by doing two things: > 1) moving the analyze test_mode above the create index because the > create index will scan the table and could set pages all-visible and > then the analyze may update the statistics > 2) create the table with autovacuum_enabled = false to avoid vacuum > and analyze running after any of the other table scans may set some > pages all-visible So, I don't love that this test relies on pg_class not having been updated to reflect relallvisible. But, I don't see a good way around this. If we need the "bad plan" to be a seq scan, then pg_class.relallvisible can't be updated. As long as only analyze and vacuum will update pg_class.relallvisible, we can preserve the desired behavior by disabling autovacuum/analyze for the table and analyzing it (to get reltuples/relpages) before any scan that could set the VM for the pages. So, I've done this and committed it in 85ae8ab0533. - Melanie
-
Re: eliminate xl_heap_visible to reduce WAL (and eventually set VM on-access)
Melanie Plageman <melanieplageman@gmail.com> — 2026-04-21T21:37:13Z
On Mon, Apr 20, 2026 at 12:18 PM Melanie Plageman <melanieplageman@gmail.com> wrote: > > > Yes, I think changing it to a temp table is the easiest fix. We could > also do autovacuum_enabled=false, I think, but making it a temp table > seems cleanest. > > I wonder if we should move the EXPLAIN test above the results queries, > then throw in a vacuum in between some of them so we exercise btree > gist as a bitmap heap scan and as an index only scan. It could provide > a little bit more coverage? Or maybe that isn't actually extra > coverage. I'm not sure. I kept it simple and just committed making it a temp table in 62407d26b7c - Melanie