On Mon, Mar 9, 2026 at 4:15 PM Peter Geoghegan <[email protected]> wrote:
>
> I'm not sure that you need to add any new comments above
> FreezeMultiXactId. The underlying principles that justify ignoring
> xmax when it is a multi are exactly the same as those that apply when
> xmax is a normal XID. I think that what you actually need is a single
> comment block (maybe 2) near the start of or above
> heap_prepare_freeze_tuple explaining your new snapshotConflictHorizon
> maintenance code, mentioning:
>
[...snip...]
>
> I'm trying not to be too prescriptive here; I just think that
> emphasizing high-level logical database concepts over physical
> database implementation details makes sense. I don't expect you to
> follow what I've written here all too closely. I didn't have the time
> to distill it down myself.
I've taken a stab at distilling down the comment and put it above
heap_prepare_freeze_tuple(). This is what I came up with:
* FreezePageConflictXid is advanced only for xmin/xvac freezing, not for xmax
* changes. We only remove xmax state here when it is lock-only, or when the
* updater XID (including an updater member of a MultiXact) must be aborted;
* otherwise, the tuple would already be removable. Neither case affects
* visibility on a standby.
I don't mention why we need a conflict horizon when freezing there,
but I do in the comment above the struct member:
/*
* Newest XID that this page's freeze actions will remove from tuple
* visibility metadata (currently xmin and/or xvac). It is used to derive
* the snapshot conflict horizon for a WAL record that freezes tuples. On
* a standby, we must not replay that change while any snapshot could
* still treat that XID as running.
*
* It's only used if we execute freeze plans for this page, so there is no
* corresponding "no freeze" tracker.
*/
TransactionId FreezePageConflictXid;
Do these seem correct enough and understandable?
- Melanie
From 2cdc5b7a0c515ad639a231e40134c55d51f1e6b1 Mon Sep 17 00:00:00 2001
From: Melanie Plageman <[email protected]>
Date: Mon, 2 Mar 2026 11:39:28 -0500
Subject: [PATCH v3] Use the newest to-be-frozen xid as the conflict horizon
for freezing
Previously WAL records that froze tuples used OldestXmin as the snapshot
conflict horizon. However, OldestXmin is newer than the newest frozen
tuple's xid. By tracking the newest to-be-frozen xid and using it as the
snapshot conflict horizon instead, we end up with an older horizon that
will result in fewer query cancellations on the standby.
Author: Melanie Plageman <[email protected]>
Reviewed-by: Peter Geoghegan <[email protected]>
Discussion: https://postgr.es/m/CAAKRu_bbaUV8OUjAfVa_iALgKnTSfB4gO3jnkfpcFgrxEpSGJQ%40mail.gmail.com
---
src/backend/access/heap/heapam.c | 14 +++++++++++
src/backend/access/heap/pruneheap.c | 36 +++++++++--------------------
src/include/access/heapam.h | 12 ++++++++++
3 files changed, 37 insertions(+), 25 deletions(-)
diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index a231563f0df..246f5182e6c 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -7098,6 +7098,12 @@ FreezeMultiXactId(MultiXactId multi, uint16 t_infomask,
* process this tuple as part of freezing its page, and return true. Return
* false if nothing can be changed about the tuple right now.
*
+ * FreezePageConflictXid is advanced only for xmin/xvac freezing, not for xmax
+ * changes. We only remove xmax state here when it is lock-only, or when the
+ * updater XID (including an updater member of a MultiXact) must be aborted;
+ * otherwise, the tuple would already be removable. Neither case affects
+ * visibility on a standby.
+ *
* Also sets *totally_frozen to true if the tuple will be totally frozen once
* caller executes returned freeze plan (or if the tuple was already totally
* frozen by an earlier VACUUM). This indicates that there are no remaining
@@ -7173,7 +7179,11 @@ heap_prepare_freeze_tuple(HeapTupleHeader tuple,
/* Verify that xmin committed if and when freeze plan is executed */
if (freeze_xmin)
+ {
frz->checkflags |= HEAP_FREEZE_CHECK_XMIN_COMMITTED;
+ if (TransactionIdFollows(xid, pagefrz->FreezePageConflictXid))
+ pagefrz->FreezePageConflictXid = xid;
+ }
}
/*
@@ -7192,6 +7202,9 @@ heap_prepare_freeze_tuple(HeapTupleHeader tuple,
*/
replace_xvac = pagefrz->freeze_required = true;
+ if (TransactionIdFollows(xid, pagefrz->FreezePageConflictXid))
+ pagefrz->FreezePageConflictXid = xid;
+
/* Will set replace_xvac flags in freeze plan below */
}
@@ -7501,6 +7514,7 @@ heap_freeze_tuple(HeapTupleHeader tuple,
pagefrz.freeze_required = true;
pagefrz.FreezePageRelfrozenXid = FreezeLimit;
pagefrz.FreezePageRelminMxid = MultiXactCutoff;
+ pagefrz.FreezePageConflictXid = InvalidTransactionId;
pagefrz.NoFreezePageRelfrozenXid = FreezeLimit;
pagefrz.NoFreezePageRelminMxid = MultiXactCutoff;
diff --git a/src/backend/access/heap/pruneheap.c b/src/backend/access/heap/pruneheap.c
index 65c9f393f41..8748fa882e9 100644
--- a/src/backend/access/heap/pruneheap.c
+++ b/src/backend/access/heap/pruneheap.c
@@ -377,6 +377,7 @@ prune_freeze_setup(PruneFreezeParams *params,
/* initialize page freezing working state */
prstate->pagefrz.freeze_required = false;
+ prstate->pagefrz.FreezePageConflictXid = InvalidTransactionId;
if (prstate->attempt_freeze)
{
Assert(new_relfrozen_xid && new_relmin_mxid);
@@ -407,7 +408,6 @@ prune_freeze_setup(PruneFreezeParams *params,
* PruneState.
*/
prstate->deadoffsets = presult->deadoffsets;
- prstate->frz_conflict_horizon = InvalidTransactionId;
/*
* Vacuum may update the VM after we're done. We can keep track of
@@ -746,22 +746,8 @@ heap_page_will_freeze(bool did_tuple_hint_fpi,
* critical section.
*/
heap_pre_freeze_checks(prstate->buffer, prstate->frozen, prstate->nfrozen);
-
- /*
- * 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(TransactionIdPrecedes(prstate->pagefrz.FreezePageConflictXid,
+ prstate->cutoffs->OldestXmin));
}
else if (prstate->nfrozen > 0)
{
@@ -952,18 +938,18 @@ heap_page_prune_and_freeze(PruneFreezeParams *params,
/*
* 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.
+ * possible modifications. If this record will prune tuples, any
+ * queries on the standby older than the newest xid of the most
+ * recently removed tuple this record will prune will conflict. If
+ * this record will freeze tuples, any queries on the standby with
+ * xids older than the newest tuple this record will freeze will
+ * conflict.
*/
TransactionId conflict_xid;
- if (TransactionIdFollows(prstate.frz_conflict_horizon,
+ if (TransactionIdFollows(prstate.pagefrz.FreezePageConflictXid,
prstate.latest_xid_removed))
- conflict_xid = prstate.frz_conflict_horizon;
+ conflict_xid = prstate.pagefrz.FreezePageConflictXid;
else
conflict_xid = prstate.latest_xid_removed;
diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
index 24a27cc043a..ad993c07311 100644
--- a/src/include/access/heapam.h
+++ b/src/include/access/heapam.h
@@ -208,6 +208,18 @@ typedef struct HeapPageFreeze
TransactionId FreezePageRelfrozenXid;
MultiXactId FreezePageRelminMxid;
+ /*
+ * Newest XID that this page's freeze actions will remove from tuple
+ * visibility metadata (currently xmin and/or xvac). It is used to derive
+ * the snapshot conflict horizon for a WAL record that freezes tuples. On
+ * a standby, we must not replay that change while any snapshot could
+ * still treat that XID as running.
+ *
+ * It's only used if we execute freeze plans for this page, so there is no
+ * corresponding "no freeze" tracker.
+ */
+ TransactionId FreezePageConflictXid;
+
/*
* "No freeze" NewRelfrozenXid/NewRelminMxid trackers.
*
--
2.43.0