REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

Started by Thom Brown30 days ago29 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.

won't retrysuccessCI history

This thread has been committed, so CI has stopped here. Anything below is the last result it produced.

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:t253905
psql -h localhost -U postgres

Built from patchset v27 (message #27), September 27, 2026 at 11:16 PM.

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 t253905_27 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 t253905_27 && git checkout t253905_27

Patchset v27 (message #27) is on t253905_27

Jump to latest
#1Thom Brown
thom@linux.com

Hi,

Whilst stress-testing REPACK (CONCURRENTLY) I managed to get it to silently
throw away committed updates to a TOASTed column. There's no error, and both
verify_heapam() and bt_index_check() seem to think everything is fine.

I had claude analyse what's going on and:
-------
It happens at the very start of REPACK. The decoding worker grabs the
toast relation's relfilenode in repack_setup_logical_decoding() and immediately
lets go of the lock, but the backend doesn't lock the toast table until
copy_table_data(). In between sits get_initial_snapshot(), which waits for all
running XIDs to finish, so an open transaction widens the gap nicely. During
that gap you can rewrite the toast table by name (VACUUM FULL / REPACK /
CLUSTER of it) and give it a fresh relfilenode - the parent is only held with
ShareUpdateExclusiveLock, so that's allowed.
-------

Reproducible steps:

-- session A: hold a transaction open so REPACK blocks building its snapshot
BEGIN;
SELECT pg_current_xact_id();

-- session B: set up, arm the one stock injection point, then REPACK (blocks)
CREATE TABLE test (id int PRIMARY KEY, big text);
ALTER TABLE test ALTER COLUMN big SET STORAGE EXTERNAL;
INSERT INTO test SELECT g, repeat('old', 3000) FROM generate_series(1,3) g;
SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
REPACK (CONCURRENTLY) test;

-- session C: rewrite the toast table in the gap, then let A commit
VACUUM FULL pg_toast.pg_toast_<oid-of-test>;

-- session A:
COMMIT;

-- session C: update the toasted rows
UPDATE test SET big = repeat('NEW', 4000) WHERE id IN (1,2,3);

SELECT id, left(big,9) AS val, length(big) FROM test ORDER BY id;
id | val | length
----+-----------+--------
1 | NEWNEWNEW | 12000
2 | NEWNEWNEW | 12000
3 | NEWNEWNEW | 12000

-- session C: let REPACK finish, then look again
SELECT injection_points_wakeup('repack-concurrently-before-lock');

SELECT id, left(big,9) AS val, length(big) FROM test ORDER BY id;
id | val | length
----+-----------+--------
1 | oldoldold | 9000
2 | oldoldold | 9000
3 | oldoldold | 9000

SELECT * FROM verify_heapam('t', check_toast => true); -- 0 rows
SELECT bt_index_check('t_pkey', heapallindexed => true); -- passes

So three committed updates have quietly reverted to their old values.

Regards

Thom

#2Manu
manuelreyesbravo@gmail.com
In reply to: Thom Brown (#1)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

Hi Thom,

Reproduced here on master (07c73f45063), Linux, following your recipe
exactly: the three rows read NEWNEWNEW/12000 before REPACK finishes and
oldoldold/9000 after it, no error anywhere.

One thing worth adding, because it changes how this reads: the
injection point is not needed.

It makes the race deterministic, but the window your analysis describes
- between repack_setup_logical_decoding() taking the toast relfilenode
and copy_table_data() locking the toast table - is wide enough on its
own, because get_initial_snapshot() sits in it waiting for running
XIDs. An ordinary open transaction is enough to hold it open. Without
any injection point:

session A BEGIN; SELECT pg_current_xact_id(); (sleeps 3s, commits)
session B REPACK (CONCURRENTLY) test; -- waits for A
session C VACUUM FULL pg_toast.pg_toast_<oid>; -- inside the gap
session C UPDATE test SET big = ... ; -- right after A commits

5 of 5 runs lost the update.

Script attached; it builds and drops the table on each iteration so the
result is not an artifact of one particular relfilenode.

So this does not need a debug build or a test-only feature to happen.
It needs a long-running transaction, a REPACK (CONCURRENTLY), and
someone rewriting that table's toast relation in the meantime - which
is an odd thing to do by hand, but it is allowed, the parent is only
held with ShareUpdateExclusiveLock as you say, and "run VACUUM FULL on
the biggest toast tables" is the kind of thing maintenance scripts do.

I have not looked for a fix yet. From your description the obvious
question is whether repack_setup_logical_decoding() should hold the
toast lock until copy_table_data() takes it, or whether the relfilenode
should be re-checked after the snapshot is built; the second sounds
cheaper but I have not read enough of that path to have an opinion
worth posting.

Regards,
Manu

Attachments:

nocfbot-repack-toast-race.sh.txttext/plain; charset=utf-8Download
#3shihao zhong
zhong950419@gmail.com
In reply to: Manu (#2)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

or whether the relfilenode should be re-checked after the snapshot is

built

Holding the toast lock from the start deadlocks. A session that asks for
AccessExclusiveLock gets an XID before it waits, and the decoding worker
waits for all XIDs while it sets up.

So the attached patch re-checks instead. Once the worker is set up it no
longer waits for anyone, so the backend locks the toast table there and
compares its relfilenode with the one the worker uses. If they differ, it
starts a new worker. Nothing has been copied yet, so REPACK just carries on.

0002 adds a test to repack_toast.spec that fails without 0001.

optional.
Thanks,
Shihao

Attachments:

t253905_3
v1-0001-Fix-REPACK-CONCURRENTLY-losing-updates-after-a-TO.patchapplication/octet-stream; name=v1-0001-Fix-REPACK-CONCURRENTLY-losing-updates-after-a-TO.patchDownload+75-2
v1-0002-Test-TOAST-rewrite-during-REPACK-CONCURRENTLY-sta.patchapplication/octet-stream; name=v1-0002-Test-TOAST-rewrite-during-REPACK-CONCURRENTLY-sta.patchDownload+188-2
#4Thom Brown
thom@linux.com
In reply to: shihao zhong (#3)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

On Wed, 23 Sept 2026 at 06:10, shihao zhong <zhong950419@gmail.com> wrote:

or whether the relfilenode should be re-checked after the snapshot is built

Holding the toast lock from the start deadlocks. A session that asks for
AccessExclusiveLock gets an XID before it waits, and the decoding worker
waits for all XIDs while it sets up.

So the attached patch re-checks instead. Once the worker is set up it no
longer waits for anyone, so the backend locks the toast table there and
compares its relfilenode with the one the worker uses. If they differ, it
starts a new worker. Nothing has been copied yet, so REPACK just carries on.

0002 adds a test to repack_toast.spec that fails without 0001.

Thanks guys.

I've tested your patches, and I can't reproduce the issue with them
applied. The test on its own fails successfully.

I do have a question relating to this:

+ UnlockRelationOid(toastrelid, ShareUpdateExclusiveLock);
+ stop_repack_decoding_worker();

Is there any opportunity for another rewrite to sneak in between these two?

Thom

#5shihao zhong
zhong950419@gmail.com
In reply to: Thom Brown (#4)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten
+ UnlockRelationOid(toastrelid, ShareUpdateExclusiveLock);
+ stop_repack_decoding_worker();

Is there any opportunity for another rewrite to sneak in between these

two?

Yes, but it doesn't matter. The old worker is thrown away and nothing has
been copied yet. The new worker reads the relfilenode itself when it
starts, so a rewrite before that is simply what it sees. A rewrite after
that is caught by the next check, which is made under the lock again.

The unlock has to come before starting the new worker anyway, or we
are back to the deadlock.

Thanks,
Shihao

#6Manu
manuelreyesbravo@gmail.com
In reply to: shihao zhong (#5)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

Hi Shihao,

I tested v1-0001 and v1-0002 on master (cff329240ba), two builds from
the same commit with --enable-cassert --enable-injection-points: one
with only 0002 applied (control) and one with both. 0001 builds with
no new warnings.

1. The race

The script I posted earlier wins the race without an injection point,
so it is a fair before/after check. Same script, same parameters,
against both builds:

master, 0002 only: 5 of 5 runs lost the update
master + 0001: 0 of 5 runs lost the update

2. The test

Thom already reported that the test fails without the fix; this only
adds that it fails for the right reason and nothing else moved:

control: repack_toast FAILS (results differ from expected)
0001: repack_toast passes, 499 ms

The other tests of the injection_points module pass in both builds, so
0002 fails only for the reason it is meant to.

3. The window Thom asked about

Is there any opportunity for another rewrite to sneak in between
these two?

Yes, but it doesn't matter. The old worker is thrown away and nothing
has been copied yet. The new worker reads the relfilenode itself when
it starts, so a rewrite before that is simply what it sees. A rewrite
after that is caught by the next check, which is made under the lock
again.

That is the claim I could put under load instead of taking it on
trust. A loop of VACUUM FULL on the toast relation runs for the whole
startup of REPACK, so it lands inside that window many times over,
while an open transaction keeps the worker waiting during setup and an
UPDATE of the TOASTed columns commits right after. With
log_min_messages=debug1 the patch's own DEBUG1 counts the restarts:

hammer 0s: REPACK ended in ~4s, 0 restarts, value correct
hammer 8s: REPACK ended in ~4s, 33 restarts, value correct
hammer 20s: REPACK ended in ~20s, 166 restarts, value correct
hammer 40s: REPACK ended in ~40s, 342 restarts, value correct
master, 20s: REPACK ended in ~5s, n/a, UPDATE LOST

So it holds up under continuous pressure: the value is never wrong, no
deadlock, and the run with no rewrites at all costs nothing (4s, zero
restarts), so the retry loop does not show up on the normal path.

4. One thing worth deciding, not a correctness issue

The numbers above also say that REPACK can be held up for as long as
the rewriting lasts. It is not a hard livelock - with the 8s hammer
it got through in 4s - but with the 20s and the 40s one it finished
only about when the hammer stopped, at roughly 8 restarts per second.
Whether it gets through is a matter of winning the window; the loop
has no cap and no backoff, and each turn starts a worker that waits
for all running transactions again.

I would not call this a bug: correct-but-waiting beats fast-and-wrong,
and a VACUUM FULL loop on a toast relation is not a real workload. But
it is unbounded, and the caller gets no hint of why nothing is
happening. Since 0001 already has the DEBUG1, would it be worth
raising it, or capping the retries and erroring out after N? Your
call - I mention it because the measurement was there.

Script attached (.txt, so the cfbot keeps testing your patches).

Regards,
Manu

Attachments:

nocfbot-repack-toast-hammer.sh.txttext/plain; charset=utf-8Download
#7Melanie Plageman
melanieplageman@gmail.com
In reply to: Thom Brown (#1)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

On Tue, Sep 22, 2026 at 8:23 PM Thom Brown <thom@linux.com> wrote:

Whilst stress-testing REPACK (CONCURRENTLY) I managed to get it to silently
throw away committed updates to a TOASTed column. There's no error, and both
verify_heapam() and bt_index_check() seem to think everything is fine.

Should this be added as an open item? [1]https://wiki.postgresql.org/wiki/PostgreSQL_19_Open_Items

- Melanie

[1]: https://wiki.postgresql.org/wiki/PostgreSQL_19_Open_Items

#8Antonin Houska
ah@cybertec.at
In reply to: shihao zhong (#3)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

shihao zhong <zhong950419@gmail.com> wrote:

or whether the relfilenode should be re-checked after the snapshot is built

Holding the toast lock from the start deadlocks. A session that asks for
AccessExclusiveLock gets an XID before it waits, and the decoding worker
waits for all XIDs while it sets up.

The same (supposedly low) deadlock risk already exists for the main table, see
this comment in rebuild_relation():

/*
* Start the worker that decodes data changes applied while we're
* copying the table contents.
*
* Note that the worker has to wait for all transactions with XID
* already assigned to finish. If some of those transactions is
* waiting for a lock conflicting with ShareUpdateExclusiveLock on our
* table (e.g. it runs CREATE INDEX), we can end up in a deadlock.
* Not sure this risk is worth unlocking/locking the table (and its
* clustering index) and checking again if it's still eligible for
* REPACK CONCURRENTLY.
*/
start_repack_decoding_worker(tableOid);

I'm not sure if locking the TOAST relation earlier would make the situation
worse.

The reason TOAST relation is not locked until copy_table_data() does so is
that CLUSTER / VACUUM FULL in v18 did it this way (not sure what the reason
for such design was). I haven't changed that for REPACK exactly because I
failed to envision this stale relfilenode issue.

--
Antonin Houska
Web: https://www.cybertec-postgresql.com

#9Thom Brown
thom@linux.com
In reply to: Melanie Plageman (#7)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

On Wed, 23 Sept 2026 at 16:07, Melanie Plageman
<melanieplageman@gmail.com> wrote:

On Tue, Sep 22, 2026 at 8:23 PM Thom Brown <thom@linux.com> wrote:

Whilst stress-testing REPACK (CONCURRENTLY) I managed to get it to silently
throw away committed updates to a TOASTed column. There's no error, and both
verify_heapam() and bt_index_check() seem to think everything is fine.

Should this be added as an open item? [1]

Yes. I've now added it.

Thanks.

Thom

#10Thom Brown
thom@linux.com
In reply to: Antonin Houska (#8)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

On Wed, 23 Sept 2026 at 17:22, Antonin Houska <ah@cybertec.at> wrote:

shihao zhong <zhong950419@gmail.com> wrote:

or whether the relfilenode should be re-checked after the snapshot is built

Holding the toast lock from the start deadlocks. A session that asks for
AccessExclusiveLock gets an XID before it waits, and the decoding worker
waits for all XIDs while it sets up.

The same (supposedly low) deadlock risk already exists for the main table, see
this comment in rebuild_relation():

/*
* Start the worker that decodes data changes applied while we're
* copying the table contents.
*
* Note that the worker has to wait for all transactions with XID
* already assigned to finish. If some of those transactions is
* waiting for a lock conflicting with ShareUpdateExclusiveLock on our
* table (e.g. it runs CREATE INDEX), we can end up in a deadlock.
* Not sure this risk is worth unlocking/locking the table (and its
* clustering index) and checking again if it's still eligible for
* REPACK CONCURRENTLY.
*/
start_repack_decoding_worker(tableOid);

I'm not sure if locking the TOAST relation earlier would make the situation
worse.

The reason TOAST relation is not locked until copy_table_data() does so is
that CLUSTER / VACUUM FULL in v18 did it this way (not sure what the reason
for such design was). I haven't changed that for REPACK exactly because I
failed to envision this stale relfilenode issue.

I gave that a try, and it does. It just swaps the lost update for a deadlock.

If you lock the toast up front and something rewrites it at the same
time (which is the thing that triggers this in the first place, e.g. a
REPACK of the toast table), REPACK falls over:

Session 1:
BEGIN;
INSERT INTO test VALUES (999999, 'x');

Session 2:
REPACK (CONCURRENTLY) test;

Session 1:
CREATE INDEX ON test (big);

ERROR: deadlock detected
DETAIL: Process 214534 waits for ShareLock on transaction 1774005;
blocked by process 214579.
Process 214579 waits for AccessExclusiveLock on relation 3672470 of
database 5; blocked by process 214534.
CONTEXT: REPACK decoding worker

The rewrite already has an XID by the time it waits, and the worker
waits for that XID whilst it sets up, so the two just sit on each
other. It doesn't matter which lock we take either because anything
that would stop the rewrite conflicts with it.

Thom

#11Masahiko Sawada
sawada.mshk@gmail.com
In reply to: Antonin Houska (#8)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

On Wed, Sep 23, 2026 at 9:23 AM Antonin Houska <ah@cybertec.at> wrote:

shihao zhong <zhong950419@gmail.com> wrote:

or whether the relfilenode should be re-checked after the snapshot is built

Holding the toast lock from the start deadlocks. A session that asks for
AccessExclusiveLock gets an XID before it waits, and the decoding worker
waits for all XIDs while it sets up.

The same (supposedly low) deadlock risk already exists for the main table, see
this comment in rebuild_relation():

/*
* Start the worker that decodes data changes applied while we're
* copying the table contents.
*
* Note that the worker has to wait for all transactions with XID
* already assigned to finish. If some of those transactions is
* waiting for a lock conflicting with ShareUpdateExclusiveLock on our
* table (e.g. it runs CREATE INDEX), we can end up in a deadlock.
* Not sure this risk is worth unlocking/locking the table (and its
* clustering index) and checking again if it's still eligible for
* REPACK CONCURRENTLY.
*/
start_repack_decoding_worker(tableOid);

I'm not sure if locking the TOAST relation earlier would make the situation
worse.

Agreed.

So I think the simplest fix would be to acquire a lock on the TOAST
table before starting the repack worker. It would make the case in
question fail with a deadlock, instead of silently losing updates.

The proposed patch also fixes the problem, but I'm concerned that it
repeatedly starts and stops the repack worker without any limit. I
think we could error out if we detect a concurrent rewrite, so that
users can re-run REPACK CONCURRENTLY. This check could also be done on
the repack worker side: after getting the relfilelocator of the TOAST
table and initializing the logical decoding, the repack worker
rechecks the relfilelocator. If they don't match, it raises an error.

Regards,

--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com

#12shihao zhong
zhong950419@gmail.com
In reply to: Masahiko Sawada (#11)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

Hi Masahiko,

Thanks for reviewing it.

I think we could error out if we detect a concurrent rewrite, so that
users can re-run REPACK CONCURRENTLY. This check could also be done on
the repack worker side

Agreed. v2 attached. REPACK now fails if the TOAST table was rewritten,
and the user can run it again.

The check stays in the backend, under the lock, though. If the worker
checks, a rewrite can still come after that check and before
copy_table_data() locks the TOAST table, and the update is lost the same
way. The backend takes the lock right after the worker is set up and
keeps it. Locking first would also work, but then the same race ends in
a deadlock instead of a clear error.

With the loop gone, the window Thom asked about is gone too. 0002 is the
test and is optional.

Shihao

On Wed, Sep 23, 2026 at 2:27 PM Masahiko Sawada <sawada.mshk@gmail.com>
wrote:

Show quoted text

On Wed, Sep 23, 2026 at 9:23 AM Antonin Houska <ah@cybertec.at> wrote:

shihao zhong <zhong950419@gmail.com> wrote:

or whether the relfilenode should be re-checked after the snapshot

is built

Holding the toast lock from the start deadlocks. A session that asks

for

AccessExclusiveLock gets an XID before it waits, and the decoding

worker

waits for all XIDs while it sets up.

The same (supposedly low) deadlock risk already exists for the main

table, see

this comment in rebuild_relation():

/*
* Start the worker that decodes data changes applied while we're
* copying the table contents.
*
* Note that the worker has to wait for all transactions with XID
* already assigned to finish. If some of those transactions is
* waiting for a lock conflicting with ShareUpdateExclusiveLock on

our

* table (e.g. it runs CREATE INDEX), we can end up in a deadlock.
* Not sure this risk is worth unlocking/locking the table (and its
* clustering index) and checking again if it's still eligible for
* REPACK CONCURRENTLY.
*/
start_repack_decoding_worker(tableOid);

I'm not sure if locking the TOAST relation earlier would make the

situation

worse.

Agreed.

So I think the simplest fix would be to acquire a lock on the TOAST
table before starting the repack worker. It would make the case in
question fail with a deadlock, instead of silently losing updates.

The proposed patch also fixes the problem, but I'm concerned that it
repeatedly starts and stops the repack worker without any limit. I
think we could error out if we detect a concurrent rewrite, so that
users can re-run REPACK CONCURRENTLY. This check could also be done on
the repack worker side: after getting the relfilelocator of the TOAST
table and initializing the logical decoding, the repack worker
rechecks the relfilelocator. If they don't match, it raises an error.

Regards,

--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com

Attachments:

t253905_12
v2-0001-Fix-REPACK-CONCURRENTLY-losing-updates-after-a-TO.patchapplication/octet-stream; name=v2-0001-Fix-REPACK-CONCURRENTLY-losing-updates-after-a-TO.patchDownload+67-1
v2-0002-Test-TOAST-rewrite-during-REPACK-CONCURRENTLY-sta.patchapplication/octet-stream; name=v2-0002-Test-TOAST-rewrite-during-REPACK-CONCURRENTLY-sta.patchDownload+199-2
#13Manu
manuelreyesbravo@gmail.com
In reply to: shihao zhong (#12)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

Hi Shihao,

Agreed. v2 attached. REPACK now fails if the TOAST table was
rewritten, and the user can run it again.

I ran the same checks as for v1 against v2, on cff329240ba with
--enable-cassert --enable-injection-points, next to a control build
with only v2-0002. Both patches apply cleanly and build with no
warnings.

1. The race without an injection point (VACUUM FULL of the TOAST
relation while the worker waits, UPDATE right after), 5 runs each:

control: REPACK succeeds, update lost in 5 of 5
v2: REPACK fails in 5 of 5, update kept in 5 of 5

ERROR: could not execute REPACK (CONCURRENTLY) on relation "test"
DETAIL: The TOAST relation was rewritten concurrently.
HINT: The transaction might succeed if retried.

2. VACUUM FULL of the TOAST relation in a loop for 20 s, over the
whole startup: v2 fails after 3.6 s with the same error, and the
update is kept. With v1 the same run took about 20 s and 166 worker
restarts, so the unbounded wait I mentioned for v1 is gone.

3. No rewrite at all: REPACK succeeds in 3 of 3, 2.5-2.6 s, the same
as the control.

4. Thom's deadlock case, where a transaction that already has an XID
locks the TOAST relation while the worker waits for it:

REINDEX TABLE of the TOAST relation (lock, no rewrite)
v2: REPACK succeeds, no deadlock, update kept
CLUSTER of the TOAST relation (lock and rewrite)
control: REPACK succeeds, update lost
v2: REPACK fails with the error above, update kept

So taking the lock after the worker's setup does what the commit
message says: no deadlock, and a clear error when the rewrite does
happen.

5. Tests: repack_toast fails on the control and passes with v2. With
v2 all injection_points tests pass (4 regress, 14 isolation), and so
do make check (239) and src/test/isolation (133).

The script is attached (.txt, so the cfbot keeps testing your
patches).

Regards,
Manu

Attachments:

nocfbot-repack-toast-v2-check.sh.txttext/plainDownload
#14Robert Treat
xzilla@users.sourceforge.net
In reply to: Masahiko Sawada (#11)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

On Wed, Sep 23, 2026 at 2:28 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:

On Wed, Sep 23, 2026 at 9:23 AM Antonin Houska <ah@cybertec.at> wrote:

shihao zhong <zhong950419@gmail.com> wrote:

or whether the relfilenode should be re-checked after the snapshot is built

Holding the toast lock from the start deadlocks. A session that asks for
AccessExclusiveLock gets an XID before it waits, and the decoding worker
waits for all XIDs while it sets up.

The same (supposedly low) deadlock risk already exists for the main table, see
this comment in rebuild_relation():

/*
* Start the worker that decodes data changes applied while we're
* copying the table contents.
*
* Note that the worker has to wait for all transactions with XID
* already assigned to finish. If some of those transactions is
* waiting for a lock conflicting with ShareUpdateExclusiveLock on our
* table (e.g. it runs CREATE INDEX), we can end up in a deadlock.
* Not sure this risk is worth unlocking/locking the table (and its
* clustering index) and checking again if it's still eligible for
* REPACK CONCURRENTLY.
*/
start_repack_decoding_worker(tableOid);

I'm not sure if locking the TOAST relation earlier would make the situation
worse.

Agreed.

So I think the simplest fix would be to acquire a lock on the TOAST
table before starting the repack worker. It would make the case in
question fail with a deadlock, instead of silently losing updates.

The proposed patch also fixes the problem, but I'm concerned that it
repeatedly starts and stops the repack worker without any limit. I
think we could error out if we detect a concurrent rewrite, so that
users can re-run REPACK CONCURRENTLY. This check could also be done on
the repack worker side: after getting the relfilelocator of the TOAST
table and initializing the logical decoding, the repack worker
rechecks the relfilelocator. If they don't match, it raises an error.

It feels a little off to me that if I am trying to REPACKCC, and
someone (maybe even myself, but certainly not Postgres) comes along
and runs a command the conflicts with my existing REPACKCC, that my
REPACKCC is canceled rather than having the other command either wait
or error out. That's a little more complicated a fix, with likely
heavier and/or longer held locks, but feels like it would be less
surprising for users.

Robert Treat
https://xzilla.net

#15Antonin Houska
ah@cybertec.at
In reply to: Thom Brown (#10)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

Thom Brown <thom@linux.com> wrote:

On Wed, 23 Sept 2026 at 17:22, Antonin Houska <ah@cybertec.at> wrote:

shihao zhong <zhong950419@gmail.com> wrote:

or whether the relfilenode should be re-checked after the snapshot is built

Holding the toast lock from the start deadlocks. A session that asks for
AccessExclusiveLock gets an XID before it waits, and the decoding worker
waits for all XIDs while it sets up.

The same (supposedly low) deadlock risk already exists for the main table, see
this comment in rebuild_relation():

/*
* Start the worker that decodes data changes applied while we're
* copying the table contents.
*
* Note that the worker has to wait for all transactions with XID
* already assigned to finish. If some of those transactions is
* waiting for a lock conflicting with ShareUpdateExclusiveLock on our
* table (e.g. it runs CREATE INDEX), we can end up in a deadlock.
* Not sure this risk is worth unlocking/locking the table (and its
* clustering index) and checking again if it's still eligible for
* REPACK CONCURRENTLY.
*/
start_repack_decoding_worker(tableOid);

I'm not sure if locking the TOAST relation earlier would make the situation
worse.

The reason TOAST relation is not locked until copy_table_data() does so is
that CLUSTER / VACUUM FULL in v18 did it this way (not sure what the reason
for such design was). I haven't changed that for REPACK exactly because I
failed to envision this stale relfilenode issue.

I gave that a try, and it does. It just swaps the lost update for a deadlock.

If you lock the toast up front and something rewrites it at the same
time (which is the thing that triggers this in the first place, e.g. a
REPACK of the toast table), REPACK falls over:

Session 1:
BEGIN;
INSERT INTO test VALUES (999999, 'x');

Session 2:
REPACK (CONCURRENTLY) test;

Session 1:
CREATE INDEX ON test (big);

ERROR: deadlock detected
DETAIL: Process 214534 waits for ShareLock on transaction 1774005;
blocked by process 214579.
Process 214579 waits for AccessExclusiveLock on relation 3672470 of
database 5; blocked by process 214534.
CONTEXT: REPACK decoding worker

The rewrite already has an XID by the time it waits, and the worker
waits for that XID whilst it sets up, so the two just sit on each
other. It doesn't matter which lock we take either because anything
that would stop the rewrite conflicts with it.

IMO this example does not exactly demonstrate the problem described in the
comment above: if REPACK (CONCURRENTLY) waits for AccessExclusiveLock, it's
going to perform the relation swap, so the worker should already be gone.

On the other hand, the message

"Process ... waits for ShareLock on transaction ..."

is what the deadlock detector would report for the decoding worker. However,
where would the request for AccessExclusiveLock come from in that case? CREATE
INDEX only uses it to lock the new index relation, however that cannot be
locked by other backends until the transaction has committed (because it's not
visible before commit).

What exactly have you changed in the code?

--
Antonin Houska
Web: https://www.cybertec-postgresql.com

#16Thom Brown
thom@linux.com
In reply to: Antonin Houska (#15)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

On Thu, 24 Sept 2026 at 08:57, Antonin Houska <ah@cybertec.at> wrote:

Thom Brown <thom@linux.com> wrote:

On Wed, 23 Sept 2026 at 17:22, Antonin Houska <ah@cybertec.at> wrote:

shihao zhong <zhong950419@gmail.com> wrote:

or whether the relfilenode should be re-checked after the snapshot is built

Holding the toast lock from the start deadlocks. A session that asks for
AccessExclusiveLock gets an XID before it waits, and the decoding worker
waits for all XIDs while it sets up.

The same (supposedly low) deadlock risk already exists for the main table, see
this comment in rebuild_relation():

/*
* Start the worker that decodes data changes applied while we're
* copying the table contents.
*
* Note that the worker has to wait for all transactions with XID
* already assigned to finish. If some of those transactions is
* waiting for a lock conflicting with ShareUpdateExclusiveLock on our
* table (e.g. it runs CREATE INDEX), we can end up in a deadlock.
* Not sure this risk is worth unlocking/locking the table (and its
* clustering index) and checking again if it's still eligible for
* REPACK CONCURRENTLY.
*/
start_repack_decoding_worker(tableOid);

I'm not sure if locking the TOAST relation earlier would make the situation
worse.

The reason TOAST relation is not locked until copy_table_data() does so is
that CLUSTER / VACUUM FULL in v18 did it this way (not sure what the reason
for such design was). I haven't changed that for REPACK exactly because I
failed to envision this stale relfilenode issue.

I gave that a try, and it does. It just swaps the lost update for a deadlock.

If you lock the toast up front and something rewrites it at the same
time (which is the thing that triggers this in the first place, e.g. a
REPACK of the toast table), REPACK falls over:

Session 1:
BEGIN;
INSERT INTO test VALUES (999999, 'x');

Session 2:
REPACK (CONCURRENTLY) test;

Session 1:
CREATE INDEX ON test (big);

ERROR: deadlock detected
DETAIL: Process 214534 waits for ShareLock on transaction 1774005;
blocked by process 214579.
Process 214579 waits for AccessExclusiveLock on relation 3672470 of
database 5; blocked by process 214534.
CONTEXT: REPACK decoding worker

The rewrite already has an XID by the time it waits, and the worker
waits for that XID whilst it sets up, so the two just sit on each
other. It doesn't matter which lock we take either because anything
that would stop the rewrite conflicts with it.

IMO this example does not exactly demonstrate the problem described in the
comment above: if REPACK (CONCURRENTLY) waits for AccessExclusiveLock, it's
going to perform the relation swap, so the worker should already be gone.

On the other hand, the message

"Process ... waits for ShareLock on transaction ..."

is what the deadlock detector would report for the decoding worker. However,
where would the request for AccessExclusiveLock come from in that case? CREATE
INDEX only uses it to lock the new index relation, however that cannot be
locked by other backends until the transaction has committed (because it's not
visible before commit).

What exactly have you changed in the code?

Apologies, I seem to have incorrectly paired tests with different
results during my copy and pasting.

I'll see if I can untangle it later today.

Thom

#17Antonin Houska
ah@cybertec.at
In reply to: Robert Treat (#14)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

Robert Treat <rob@xzilla.net> wrote:

On Wed, Sep 23, 2026 at 2:28 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:

On Wed, Sep 23, 2026 at 9:23 AM Antonin Houska <ah@cybertec.at> wrote:

shihao zhong <zhong950419@gmail.com> wrote:

or whether the relfilenode should be re-checked after the snapshot is built

Holding the toast lock from the start deadlocks. A session that asks for
AccessExclusiveLock gets an XID before it waits, and the decoding worker
waits for all XIDs while it sets up.

The same (supposedly low) deadlock risk already exists for the main table, see
this comment in rebuild_relation():

/*
* Start the worker that decodes data changes applied while we're
* copying the table contents.
*
* Note that the worker has to wait for all transactions with XID
* already assigned to finish. If some of those transactions is
* waiting for a lock conflicting with ShareUpdateExclusiveLock on our
* table (e.g. it runs CREATE INDEX), we can end up in a deadlock.
* Not sure this risk is worth unlocking/locking the table (and its
* clustering index) and checking again if it's still eligible for
* REPACK CONCURRENTLY.
*/
start_repack_decoding_worker(tableOid);

I'm not sure if locking the TOAST relation earlier would make the situation
worse.

Agreed.

So I think the simplest fix would be to acquire a lock on the TOAST
table before starting the repack worker. It would make the case in
question fail with a deadlock, instead of silently losing updates.

The proposed patch also fixes the problem, but I'm concerned that it
repeatedly starts and stops the repack worker without any limit. I
think we could error out if we detect a concurrent rewrite, so that
users can re-run REPACK CONCURRENTLY. This check could also be done on
the repack worker side: after getting the relfilelocator of the TOAST
table and initializing the logical decoding, the repack worker
rechecks the relfilelocator. If they don't match, it raises an error.

It feels a little off to me that if I am trying to REPACKCC, and
someone (maybe even myself, but certainly not Postgres) comes along
and runs a command the conflicts with my existing REPACKCC, that my
REPACKCC is canceled rather than having the other command either wait
or error out.

pg_squeeze gives up as soon as it notices a "disrupting" catalog
change. Although I haven't heard complaints about this behavior (it's probably
not common to run conflicting DDL commands during maintenance window), I admit
it's not the ideal approach.

For REPACK (CONCURRENTLY), we decided to not give up voluntarily. Even if
REPACK ends up in a deadlock, it still has some chance to win. The direction
we took here is to adjust the deadlock detector (in future versions) so that
REPACK always wins. Raising ERROR on REPACK's side in case of specific
conflict would be against that strategy.

(What I said does not mean that I'm in favor of restarting the decoding worker
either. I still prefer locking the TOAST relation early, as I noted elsewhere
in the thread.)

--
Antonin Houska
Web: https://www.cybertec-postgresql.com

#18shihao zhong
zhong950419@gmail.com
In reply to: Antonin Houska (#17)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

(What I said does not mean that I'm in favor of restarting the decoding

worker

either. I still prefer locking the TOAST relation early, as I noted

elsewhere

in the thread.)

OK. v3 locks the TOAST relation before the worker starts, as Sawada-san
first suggested. A rewrite of the TOAST relation now waits for REPACK,
which I think is also what Robert asked for.

The deadlock I mentioned only happens if the rewrite starts while the
worker still waits for older transactions. The rewrite gets the error if
those finish within deadlock_timeout, otherwise REPACK does. Nothing is
lost either way. ALTER TABLE on the table itself hits the same deadlock
on master today.

0002 is the test. The rewrite there uses lock_timeout,
so the result does not depend on which side the deadlock detector picks.

Thanks,
Shihao

Attachments:

t253905_18
v3-0002-Test-TOAST-rewrite-during-REPACK-CONCURRENTLY-sta.patchapplication/octet-stream; name=v3-0002-Test-TOAST-rewrite-during-REPACK-CONCURRENTLY-sta.patchDownload+201-2
v3-0001-Fix-REPACK-CONCURRENTLY-losing-updates-after-a-TO.patchapplication/octet-stream; name=v3-0001-Fix-REPACK-CONCURRENTLY-losing-updates-after-a-TO.patchDownload+18-5
#19Manu
manuelreyesbravo@gmail.com
In reply to: shihao zhong (#18)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

Hi Shihao,

shihao zhong <zhong950419@gmail.com> wrote:

The rewrite gets the error if those finish within deadlock_timeout,
otherwise REPACK does.

That matches what I see with v3 on master (e27f3b2cad7), using the
same checks as for v2. With Thom's sequence (a transaction open for
3 s, VACUUM FULL of the TOAST relation meanwhile) and the default
deadlock_timeout of 1 s, REPACK got "deadlock detected" in all 6 runs.
With deadlock_timeout = 5s, REPACK finished and the VACUUM FULL got
the error in all 3. No update was lost in any run; master loses it
every time.

One case differs from v2: a transaction that already has an XID runs
REINDEX on the TOAST relation, with no rewrite, during the startup.
On master and with v2, REPACK completes; with v3 it gets "deadlock
detected", at both timeouts. ALTER TABLE on the table itself
deadlocks the same way on master, as you said.

repack_toast fails without 0001 and passed 20 of 20 runs with it;
check and the isolation suite pass. The script and output are
attached.

Regards,
Manu

Attachments:

nocfbot-repack-toast-v3-check.txttext/plainDownload
#20Antonin Houska
ah@cybertec.at
In reply to: shihao zhong (#18)
Re: REPACK (CONCURRENTLY) can silently lose updates when the toast table is rewritten

shihao zhong <zhong950419@gmail.com> wrote:

(What I said does not mean that I'm in favor of restarting the decoding worker
either. I still prefer locking the TOAST relation early, as I noted elsewhere
in the thread.)

OK. v3 locks the TOAST relation before the worker starts, as Sawada-san
first suggested. A rewrite of the TOAST relation now waits for REPACK,
which I think is also what Robert asked for.

Thanks for the patch. I'm just not sure this is the best place to lock the
TOAST table: note that copy_table_data() locks it again.

I'd prefer locking it close to the place we lock the main table (perhaps in
cluster_rel(), after all the checks have been done?) and replace the locking
statements (both in the copy_table_data() and in your patch) with
Assert(CheckRelationLockedByMe(...)).

--
Antonin Houska
Web: https://www.cybertec-postgresql.com

#21Thom Brown
thom@linux.com
In reply to: Thom Brown (#16)
#22Thom Brown
thom@linux.com
In reply to: Antonin Houska (#20)
#23shihao zhong
zhong950419@gmail.com
In reply to: Thom Brown (#22)
#24Alvaro Herrera
alvherre@2ndquadrant.com
In reply to: Thom Brown (#22)
#25shihao zhong
zhong950419@gmail.com
In reply to: Alvaro Herrera (#24)
#26Masahiko Sawada
sawada.mshk@gmail.com
In reply to: Alvaro Herrera (#24)
#27shihao zhong
zhong950419@gmail.com
In reply to: Masahiko Sawada (#26)
#28Manu
manuelreyesbravo@gmail.com
In reply to: shihao zhong (#27)
#29Alvaro Herrera
alvherre@2ndquadrant.com
In reply to: Manu (#28)