Allow aggressive VACUUM to freeze without a cleanup lock

Started by Jingtang Zhangabout 19 hours ago5 messageshackers
Beta feature

Hackorum builds and tests every patch posted to the lists, not only commitfest submissions. This is Hackorum's own CI rather than the PostgreSQL project's, and it is still under testing - please report anything that looks wrong.

appliessuccessCI history

You can run a PostgreSQL built from this patch straight from Docker, with no checkout and no build:

docker run --rm -p 5432:5432 ghcr.io/hackorum-dev/postgres-patch:t253544
psql -h localhost -U postgres

Built from patchset v1 (message #1), August 25, 2026 at 10:49 AM.

Every patchset is also pushed to a branch of our PostgreSQL fork, so you can check out the same tree CI built. Without a PostgreSQL checkout:

git clone --branch t253544_1 https://github.com/hackorum-dev/postgres.git

In a checkout you already have, add the fork once:

git remote add hackorum https://github.com/hackorum-dev/postgres.git

then, for this patchset and every later one:

git fetch hackorum t253544_1 && git checkout t253544_1

Patchset v1 (message #1) is on t253544_1

Jump to latest
#1Jingtang Zhang
mrdrivingduck@gmail.com

Hi,

I encountered a case where an aggressive VACUUM remained waiting for a
buffer cleanup lock on a page containing a frequently locked row. There
were many concurrent SELECT FOR UPDATE transactions on the same row. The
transactions were individually short-lived, but their buffer pins
overlapped continuously, so VACUUM did not get an opportunity to acquire
the cleanup lock. The system was consuming XIDs quickly, so leaving an
anti-wraparound VACUUM blocked indefinitely would allow the age of unfrozen
XIDs to advance rapidly toward the wraparound danger threshold.

I also noticed a previous report of a similar problem, in which a VACUUM
FREEZE waited on BufferPin for several days while buffer pins from
successive readers overlapped [1]/messages/by-id/1059371874.2807306.1706727919318@mail.yahoo.com. A similar cleanup-lock starvation problem
was discussed in 2011 [2]/messages/by-id/BANLkTinmWFR1-mPu4nduUjxUfvWXZni-7Q@mail.gmail.com.

This seems particularly undesirable for an anti-wraparound VACUUM.
Skipping pruning can leave dead tuples and unused space behind, but
failing to freeze old XIDs can eventually prevent the database from
accepting writes.

The 2011 discussion [2]/messages/by-id/BANLkTinmWFR1-mPu4nduUjxUfvWXZni-7Q@mail.gmail.com raised the related question of whether VACUUM
could make progress after failing to acquire a cleanup lock. It found that
changing line pointers while holding only an exclusive buffer content lock
would be unsafe, because another backend can inspect a line pointer while
holding only a buffer pin. However, freezing tuple headers is different: an
exclusive buffer content lock is sufficient for that.

The idea here is deliberately narrow. It applies only to aggressive VACUUM.
After failing to acquire a cleanup lock, and before waiting for one, VACUUM
makes one freeze-only attempt under an exclusive buffer content lock (which
is less prone to starvation than a cleanup lock). It only makes a
best-effort attempt to freeze tuple headers. Dead tuples are left untouched,
but their XIDs and MultiXactIds are considered when determining whether the
relation freeze horizon can advance. If no dead tuple prevents that progress,
VACUUM can freeze the tuples that can be safely frozen and continue.
Otherwise, it falls back to waiting for a cleanup lock and running the
existing combined prune-and-freeze path.

Patch 1 refactors the per-page VACUUM path, where pruning and freezing are
currently coupled, to separate freeze planning and execution from pruning.
It does not change behavior: the pruning path still uses the same combined
prune-and-freeze WAL record. This prepares for freezing a page independently
of pruning.

Patch 2 adds the freeze-only path after cleanup-lock acquisition fails. The
existing path is unchanged when a cleanup lock is available. The new path is
used only when cleanup-lock contention would otherwise make an aggressive
VACUUM wait.

I have included a small reproducer using concurrent SELECT FOR UPDATE
transactions on one row. Without the patch, VACUUM FREEZE remains waiting
on BufferPin while the workload continues. With the patch, it freezes the
live tuple and completes while the workload is still running.

I would appreciate feedback on this approach.

---
Regards,
Jingtang

Alibaba Cloud

[1]: /messages/by-id/1059371874.2807306.1706727919318@mail.yahoo.com
[2]: /messages/by-id/BANLkTinmWFR1-mPu4nduUjxUfvWXZni-7Q@mail.gmail.com

Attachments:

t253544_1
v1-0001-vacuum-Separate-heap-page-freezing-state.patchapplication/octet-stream; name=v1-0001-vacuum-Separate-heap-page-freezing-state.patchDownload+116-68
v1-0002-vacuum-Allow-freezing-without-a-cleanup-lock.patchapplication/octet-stream; name=v1-0002-vacuum-Allow-freezing-without-a-cleanup-lock.patchDownload+369-5
#2Melanie Plageman
melanieplageman@gmail.com
In reply to: Jingtang Zhang (#1)
Re: Allow aggressive VACUUM to freeze without a cleanup lock

On Tue, Aug 25, 2026 at 6:32 AM Jingtang Zhang <mrdrivingduck@gmail.com> wrote:

The 2011 discussion [2] raised the related question of whether VACUUM
could make progress after failing to acquire a cleanup lock. It found that
changing line pointers while holding only an exclusive buffer content lock
would be unsafe, because another backend can inspect a line pointer while
holding only a buffer pin. However, freezing tuple headers is different: an
exclusive buffer content lock is sufficient for that.

I think this proposal (in the email) should explain why freezing xids
and mxids is safe with only an exclusive lock. AFAICT that 2011 thread
doesn't get specific. And today, lazy_scan_noprune() specifically
returns false and requires a cleanup lock during an aggressive vacuum
when freezing would be required. If this is not necessary, you should
explain why. Was it never required and we were being over-cautious?
Did something change in the code to make it safe and we forgot to
update lazy_scan_noprune()?

- Melanie

In reply to: Melanie Plageman (#2)
Re: Allow aggressive VACUUM to freeze without a cleanup lock

On Tue, Aug 25, 2026 at 10:06 AM Melanie Plageman
<melanieplageman@gmail.com> wrote:

I think this proposal (in the email) should explain why freezing xids
and mxids is safe with only an exclusive lock. AFAICT that 2011 thread
doesn't get specific.

As far as I know, the only fundamental reason we require a cleanup
lock is to make page defragmentation safe. Pruning as currently
defined implies page defragmentation, and freezing is closely tied to
pruning, so we tend to treat both as requiring a cleanup lock. But a
cleanup lock is likely not strictly necessary.

Freezing expects pruning to remove dead tuples, because we have no way
to freeze them. But it doesn't actually require page defragmentation.
So it is probably possible to invent a variant of pruning that doesn't
remove tuple storage, and thus has no need to defragment the page. I'm
thinking of an approach that performs an in-place update of xmin and
xmax, setting both to InvalidTransactionId. That would enable
repurposing lazy_scan_noprune into (say) lazy_scan_nodefrag, which
would always be able to freeze (and psuedo-prune) every page without
waiting for a cleanup lock.

Obviously I haven't prototyped this, so there might be some hard to
foresee difficulties. It's possible that pruning relies on cleanup
locks in a way nobody realizes right now -- it wouldn't be the first
time something like that happened. When I made the second pass over
the heap not require a cleanup lock for Postgres 14, it exposed race
conditions with how we set the visibility map in that pass.
Technically that wasn't my fault; the fixes for those bugs were in and
around heap_update IIRC.

--
Peter Geoghegan

In reply to: Peter Geoghegan (#3)
Re: Allow aggressive VACUUM to freeze without a cleanup lock

On Tue, Aug 25, 2026 at 10:28 AM Peter Geoghegan <pg@bowt.ie> wrote:

As far as I know, the only fundamental reason we require a cleanup
lock is to make page defragmentation safe. Pruning as currently
defined implies page defragmentation, and freezing is closely tied to
pruning, so we tend to treat both as requiring a cleanup lock. But a
cleanup lock is likely not strictly necessary.

Fun fact: prior to the invention of HOT in 2007, we'd freeze with only
a *shared* lock in the first heap pass. The first heap pass collected
dead TIDs and performed freezing, but it didn't do any pruning.
Freezing wasn't tied to pruning (or anything like it) in the way it is
today.

Pruning didn't really exist (not as we know it today), but page
defragmentation did. At that time, we only removed dead tuples/tuple
storage and performed page defragmentation in VACUUM's second heap
pass (which required a full cleanup lock).

So it's reasonable to surmise that freezing could be done with only an
exclusive lock on top of the current VACUUM code (though not a shared
lock, which seems really dangerous). And that page defragmentation is
the only thing that necessitates a cleanup lock/makes this idea
difficult.

--
Peter Geoghegan

#5邱宇航
iamqyh@gmail.com
In reply to: Peter Geoghegan (#4)
Re: Allow aggressive VACUUM to freeze without a cleanup lock

Hi Melanie, Peter, Jingtang,

So it's reasonable to surmise that freezing could be done with only an
exclusive lock on top of the current VACUUM code (though not a shared
lock, which seems really dangerous). And that page defragmentation is
the only thing that necessitates a cleanup lock/makes this idea difficult.

Agreed. Freezing itself only needs an exclusive lock, a cleanup lock is needed
for page defragmentation. I also agree with Melanie that the patch should
explain this if it is not already documented elsewhere.

I reviewed the patch and found a few issues.

First, the buffer lock is released and reacquired between
`lazy_scan_noprune()` and `lazy_scan_freeze()`. The page may be changed or
already frozen during this window, so this assertion is not always valid:
```C
Assert(state.pagefrz.freeze_required);
```

Second, the code may call heap_prepare_freeze_tuple() and then discard the
freeze plans after finding an old DEAD tuple. This function may create a new
MultiXactId, write WAL, and advance the MultiXact state. These side effects
remain even if the plans are discarded.

There is also a small naming issue. lazy_scan_noprune and lazy_scan_freeze are
quite confusing now. I think lazy_scan_noprune is not correct at this moment.

The patch adds FreezeState, HeapPageFreezeParams, and
HeapPageFreezeResult, but they overlap with PruneState,
PruneFreezeParams, and PruneFreezeResult. These abstractions add complexity
without much reuse.

Pages containing old XIDs or MultiXactIds in DEAD tuples still need a cleanup
lock. I think this is a reasonable limitation for this patch.

Best Regards,
Yuhang Qiu