agora inbox for pgsql-bugs@postgresql.org
help / color / mirror / Atom feedBUG #19556: Segmentation fault in test_decoding
10+ messages / 3 participants
[nested] [flat]
* BUG #19556: Segmentation fault in test_decoding
@ 2026-07-17 06:24 PG Bug reporting form <noreply@postgresql.org>
0 siblings, 1 reply; 10+ messages in thread
From: PG Bug reporting form @ 2026-07-17 06:24 UTC (permalink / raw)
To: pgsql-bugs@lists.postgresql.org; +Cc: a.kozhemyakin@postgrespro.ru
The following bug has been logged on the website:
Bug reference: 19556
Logged by: Alexander Kozhemyakin
Email address: a.kozhemyakin@postgrespro.ru
PostgreSQL version: 19beta2
Operating system: ubuntu 26.04
Description:
Hi,
The following script causes the server to crash with a Segmentation fault.
initdb -D data
echo "
max_prepared_transactions = '15'
wal_level = logical " >> data/postgresql.auto.conf
pg_ctl -D data -l logfile start
psql <<EOF
SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot',
'test_decoding', false, true);
CREATE TABLE test (id int PRIMARY KEY, data text);
INSERT INTO test VALUES (1, 'test data');
BEGIN;
SELECT * FROM test WHERE id = 1 FOR SHARE;
PREPARE TRANSACTION 'p1';
SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL,
'include-xids', '0', 'skip-empty-xacts', '1');
EOF
backtrace
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x0000749f6ee23f54 in pg_decode_prepare_txn (ctx=0x6344c3d7f3f0,
txn=0x6344c3d7d320, prepare_lsn=24628632) at
/pgpro/postgres/contrib/test_decoding/test_decoding.c:378
378 if (data->skip_empty_xacts && !txndata->xact_wrote_changes)
(gdb) bt
#0 0x0000749f6ee23f54 in pg_decode_prepare_txn (ctx=0x6344c3d7f3f0,
txn=0x6344c3d7d320, prepare_lsn=24628632) at
/pgpro/postgres/contrib/test_decoding/test_decoding.c:378
#1 0x00006344a8e8293c in prepare_cb_wrapper (cache=<optimized out>,
txn=<optimized out>, prepare_lsn=<optimized out>) at
/pgpro/postgres/src/backend/replication/logical/logical.c:985
#2 0x00006344a8e90c24 in ReorderBufferPrepare (rb=0x6344c3cd9e10,
xid=xid@entry=745, gid=gid@entry=0x7ffcb3620ef4 "p1") at
/pgpro/postgres/src/backend/replication/logical/reorderbuffer.c:2934
#3 0x00006344a8e7fa26 in DecodePrepare (ctx=0x6344c3d7f3f0,
buf=0x7ffcb3621030, parsed=0x7ffcb3620ea0) at
/pgpro/postgres/src/backend/replication/logical/decode.c:815
#4 xact_decode (ctx=0x6344c3d7f3f0, buf=0x7ffcb3621030) at
/pgpro/postgres/src/backend/replication/logical/decode.c:347
#5 0x00006344a8e7f223 in LogicalDecodingProcessRecord
(ctx=ctx@entry=0x6344c3d7f3f0, record=0x6344c3d7f788) at
/pgpro/postgres/src/backend/replication/logical/decode.c:116
#6 0x00006344a8e8577b in pg_logical_slot_get_changes_guts
(fcinfo=0x6344c3d83410, confirm=confirm@entry=true,
binary=binary@entry=false) at
/pgpro/postgres/src/backend/replication/logical/logicalfuncs.c:266
#7 0x00006344a8e858f4 in pg_logical_slot_get_changes (fcinfo=<optimized
out>) at /pgpro/postgres/src/backend/replication/logical/logicalfuncs.c:333
#8 0x00006344a8d3666d in ExecMakeTableFunctionResult
(setexpr=0x6344c3d5ffc8, econtext=0x6344c3d5fe18, argContext=<optimized
out>, expectedDesc=0x6344c3d49868, randomAccess=false)
at /pgpro/postgres/src/backend/executor/execSRF.c:234
#9 0x00006344a8d4a14b in FunctionNext (node=node@entry=0x6344c3d5fc08) at
/pgpro/postgres/src/backend/executor/nodeFunctionscan.c:94
#10 0x00006344a8d36f4c in ExecScanFetch (node=<optimized out>,
epqstate=<optimized out>, accessMtd=<optimized out>, recheckMtd=<optimized
out>) at /pgpro/postgres/src/include/executor/execScan.h:126
#11 ExecScanExtended (node=<optimized out>, accessMtd=0x6344a8d49e10
<FunctionNext>, recheckMtd=0x6344a8d49e00 <FunctionRecheck>, epqstate=0x0,
qual=0x0, projInfo=0x6344c3d49ea8)
at /pgpro/postgres/src/include/executor/execScan.h:187
#12 ExecScan (node=0x6344c3d5fc08, accessMtd=0x6344a8d49e10 <FunctionNext>,
recheckMtd=0x6344a8d49e00 <FunctionRecheck>) at
/pgpro/postgres/src/backend/executor/execScan.c:59
#13 0x00006344a8d2c17b in ExecProcNode (node=0x6344c3d5fc08) at
/pgpro/postgres/src/include/executor/executor.h:272
#14 ExecutePlan (queryDesc=0x6344c3cd55d0, operation=CMD_SELECT,
sendTuples=true, numberTuples=0, direction=<optimized out>,
dest=0x6344c3d8b7b8) at /pgpro/postgres/src/backend/executor/execMain.c:1675
#15 standard_ExecutorRun (queryDesc=0x6344c3cd55d0, direction=<optimized
out>, count=0) at /pgpro/postgres/src/backend/executor/execMain.c:364
#16 0x00006344a8f11478 in PortalRunSelect
(portal=portal@entry=0x6344c3d018b0, forward=forward@entry=true, count=0,
count@entry=9223372036854775807, dest=dest@entry=0x6344c3d8b7b8)
at /pgpro/postgres/src/backend/tcop/pquery.c:920
#17 0x00006344a8f12c5e in PortalRun (portal=portal@entry=0x6344c3d018b0,
count=count@entry=9223372036854775807, isTopLevel=isTopLevel@entry=true,
dest=dest@entry=0x6344c3d8b7b8, altdest=altdest@entry=0x6344c3d8b7b8,
qc=qc@entry=0x7ffcb3621640) at
/pgpro/postgres/src/backend/tcop/pquery.c:764
#18 0x00006344a8f0e95e in exec_simple_query (query_string=0x6344c3c80f60
"SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL,
'include-xids', '0', 'skip-empty-xacts', '1');")
at /pgpro/postgres/src/backend/tcop/postgres.c:1271
#19 0x00006344a8f103e5 in PostgresMain (dbname=<optimized out>,
username=<optimized out>) at
/pgpro/postgres/src/backend/tcop/postgres.c:4691
#20 0x00006344a8f0aaa3 in BackendMain (startup_data=<optimized out>,
startup_data_len=<optimized out>) at
/pgpro/postgres/src/backend/tcop/backend_startup.c:107
#21 0x00006344a8e6184f in postmaster_child_launch (child_type=<optimized
out>, child_slot=1, startup_data=startup_data@entry=0x7ffcb3621aec "",
startup_data_len=startup_data_len@entry=4,
client_sock=client_sock@entry=0x7ffcb3621af0) at
/pgpro/postgres/src/backend/postmaster/launch_backend.c:274
#22 0x00006344a8e653ef in BackendStartup (client_sock=0x7ffcb3621af0) at
/pgpro/postgres/src/backend/postmaster/postmaster.c:3519
#23 ServerLoop () at
/pgpro/postgres/src/backend/postmaster/postmaster.c:1688
#24 0x00006344a8e66cbf in PostmasterMain (argc=argc@entry=3,
argv=argv@entry=0x6344c3c7b440) at
/pgpro/postgres/src/backend/postmaster/postmaster.c:1386
#25 0x00006344a8b3b35e in main (argc=3, argv=0x6344c3c7b440) at
/pgpro/postgres/src/backend/main/main.c:230
first bad commit 072ee847ad4c3fb52
^ permalink raw reply [nested|flat] 10+ messages in thread
* Re: BUG #19556: Segmentation fault in test_decoding
@ 2026-07-19 07:30 Masahiko Sawada <sawada.mshk@gmail.com>
parent: PG Bug reporting form <noreply@postgresql.org>
0 siblings, 1 reply; 10+ messages in thread
From: Masahiko Sawada @ 2026-07-19 07:30 UTC (permalink / raw)
To: a.kozhemyakin@postgrespro.ru; pgsql-bugs@lists.postgresql.org
On Fri, Jul 17, 2026 at 9:30 AM PG Bug reporting form
<noreply@postgresql.org> wrote:
>
> The following bug has been logged on the website:
>
> Bug reference: 19556
> Logged by: Alexander Kozhemyakin
> Email address: a.kozhemyakin@postgrespro.ru
> PostgreSQL version: 19beta2
> Operating system: ubuntu 26.04
> Description:
>
> Hi,
>
> The following script causes the server to crash with a Segmentation fault.
>
> initdb -D data
> echo "
> max_prepared_transactions = '15'
> wal_level = logical " >> data/postgresql.auto.conf
> pg_ctl -D data -l logfile start
>
> psql <<EOF
> SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot',
> 'test_decoding', false, true);
> CREATE TABLE test (id int PRIMARY KEY, data text);
> INSERT INTO test VALUES (1, 'test data');
> BEGIN;
> SELECT * FROM test WHERE id = 1 FOR SHARE;
> PREPARE TRANSACTION 'p1';
> SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL,
> 'include-xids', '0', 'skip-empty-xacts', '1');
> EOF
>
>
> backtrace
> Program terminated with signal SIGSEGV, Segmentation fault.
> #0 0x0000749f6ee23f54 in pg_decode_prepare_txn (ctx=0x6344c3d7f3f0,
> txn=0x6344c3d7d320, prepare_lsn=24628632) at
> /pgpro/postgres/contrib/test_decoding/test_decoding.c:378
> 378 if (data->skip_empty_xacts && !txndata->xact_wrote_changes)
> (gdb) bt
> #0 0x0000749f6ee23f54 in pg_decode_prepare_txn (ctx=0x6344c3d7f3f0,
> txn=0x6344c3d7d320, prepare_lsn=24628632) at
> /pgpro/postgres/contrib/test_decoding/test_decoding.c:378
> #1 0x00006344a8e8293c in prepare_cb_wrapper (cache=<optimized out>,
> txn=<optimized out>, prepare_lsn=<optimized out>) at
> /pgpro/postgres/src/backend/replication/logical/logical.c:985
> #2 0x00006344a8e90c24 in ReorderBufferPrepare (rb=0x6344c3cd9e10,
> xid=xid@entry=745, gid=gid@entry=0x7ffcb3620ef4 "p1") at
> /pgpro/postgres/src/backend/replication/logical/reorderbuffer.c:2934
> #3 0x00006344a8e7fa26 in DecodePrepare (ctx=0x6344c3d7f3f0,
> buf=0x7ffcb3621030, parsed=0x7ffcb3620ea0) at
> /pgpro/postgres/src/backend/replication/logical/decode.c:815
> #4 xact_decode (ctx=0x6344c3d7f3f0, buf=0x7ffcb3621030) at
> /pgpro/postgres/src/backend/replication/logical/decode.c:347
> #5 0x00006344a8e7f223 in LogicalDecodingProcessRecord
> (ctx=ctx@entry=0x6344c3d7f3f0, record=0x6344c3d7f788) at
> /pgpro/postgres/src/backend/replication/logical/decode.c:116
> #6 0x00006344a8e8577b in pg_logical_slot_get_changes_guts
> (fcinfo=0x6344c3d83410, confirm=confirm@entry=true,
> binary=binary@entry=false) at
> /pgpro/postgres/src/backend/replication/logical/logicalfuncs.c:266
> #7 0x00006344a8e858f4 in pg_logical_slot_get_changes (fcinfo=<optimized
> out>) at /pgpro/postgres/src/backend/replication/logical/logicalfuncs.c:333
> #8 0x00006344a8d3666d in ExecMakeTableFunctionResult
> (setexpr=0x6344c3d5ffc8, econtext=0x6344c3d5fe18, argContext=<optimized
> out>, expectedDesc=0x6344c3d49868, randomAccess=false)
> at /pgpro/postgres/src/backend/executor/execSRF.c:234
> #9 0x00006344a8d4a14b in FunctionNext (node=node@entry=0x6344c3d5fc08) at
> /pgpro/postgres/src/backend/executor/nodeFunctionscan.c:94
> #10 0x00006344a8d36f4c in ExecScanFetch (node=<optimized out>,
> epqstate=<optimized out>, accessMtd=<optimized out>, recheckMtd=<optimized
> out>) at /pgpro/postgres/src/include/executor/execScan.h:126
> #11 ExecScanExtended (node=<optimized out>, accessMtd=0x6344a8d49e10
> <FunctionNext>, recheckMtd=0x6344a8d49e00 <FunctionRecheck>, epqstate=0x0,
> qual=0x0, projInfo=0x6344c3d49ea8)
> at /pgpro/postgres/src/include/executor/execScan.h:187
> #12 ExecScan (node=0x6344c3d5fc08, accessMtd=0x6344a8d49e10 <FunctionNext>,
> recheckMtd=0x6344a8d49e00 <FunctionRecheck>) at
> /pgpro/postgres/src/backend/executor/execScan.c:59
> #13 0x00006344a8d2c17b in ExecProcNode (node=0x6344c3d5fc08) at
> /pgpro/postgres/src/include/executor/executor.h:272
> #14 ExecutePlan (queryDesc=0x6344c3cd55d0, operation=CMD_SELECT,
> sendTuples=true, numberTuples=0, direction=<optimized out>,
> dest=0x6344c3d8b7b8) at /pgpro/postgres/src/backend/executor/execMain.c:1675
> #15 standard_ExecutorRun (queryDesc=0x6344c3cd55d0, direction=<optimized
> out>, count=0) at /pgpro/postgres/src/backend/executor/execMain.c:364
> #16 0x00006344a8f11478 in PortalRunSelect
> (portal=portal@entry=0x6344c3d018b0, forward=forward@entry=true, count=0,
> count@entry=9223372036854775807, dest=dest@entry=0x6344c3d8b7b8)
> at /pgpro/postgres/src/backend/tcop/pquery.c:920
> #17 0x00006344a8f12c5e in PortalRun (portal=portal@entry=0x6344c3d018b0,
> count=count@entry=9223372036854775807, isTopLevel=isTopLevel@entry=true,
> dest=dest@entry=0x6344c3d8b7b8, altdest=altdest@entry=0x6344c3d8b7b8,
> qc=qc@entry=0x7ffcb3621640) at
> /pgpro/postgres/src/backend/tcop/pquery.c:764
> #18 0x00006344a8f0e95e in exec_simple_query (query_string=0x6344c3c80f60
> "SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL,
> 'include-xids', '0', 'skip-empty-xacts', '1');")
> at /pgpro/postgres/src/backend/tcop/postgres.c:1271
> #19 0x00006344a8f103e5 in PostgresMain (dbname=<optimized out>,
> username=<optimized out>) at
> /pgpro/postgres/src/backend/tcop/postgres.c:4691
> #20 0x00006344a8f0aaa3 in BackendMain (startup_data=<optimized out>,
> startup_data_len=<optimized out>) at
> /pgpro/postgres/src/backend/tcop/backend_startup.c:107
> #21 0x00006344a8e6184f in postmaster_child_launch (child_type=<optimized
> out>, child_slot=1, startup_data=startup_data@entry=0x7ffcb3621aec "",
> startup_data_len=startup_data_len@entry=4,
> client_sock=client_sock@entry=0x7ffcb3621af0) at
> /pgpro/postgres/src/backend/postmaster/launch_backend.c:274
> #22 0x00006344a8e653ef in BackendStartup (client_sock=0x7ffcb3621af0) at
> /pgpro/postgres/src/backend/postmaster/postmaster.c:3519
> #23 ServerLoop () at
> /pgpro/postgres/src/backend/postmaster/postmaster.c:1688
> #24 0x00006344a8e66cbf in PostmasterMain (argc=argc@entry=3,
> argv=argv@entry=0x6344c3c7b440) at
> /pgpro/postgres/src/backend/postmaster/postmaster.c:1386
> #25 0x00006344a8b3b35e in main (argc=3, argv=0x6344c3c7b440) at
> /pgpro/postgres/src/backend/main/main.c:230
>
Thank you for the report!
> first bad commit 072ee847ad4c3fb52
Right. I've confirmed that this issue can happen PG18 or newer. In
ReorderBufferPrepare() it sends a prepare message if
ReorderBufferReplay() didn't send it, but ISTM missed the case where
the transaction is empty. I'll investigate the issue further and work
on it.
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 10+ messages in thread
* Re: BUG #19556: Segmentation fault in test_decoding
@ 2026-07-22 02:51 Masahiko Sawada <sawada.mshk@gmail.com>
parent: Masahiko Sawada <sawada.mshk@gmail.com>
0 siblings, 1 reply; 10+ messages in thread
From: Masahiko Sawada @ 2026-07-22 02:51 UTC (permalink / raw)
To: a.kozhemyakin@postgrespro.ru; pgsql-bugs@lists.postgresql.org; +Cc: Amit Kapila <amit.kapila16@gmail.com>
(CC'ing Amit as commit a271a1b50e might be related)
On Sun, Jul 19, 2026 at 12:30 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
>
> On Fri, Jul 17, 2026 at 9:30 AM PG Bug reporting form
> <noreply@postgresql.org> wrote:
> >
> > The following bug has been logged on the website:
> >
> > Bug reference: 19556
> > Logged by: Alexander Kozhemyakin
> > Email address: a.kozhemyakin@postgrespro.ru
> > PostgreSQL version: 19beta2
> > Operating system: ubuntu 26.04
> > Description:
> >
> > Hi,
> >
> > The following script causes the server to crash with a Segmentation fault.
> >
> > initdb -D data
> > echo "
> > max_prepared_transactions = '15'
> > wal_level = logical " >> data/postgresql.auto.conf
> > pg_ctl -D data -l logfile start
> >
> > psql <<EOF
> > SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot',
> > 'test_decoding', false, true);
> > CREATE TABLE test (id int PRIMARY KEY, data text);
> > INSERT INTO test VALUES (1, 'test data');
> > BEGIN;
> > SELECT * FROM test WHERE id = 1 FOR SHARE;
> > PREPARE TRANSACTION 'p1';
> > SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL,
> > 'include-xids', '0', 'skip-empty-xacts', '1');
> > EOF
> >
> >
> > backtrace
> > Program terminated with signal SIGSEGV, Segmentation fault.
> > #0 0x0000749f6ee23f54 in pg_decode_prepare_txn (ctx=0x6344c3d7f3f0,
> > txn=0x6344c3d7d320, prepare_lsn=24628632) at
> > /pgpro/postgres/contrib/test_decoding/test_decoding.c:378
> > 378 if (data->skip_empty_xacts && !txndata->xact_wrote_changes)
> > (gdb) bt
> > #0 0x0000749f6ee23f54 in pg_decode_prepare_txn (ctx=0x6344c3d7f3f0,
> > txn=0x6344c3d7d320, prepare_lsn=24628632) at
> > /pgpro/postgres/contrib/test_decoding/test_decoding.c:378
> > #1 0x00006344a8e8293c in prepare_cb_wrapper (cache=<optimized out>,
> > txn=<optimized out>, prepare_lsn=<optimized out>) at
> > /pgpro/postgres/src/backend/replication/logical/logical.c:985
> > #2 0x00006344a8e90c24 in ReorderBufferPrepare (rb=0x6344c3cd9e10,
> > xid=xid@entry=745, gid=gid@entry=0x7ffcb3620ef4 "p1") at
> > /pgpro/postgres/src/backend/replication/logical/reorderbuffer.c:2934
> > #3 0x00006344a8e7fa26 in DecodePrepare (ctx=0x6344c3d7f3f0,
> > buf=0x7ffcb3621030, parsed=0x7ffcb3620ea0) at
> > /pgpro/postgres/src/backend/replication/logical/decode.c:815
> > #4 xact_decode (ctx=0x6344c3d7f3f0, buf=0x7ffcb3621030) at
> > /pgpro/postgres/src/backend/replication/logical/decode.c:347
> > #5 0x00006344a8e7f223 in LogicalDecodingProcessRecord
> > (ctx=ctx@entry=0x6344c3d7f3f0, record=0x6344c3d7f788) at
> > /pgpro/postgres/src/backend/replication/logical/decode.c:116
> > #6 0x00006344a8e8577b in pg_logical_slot_get_changes_guts
> > (fcinfo=0x6344c3d83410, confirm=confirm@entry=true,
> > binary=binary@entry=false) at
> > /pgpro/postgres/src/backend/replication/logical/logicalfuncs.c:266
> > #7 0x00006344a8e858f4 in pg_logical_slot_get_changes (fcinfo=<optimized
> > out>) at /pgpro/postgres/src/backend/replication/logical/logicalfuncs.c:333
> > #8 0x00006344a8d3666d in ExecMakeTableFunctionResult
> > (setexpr=0x6344c3d5ffc8, econtext=0x6344c3d5fe18, argContext=<optimized
> > out>, expectedDesc=0x6344c3d49868, randomAccess=false)
> > at /pgpro/postgres/src/backend/executor/execSRF.c:234
> > #9 0x00006344a8d4a14b in FunctionNext (node=node@entry=0x6344c3d5fc08) at
> > /pgpro/postgres/src/backend/executor/nodeFunctionscan.c:94
> > #10 0x00006344a8d36f4c in ExecScanFetch (node=<optimized out>,
> > epqstate=<optimized out>, accessMtd=<optimized out>, recheckMtd=<optimized
> > out>) at /pgpro/postgres/src/include/executor/execScan.h:126
> > #11 ExecScanExtended (node=<optimized out>, accessMtd=0x6344a8d49e10
> > <FunctionNext>, recheckMtd=0x6344a8d49e00 <FunctionRecheck>, epqstate=0x0,
> > qual=0x0, projInfo=0x6344c3d49ea8)
> > at /pgpro/postgres/src/include/executor/execScan.h:187
> > #12 ExecScan (node=0x6344c3d5fc08, accessMtd=0x6344a8d49e10 <FunctionNext>,
> > recheckMtd=0x6344a8d49e00 <FunctionRecheck>) at
> > /pgpro/postgres/src/backend/executor/execScan.c:59
> > #13 0x00006344a8d2c17b in ExecProcNode (node=0x6344c3d5fc08) at
> > /pgpro/postgres/src/include/executor/executor.h:272
> > #14 ExecutePlan (queryDesc=0x6344c3cd55d0, operation=CMD_SELECT,
> > sendTuples=true, numberTuples=0, direction=<optimized out>,
> > dest=0x6344c3d8b7b8) at /pgpro/postgres/src/backend/executor/execMain.c:1675
> > #15 standard_ExecutorRun (queryDesc=0x6344c3cd55d0, direction=<optimized
> > out>, count=0) at /pgpro/postgres/src/backend/executor/execMain.c:364
> > #16 0x00006344a8f11478 in PortalRunSelect
> > (portal=portal@entry=0x6344c3d018b0, forward=forward@entry=true, count=0,
> > count@entry=9223372036854775807, dest=dest@entry=0x6344c3d8b7b8)
> > at /pgpro/postgres/src/backend/tcop/pquery.c:920
> > #17 0x00006344a8f12c5e in PortalRun (portal=portal@entry=0x6344c3d018b0,
> > count=count@entry=9223372036854775807, isTopLevel=isTopLevel@entry=true,
> > dest=dest@entry=0x6344c3d8b7b8, altdest=altdest@entry=0x6344c3d8b7b8,
> > qc=qc@entry=0x7ffcb3621640) at
> > /pgpro/postgres/src/backend/tcop/pquery.c:764
> > #18 0x00006344a8f0e95e in exec_simple_query (query_string=0x6344c3c80f60
> > "SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL,
> > 'include-xids', '0', 'skip-empty-xacts', '1');")
> > at /pgpro/postgres/src/backend/tcop/postgres.c:1271
> > #19 0x00006344a8f103e5 in PostgresMain (dbname=<optimized out>,
> > username=<optimized out>) at
> > /pgpro/postgres/src/backend/tcop/postgres.c:4691
> > #20 0x00006344a8f0aaa3 in BackendMain (startup_data=<optimized out>,
> > startup_data_len=<optimized out>) at
> > /pgpro/postgres/src/backend/tcop/backend_startup.c:107
> > #21 0x00006344a8e6184f in postmaster_child_launch (child_type=<optimized
> > out>, child_slot=1, startup_data=startup_data@entry=0x7ffcb3621aec "",
> > startup_data_len=startup_data_len@entry=4,
> > client_sock=client_sock@entry=0x7ffcb3621af0) at
> > /pgpro/postgres/src/backend/postmaster/launch_backend.c:274
> > #22 0x00006344a8e653ef in BackendStartup (client_sock=0x7ffcb3621af0) at
> > /pgpro/postgres/src/backend/postmaster/postmaster.c:3519
> > #23 ServerLoop () at
> > /pgpro/postgres/src/backend/postmaster/postmaster.c:1688
> > #24 0x00006344a8e66cbf in PostmasterMain (argc=argc@entry=3,
> > argv=argv@entry=0x6344c3c7b440) at
> > /pgpro/postgres/src/backend/postmaster/postmaster.c:1386
> > #25 0x00006344a8b3b35e in main (argc=3, argv=0x6344c3c7b440) at
> > /pgpro/postgres/src/backend/main/main.c:230
> >
>
> Thank you for the report!
>
> > first bad commit 072ee847ad4c3fb52
>
> Right. I've confirmed that this issue can happen PG18 or newer. In
> ReorderBufferPrepare() it sends a prepare message if
> ReorderBufferReplay() didn't send it, but ISTM missed the case where
> the transaction is empty. I'll investigate the issue further and work
> on it.
>
While researching this bug, I found another one that is related and
probably should be fixed first: even in PG17 and earlier, logical
decoding calls the commit_prepared callback without first calling the
prepare callback:
BEGIN;
SELECT * FROM test WHERE id = 1 FOR SHARE;
PREPARE TRANSACTION 'p1';
COMMIT PREPARED 'p1';
In logical replication, the subscriber ends up with an error because
the prepared transaction doesn't exist on it.
In summary, with the above scenario, logical decoding calls:
- the prepare and commit_prepared callbacks (PG18+)
- the commit_prepared callback (PG17-)
Neither is correct.
I think we shouldn't call the commit_prepared callback for an empty
transaction (one that has no base snapshot), so in this case we
shouldn't call any of the begin_prepare, prepare, or commit_prepared
callbacks. Thoughts?
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 10+ messages in thread
* Re: BUG #19556: Segmentation fault in test_decoding
@ 2026-07-22 06:07 Amit Kapila <amit.kapila16@gmail.com>
parent: Masahiko Sawada <sawada.mshk@gmail.com>
0 siblings, 1 reply; 10+ messages in thread
From: Amit Kapila @ 2026-07-22 06:07 UTC (permalink / raw)
To: Masahiko Sawada <sawada.mshk@gmail.com>; +Cc: a.kozhemyakin@postgrespro.ru; pgsql-bugs@lists.postgresql.org
On Wed, Jul 22, 2026 at 8:22 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
>
> While researching this bug, I found another one that is related and
> probably should be fixed first: even in PG17 and earlier, logical
> decoding calls the commit_prepared callback without first calling the
> prepare callback:
>
> BEGIN;
> SELECT * FROM test WHERE id = 1 FOR SHARE;
> PREPARE TRANSACTION 'p1';
> COMMIT PREPARED 'p1';
>
> In logical replication, the subscriber ends up with an error because
> the prepared transaction doesn't exist on it.
>
> In summary, with the above scenario, logical decoding calls:
>
> - the prepare and commit_prepared callbacks (PG18+)
> - the commit_prepared callback (PG17-)
>
> Neither is correct.
>
> I think we shouldn't call the commit_prepared callback for an empty
> transaction (one that has no base snapshot), so in this case we
> shouldn't call any of the begin_prepare, prepare, or commit_prepared
> callbacks. Thoughts?
>
I agree that when base_snapshot is not set, we shouldn't send these
transaction commands. I checked HEAD and it seems below change in
commit 072ee847ad lead to sending prepare:
- if (txn->concurrent_abort && !rbtxn_is_streamed(txn))
+ if (!rbtxn_sent_prepare(txn))
+ {
rb->prepare(rb, txn, txn->final_lsn);
+ txn->txn_flags |= RBTXN_SENT_PREPARE;
+ }
Why did we remove the check of the aborted xact?
I'll check PG17 and share my findings with you.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 10+ messages in thread
* Re: BUG #19556: Segmentation fault in test_decoding
@ 2026-07-22 09:13 Amit Kapila <amit.kapila16@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 10+ messages in thread
From: Amit Kapila @ 2026-07-22 09:13 UTC (permalink / raw)
To: Masahiko Sawada <sawada.mshk@gmail.com>; +Cc: a.kozhemyakin@postgrespro.ru; pgsql-bugs@lists.postgresql.org
On Wed, Jul 22, 2026 at 11:37 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Wed, Jul 22, 2026 at 8:22 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> >
> > While researching this bug, I found another one that is related and
> > probably should be fixed first: even in PG17 and earlier, logical
> > decoding calls the commit_prepared callback without first calling the
> > prepare callback:
> >
> > BEGIN;
> > SELECT * FROM test WHERE id = 1 FOR SHARE;
> > PREPARE TRANSACTION 'p1';
> > COMMIT PREPARED 'p1';
> >
> > In logical replication, the subscriber ends up with an error because
> > the prepared transaction doesn't exist on it.
> >
> > In summary, with the above scenario, logical decoding calls:
> >
> > - the prepare and commit_prepared callbacks (PG18+)
> > - the commit_prepared callback (PG17-)
> >
> > Neither is correct.
> >
> > I think we shouldn't call the commit_prepared callback for an empty
> > transaction (one that has no base snapshot), so in this case we
> > shouldn't call any of the begin_prepare, prepare, or commit_prepared
> > callbacks. Thoughts?
> >
>
> I agree that when base_snapshot is not set, we shouldn't send these
> transaction commands. I checked HEAD and it seems below change in
> commit 072ee847ad lead to sending prepare:
> - if (txn->concurrent_abort && !rbtxn_is_streamed(txn))
> + if (!rbtxn_sent_prepare(txn))
> + {
> rb->prepare(rb, txn, txn->final_lsn);
> + txn->txn_flags |= RBTXN_SENT_PREPARE;
> + }
>
> Why did we remove the check of the aborted xact?
>
> I'll check PG17 and share my findings with you.
>
For PG17 and before, I think we can skip replaying commit if
base_snapshot is not set similar to ReorderBufferReplay(). See
attached. The other possibility is to update ReorderBufferReplay() to
retrun a special value so that callers can skip sending commit or
prepare.
--
With Regards,
Amit Kapila.
Attachments:
[application/octet-stream] fix_commit_prepare_1.patch (1018B, ../../CAA4eK1JEmsFkKpf5hNKTy5sA4yFqOpCNA2PmrKAb7n2b=o_U7w@mail.gmail.com/2-fix_commit_prepare_1.patch)
download | inline diff:
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index 216fdde0868..bd8dae02ffb 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -2939,6 +2939,24 @@ ReorderBufferFinishPrepared(ReorderBuffer *rb, TransactionId xid,
txn->origin_id = origin_id;
txn->origin_lsn = origin_lsn;
+ /*
+ * If this transaction has no snapshot, it didn't make any changes to the
+ * database, so there's nothing to decode. Note that
+ * ReorderBufferCommitChild will have transferred any snapshots from
+ * subtransactions if there were any.
+ */
+ if (txn->base_snapshot == NULL)
+ {
+ Assert(txn->ninvalidations == 0);
+
+ /*
+ * Removing this txn before a commit might result in the computation
+ * of an incorrect restart_lsn. See SnapBuildProcessRunningXacts.
+ */
+ ReorderBufferCleanupTXN(rb, txn);
+ return;
+ }
+
if (is_commit)
rb->commit_prepared(rb, txn, commit_lsn);
else
^ permalink raw reply [nested|flat] 10+ messages in thread
* Re: BUG #19556: Segmentation fault in test_decoding
@ 2026-07-23 02:16 Masahiko Sawada <sawada.mshk@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 10+ messages in thread
From: Masahiko Sawada @ 2026-07-23 02:16 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: a.kozhemyakin@postgrespro.ru; pgsql-bugs@lists.postgresql.org
On Wed, Jul 22, 2026 at 2:13 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Wed, Jul 22, 2026 at 11:37 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Wed, Jul 22, 2026 at 8:22 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> > >
> > > While researching this bug, I found another one that is related and
> > > probably should be fixed first: even in PG17 and earlier, logical
> > > decoding calls the commit_prepared callback without first calling the
> > > prepare callback:
> > >
> > > BEGIN;
> > > SELECT * FROM test WHERE id = 1 FOR SHARE;
> > > PREPARE TRANSACTION 'p1';
> > > COMMIT PREPARED 'p1';
> > >
> > > In logical replication, the subscriber ends up with an error because
> > > the prepared transaction doesn't exist on it.
> > >
> > > In summary, with the above scenario, logical decoding calls:
> > >
> > > - the prepare and commit_prepared callbacks (PG18+)
> > > - the commit_prepared callback (PG17-)
> > >
> > > Neither is correct.
> > >
> > > I think we shouldn't call the commit_prepared callback for an empty
> > > transaction (one that has no base snapshot), so in this case we
> > > shouldn't call any of the begin_prepare, prepare, or commit_prepared
> > > callbacks. Thoughts?
> > >
> >
> > I agree that when base_snapshot is not set, we shouldn't send these
> > transaction commands. I checked HEAD and it seems below change in
> > commit 072ee847ad lead to sending prepare:
> > - if (txn->concurrent_abort && !rbtxn_is_streamed(txn))
> > + if (!rbtxn_sent_prepare(txn))
> > + {
> > rb->prepare(rb, txn, txn->final_lsn);
> > + txn->txn_flags |= RBTXN_SENT_PREPARE;
> > + }
> >
> > Why did we remove the check of the aborted xact?
Commit 072ee847 replaced txn->concurrent_abort flag with
RBTXN_IS_ABORTED and this flag was used to check if we have sent a
prepare message for the transaction. We thought it can be achieved by
directly checking the RBTXN_SENT_PREPARE instead.
> >
> > I'll check PG17 and share my findings with you.
> >
>
> For PG17 and before, I think we can skip replaying commit if
> base_snapshot is not set similar to ReorderBufferReplay(). See
> attached. The other possibility is to update ReorderBufferReplay() to
> retrun a special value so that callers can skip sending commit or
> prepare.
Thank you for the patch. I like the approach the proposed patch does.
I've made a patch for PG18+ that fixes both issues with regression
tests. For PG17 or earlier, the patch doesn't need the changes in
ReorderBufferPrepare() but has the same regression tests.
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
Attachments:
[text/x-patch] v1-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch (8.5K, ../../CAD21AoBKC=HqDsPtr0wbJR-h7VfBVkPdjDXF1P+-9P9xPHjFGA@mail.gmail.com/2-v1-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch)
download | inline diff:
From f322694c83281e2f1f2ed685fcecfd1c3d08b516 Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Wed, 22 Jul 2026 12:22:34 -0700
Subject: [PATCH v1] Fix logical decoding of empty prepared transactions.
A two-phase transaction that is assigned an XID but produces no change
to be decoded -- for example, one that only acquires row locks via
SELECT ... FOR SHARE -- has no base snapshot in the reorder
buffer. ReorderBufferReplay() already skips such a transaction at
PREPARE time and never invokes the begin_prepare/change/prepare
callbacks for it, but ReorderBufferFinishPrepared() still called the
commit_prepared (or rollback_prepared) callback. As a result a
spurious COMMIT/ROLLBACK PREPARED was sent to the output plugin with
no preceding PREPARE. For the built-in subscriber this breaks
replication (the apply worker fails to find the prepared transaction),
and test_decoding could even crash.
Fix this by detecting an empty transaction (base_snapshot == NULL) in
ReorderBufferFinishPrepared() and cleaning it up without invoking the
commit/rollback prepared callbacks, mirroring the existing empty
transaction handling in ReorderBufferReplay().
On master and PG18, commit 072ee847ad4 changed ReorderBufferPrepare()
to send the prepare whenever it had not already been
sent (!rbtxn_sent_prepare()), which also fires for empty transactions
and emits a spurious PREPARE. On those branches ReorderBufferPrepare()
is therefore additionally guarded with base_snapshot != NULL. This
guard, and the Assert(!rbtxn_sent_prepare()) added in
ReorderBufferFinishPrepared(), are not necessary on 17 and earlier:
there ReorderBufferPrepare() only sends a prepare for
concurrently-aborted transactions (which never applies to an empty
transaction) and the RBTXN_SENT_PREPARE flag does not exist.
Back-patch to 14, where decoding of two-phase transactions was introduced.
Bug: 19556
Reported-by: Alexander Kozhemyakin<a.kozhemyakin@postgrespro.ru>
Author:
Reviewed-by:
Discussion: https://postgr.es/m/19556-daa6d7ea65054d48@postgresql.org
Backpatch-through: 14
---
contrib/test_decoding/expected/twophase.out | 25 ++++++++++++
contrib/test_decoding/sql/twophase.sql | 12 ++++++
.../replication/logical/reorderbuffer.c | 27 +++++++++++--
src/test/subscription/t/021_twophase.pl | 40 +++++++++++++++++++
4 files changed, 101 insertions(+), 3 deletions(-)
diff --git a/contrib/test_decoding/expected/twophase.out b/contrib/test_decoding/expected/twophase.out
index 08a7c56b5df..ea3c51f8215 100644
--- a/contrib/test_decoding/expected/twophase.out
+++ b/contrib/test_decoding/expected/twophase.out
@@ -227,6 +227,31 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
COMMIT PREPARED 'test_toast_table_access'
(1 row)
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/contrib/test_decoding/sql/twophase.sql b/contrib/test_decoding/sql/twophase.sql
index 4b9ef0c0c44..834e5282c30 100644
--- a/contrib/test_decoding/sql/twophase.sql
+++ b/contrib/test_decoding/sql/twophase.sql
@@ -125,6 +125,18 @@ COMMIT PREPARED 'test_toast_table_access';
-- consume commit prepared
SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index 6df6166d8a7..79a28b35322 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -2979,11 +2979,13 @@ ReorderBufferPrepare(ReorderBuffer *rb, TransactionId xid,
txn->prepare_time, txn->origin_id, txn->origin_lsn);
/*
- * Send a prepare if not already done so. This might occur if we have
- * detected a concurrent abort while replaying the non-streaming
+ * Send a prepare if not already done so, but only if this transaction made
+ * changes to the database (i.e. has a base snapshot); there is nothing to
+ * prepare for an empty transaction. The "not already sent" case can occur
+ * if we have detected a concurrent abort while replaying the non-streaming
* transaction.
*/
- if (!rbtxn_sent_prepare(txn))
+ if (!rbtxn_sent_prepare(txn) && txn->base_snapshot != NULL)
{
rb->prepare(rb, txn, txn->final_lsn);
txn->txn_flags |= RBTXN_SENT_PREPARE;
@@ -3050,6 +3052,25 @@ ReorderBufferFinishPrepared(ReorderBuffer *rb, TransactionId xid,
txn->prepare_time, txn->origin_id, txn->origin_lsn);
}
+ /*
+ * If this transaction has no snapshot, it didn't make any changes to the
+ * database, so there's nothing to decode. Note that
+ * ReorderBufferCommitChild will have transferred any snapshots from
+ * subtransactions if there were any.
+ */
+ if (txn->base_snapshot == NULL)
+ {
+ Assert(txn->ninvalidations == 0);
+ Assert(!rbtxn_sent_prepare(txn));
+
+ /*
+ * Removing this txn before a commit might result in the computation
+ * of an incorrect restart_lsn. See SnapBuildProcessRunningXacts.
+ */
+ ReorderBufferCleanupTXN(rb, txn);
+ return;
+ }
+
txn->final_lsn = commit_lsn;
txn->end_lsn = end_lsn;
txn->commit_time = commit_time;
diff --git a/src/test/subscription/t/021_twophase.pl b/src/test/subscription/t/021_twophase.pl
index 4404d7b5449..c1e8e069628 100644
--- a/src/test/subscription/t/021_twophase.pl
+++ b/src/test/subscription/t/021_twophase.pl
@@ -309,6 +309,46 @@ $result = $node_subscriber->safe_psql('postgres',
"SELECT count(*) FROM pg_prepared_xacts;");
is($result, qq(0), 'transaction is aborted on subscriber');
+###############################
+# Test that an empty prepared transaction is not replicated.
+#
+# A transaction that is assigned an XID but makes no change decoded by logical
+# replication (here, via a row lock) must not be sent to the subscriber.
+# Otherwise the subscriber would receive a PREPARE with no preceding BEGIN
+# PREPARE and error out, breaking replication.
+###############################
+
+# An empty prepared transaction that is committed.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ COMMIT PREPARED 'test_empty_prepared';");
+
+# An empty prepared transaction that is rolled back.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ ROLLBACK PREPARED 'test_empty_prepared';");
+
+# A subsequent normal change must still replicate. Reaching catchup confirms
+# the apply worker was not stalled by the empty prepared transactions above.
+$node_publisher->safe_psql('postgres', "INSERT INTO tab_full VALUES (31);");
+$node_publisher->wait_for_catchup($appname);
+
+# The empty transactions must not have been prepared on the subscriber.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM pg_prepared_xacts;");
+is($result, qq(0), 'empty prepared transaction is not replicated');
+
+# The subsequent change is visible, so replication is healthy.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM tab_full WHERE a = 31;");
+is($result, qq(1), 'replication continues after an empty prepared transaction');
+
###############################
# copy_data=false and two_phase
###############################
--
2.55.0
^ permalink raw reply [nested|flat] 10+ messages in thread
* Re: BUG #19556: Segmentation fault in test_decoding
@ 2026-07-23 06:50 Amit Kapila <amit.kapila16@gmail.com>
parent: Masahiko Sawada <sawada.mshk@gmail.com>
0 siblings, 1 reply; 10+ messages in thread
From: Amit Kapila @ 2026-07-23 06:50 UTC (permalink / raw)
To: Masahiko Sawada <sawada.mshk@gmail.com>; +Cc: a.kozhemyakin@postgrespro.ru; pgsql-bugs@lists.postgresql.org
On Thu, Jul 23, 2026 at 7:47 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
>
> Thank you for the patch. I like the approach the proposed patch does.
> I've made a patch for PG18+ that fixes both issues with regression
> tests. For PG17 or earlier, the patch doesn't need the changes in
> ReorderBufferPrepare() but has the same regression tests.
>
The patch LGTM. One minor point: Shall we retain the earlier comments
(We send the prepare for the concurrently aborted xacts so that later
when rollback prepared is decoded and sent, the downstream should be
able to rollback such a xact. See comments atop DecodePrepare.)? This
makes it clear why we are sending prepare for concurrent abort cases.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 10+ messages in thread
* Re: BUG #19556: Segmentation fault in test_decoding
@ 2026-07-23 18:24 Masahiko Sawada <sawada.mshk@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 10+ messages in thread
From: Masahiko Sawada @ 2026-07-23 18:24 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: a.kozhemyakin@postgrespro.ru; pgsql-bugs@lists.postgresql.org
On Wed, Jul 22, 2026 at 11:50 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Thu, Jul 23, 2026 at 7:47 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> >
> > Thank you for the patch. I like the approach the proposed patch does.
> > I've made a patch for PG18+ that fixes both issues with regression
> > tests. For PG17 or earlier, the patch doesn't need the changes in
> > ReorderBufferPrepare() but has the same regression tests.
> >
>
> The patch LGTM. One minor point: Shall we retain the earlier comments
> (We send the prepare for the concurrently aborted xacts so that later
> when rollback prepared is decoded and sent, the downstream should be
> able to rollback such a xact. See comments atop DecodePrepare.)? This
> makes it clear why we are sending prepare for concurrent abort cases.
Thank you for reviewing the 0001 patch! Yes, I agree to retain the
comment. I'll update the patch and push it early next week.
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 10+ messages in thread
* Re: BUG #19556: Segmentation fault in test_decoding
@ 2026-07-27 21:28 Masahiko Sawada <sawada.mshk@gmail.com>
parent: Masahiko Sawada <sawada.mshk@gmail.com>
0 siblings, 1 reply; 10+ messages in thread
From: Masahiko Sawada @ 2026-07-27 21:28 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: a.kozhemyakin@postgrespro.ru; pgsql-bugs@lists.postgresql.org
On Thu, Jul 23, 2026 at 11:24 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
>
> On Wed, Jul 22, 2026 at 11:50 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Thu, Jul 23, 2026 at 7:47 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> > >
> > > Thank you for the patch. I like the approach the proposed patch does.
> > > I've made a patch for PG18+ that fixes both issues with regression
> > > tests. For PG17 or earlier, the patch doesn't need the changes in
> > > ReorderBufferPrepare() but has the same regression tests.
> > >
> >
> > The patch LGTM. One minor point: Shall we retain the earlier comments
> > (We send the prepare for the concurrently aborted xacts so that later
> > when rollback prepared is decoded and sent, the downstream should be
> > able to rollback such a xact. See comments atop DecodePrepare.)? This
> > makes it clear why we are sending prepare for concurrent abort cases.
>
> Thank you for reviewing the 0001 patch! Yes, I agree to retain the
> comment. I'll update the patch and push it early next week.
>
I prepared the patches for all branches. I'm going to push them
tomorrow if there is no further comment.
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
Attachments:
[text/x-patch] REL14_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch (5.6K, ../../CAD21AoAshLy0Ut08TkaGVdjSHWSA4Y5twcLqy8+jfLPfRYxWQw@mail.gmail.com/2-REL14_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch)
download | inline diff:
From f77e6ba90d687c38532f2f3703d191e067f7e577 Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Wed, 22 Jul 2026 12:22:34 -0700
Subject: [PATCH v2] Fix logical decoding of empty prepared transactions.
A two-phase transaction that is assigned an XID but produces no change
to be decoded -- for example, one that only acquires row locks via
SELECT ... FOR SHARE -- has no base snapshot in the reorder
buffer. ReorderBufferReplay() already skips such a transaction at
PREPARE time and never invokes the begin_prepare/change/prepare
callbacks for it, but ReorderBufferFinishPrepared() still called the
commit_prepared (or rollback_prepared) callback. As a result a
spurious COMMIT/ROLLBACK PREPARED was sent to the output plugin with
no preceding PREPARE. For the built-in subscriber this breaks
replication (the apply worker fails to find the prepared transaction),
and test_decoding could even crash.
Fix this by detecting an empty transaction (base_snapshot == NULL) in
ReorderBufferFinishPrepared() and cleaning it up without invoking the
commit/rollback prepared callbacks, mirroring the existing empty
transaction handling in ReorderBufferReplay().
On master and PG18, commit 072ee847ad4 changed ReorderBufferPrepare()
to send the prepare whenever it had not already been
sent, which also fires for empty transactions and emits a spurious
PREPARE. On those branches ReorderBufferPrepare() is therefore
additionally guarded with base_snapshot != NULL. This guard and the
Assert(!rbtxn_sent_prepare()) added in ReorderBufferFinishPrepared(),
are not necessary on 17 and earlier: there ReorderBufferPrepare() only
sends a prepare for concurrently-aborted transactions (which never
applies to an empty transaction) and the RBTXN_SENT_PREPARE flag does
not exist.
Back-patch to 14, where decoding of two-phase transactions was introduced.
Bug: 19556
Reported-by: Alexander Kozhemyakin<a.kozhemyakin@postgrespro.ru>
Reviewed-by: Amit Kapila <amit.kapila16@gmail.com>
Discussion: https://postgr.es/m/19556-daa6d7ea65054d48@postgresql.org
Backpatch-through: 14
(cherry picked from commit 7090da44930474fe39f0976b343ab02874ef6db8)
---
contrib/test_decoding/expected/twophase.out | 25 +++++++++++++++++++
contrib/test_decoding/sql/twophase.sql | 12 +++++++++
.../replication/logical/reorderbuffer.c | 18 +++++++++++++
3 files changed, 55 insertions(+)
diff --git a/contrib/test_decoding/expected/twophase.out b/contrib/test_decoding/expected/twophase.out
index 12e2e3a0ffe..a572230c72d 100644
--- a/contrib/test_decoding/expected/twophase.out
+++ b/contrib/test_decoding/expected/twophase.out
@@ -224,6 +224,31 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
COMMIT PREPARED 'test_toast_table_access'
(1 row)
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/contrib/test_decoding/sql/twophase.sql b/contrib/test_decoding/sql/twophase.sql
index e3ea45539cc..44c3f7089ae 100644
--- a/contrib/test_decoding/sql/twophase.sql
+++ b/contrib/test_decoding/sql/twophase.sql
@@ -122,6 +122,18 @@ COMMIT PREPARED 'test_toast_table_access';
-- consume commit prepared
SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index bdcd428894f..e6e14c5fb02 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -2861,6 +2861,24 @@ ReorderBufferFinishPrepared(ReorderBuffer *rb, TransactionId xid,
txn->commit_time, txn->origin_id, txn->origin_lsn);
}
+ /*
+ * If this transaction has no snapshot, it didn't make any changes to the
+ * database, so there's nothing to decode. Note that
+ * ReorderBufferCommitChild will have transferred any snapshots from
+ * subtransactions if there were any.
+ */
+ if (txn->base_snapshot == NULL)
+ {
+ Assert(txn->ninvalidations == 0);
+
+ /*
+ * Removing this txn before a commit might result in the computation
+ * of an incorrect restart_lsn. See SnapBuildProcessRunningXacts.
+ */
+ ReorderBufferCleanupTXN(rb, txn);
+ return;
+ }
+
txn->final_lsn = commit_lsn;
txn->end_lsn = end_lsn;
txn->commit_time = commit_time;
--
2.54.0
[text/x-patch] REL15_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch (7.7K, ../../CAD21AoAshLy0Ut08TkaGVdjSHWSA4Y5twcLqy8+jfLPfRYxWQw@mail.gmail.com/3-REL15_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch)
download | inline diff:
From a46b6cb3c0f6c7c6cffc23286ba7035d57848d9c Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Wed, 22 Jul 2026 12:22:34 -0700
Subject: [PATCH v2] Fix logical decoding of empty prepared transactions.
A two-phase transaction that is assigned an XID but produces no change
to be decoded -- for example, one that only acquires row locks via
SELECT ... FOR SHARE -- has no base snapshot in the reorder
buffer. ReorderBufferReplay() already skips such a transaction at
PREPARE time and never invokes the begin_prepare/change/prepare
callbacks for it, but ReorderBufferFinishPrepared() still called the
commit_prepared (or rollback_prepared) callback. As a result a
spurious COMMIT/ROLLBACK PREPARED was sent to the output plugin with
no preceding PREPARE. For the built-in subscriber this breaks
replication (the apply worker fails to find the prepared transaction),
and test_decoding could even crash.
Fix this by detecting an empty transaction (base_snapshot == NULL) in
ReorderBufferFinishPrepared() and cleaning it up without invoking the
commit/rollback prepared callbacks, mirroring the existing empty
transaction handling in ReorderBufferReplay().
On master and PG18, commit 072ee847ad4 changed ReorderBufferPrepare()
to send the prepare whenever it had not already been
sent, which also fires for empty transactions and emits a spurious
PREPARE. On those branches ReorderBufferPrepare() is therefore
additionally guarded with base_snapshot != NULL. This guard and the
Assert(!rbtxn_sent_prepare()) added in ReorderBufferFinishPrepared(),
are not necessary on 17 and earlier: there ReorderBufferPrepare() only
sends a prepare for concurrently-aborted transactions (which never
applies to an empty transaction) and the RBTXN_SENT_PREPARE flag does
not exist.
Back-patch to 14, where decoding of two-phase transactions was introduced.
Bug: 19556
Reported-by: Alexander Kozhemyakin<a.kozhemyakin@postgrespro.ru>
Reviewed-by: Amit Kapila <amit.kapila16@gmail.com>
Discussion: https://postgr.es/m/19556-daa6d7ea65054d48@postgresql.org
Backpatch-through: 14
---
contrib/test_decoding/expected/twophase.out | 25 ++++++++++++
contrib/test_decoding/sql/twophase.sql | 12 ++++++
.../replication/logical/reorderbuffer.c | 18 +++++++++
src/test/subscription/t/021_twophase.pl | 40 +++++++++++++++++++
4 files changed, 95 insertions(+)
diff --git a/contrib/test_decoding/expected/twophase.out b/contrib/test_decoding/expected/twophase.out
index 22d128d431f..4d16d50560b 100644
--- a/contrib/test_decoding/expected/twophase.out
+++ b/contrib/test_decoding/expected/twophase.out
@@ -227,6 +227,31 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
COMMIT PREPARED 'test_toast_table_access'
(1 row)
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/contrib/test_decoding/sql/twophase.sql b/contrib/test_decoding/sql/twophase.sql
index 0ff6ede1f38..0a569ee531d 100644
--- a/contrib/test_decoding/sql/twophase.sql
+++ b/contrib/test_decoding/sql/twophase.sql
@@ -125,6 +125,18 @@ COMMIT PREPARED 'test_toast_table_access';
-- consume commit prepared
SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index 59efa73930f..4e57ab5dc58 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -2864,6 +2864,24 @@ ReorderBufferFinishPrepared(ReorderBuffer *rb, TransactionId xid,
txn->xact_time.prepare_time, txn->origin_id, txn->origin_lsn);
}
+ /*
+ * If this transaction has no snapshot, it didn't make any changes to the
+ * database, so there's nothing to decode. Note that
+ * ReorderBufferCommitChild will have transferred any snapshots from
+ * subtransactions if there were any.
+ */
+ if (txn->base_snapshot == NULL)
+ {
+ Assert(txn->ninvalidations == 0);
+
+ /*
+ * Removing this txn before a commit might result in the computation
+ * of an incorrect restart_lsn. See SnapBuildProcessRunningXacts.
+ */
+ ReorderBufferCleanupTXN(rb, txn);
+ return;
+ }
+
txn->final_lsn = commit_lsn;
txn->end_lsn = end_lsn;
txn->xact_time.commit_time = commit_time;
diff --git a/src/test/subscription/t/021_twophase.pl b/src/test/subscription/t/021_twophase.pl
index bd04a1f36a1..5e2ddd47fcf 100644
--- a/src/test/subscription/t/021_twophase.pl
+++ b/src/test/subscription/t/021_twophase.pl
@@ -297,6 +297,46 @@ $result = $node_subscriber->safe_psql('postgres',
"SELECT count(*) FROM pg_prepared_xacts;");
is($result, qq(0), 'transaction is aborted on subscriber');
+###############################
+# Test that an empty prepared transaction is not replicated.
+#
+# A transaction that is assigned an XID but makes no change decoded by logical
+# replication (here, via a row lock) must not be sent to the subscriber.
+# Otherwise the subscriber would receive a PREPARE with no preceding BEGIN
+# PREPARE and error out, breaking replication.
+###############################
+
+# An empty prepared transaction that is committed.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ COMMIT PREPARED 'test_empty_prepared';");
+
+# An empty prepared transaction that is rolled back.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ ROLLBACK PREPARED 'test_empty_prepared';");
+
+# A subsequent normal change must still replicate. Reaching catchup confirms
+# the apply worker was not stalled by the empty prepared transactions above.
+$node_publisher->safe_psql('postgres', "INSERT INTO tab_full VALUES (31);");
+$node_publisher->wait_for_catchup($appname);
+
+# The empty transactions must not have been prepared on the subscriber.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM pg_prepared_xacts;");
+is($result, qq(0), 'empty prepared transaction is not replicated');
+
+# The subsequent change is visible, so replication is healthy.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM tab_full WHERE a = 31;");
+is($result, qq(1), 'replication continues after an empty prepared transaction');
+
###############################
# copy_data=false and two_phase
###############################
--
2.54.0
[text/x-patch] REL18_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch (8.6K, ../../CAD21AoAshLy0Ut08TkaGVdjSHWSA4Y5twcLqy8+jfLPfRYxWQw@mail.gmail.com/4-REL18_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch)
download | inline diff:
From 07cb8939b2d27081921f9041e6df663f9555521b Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Wed, 22 Jul 2026 12:22:34 -0700
Subject: [PATCH v2] Fix logical decoding of empty prepared transactions.
A two-phase transaction that is assigned an XID but produces no change
to be decoded -- for example, one that only acquires row locks via
SELECT ... FOR SHARE -- has no base snapshot in the reorder
buffer. ReorderBufferReplay() already skips such a transaction at
PREPARE time and never invokes the begin_prepare/change/prepare
callbacks for it, but ReorderBufferFinishPrepared() still called the
commit_prepared (or rollback_prepared) callback. As a result a
spurious COMMIT/ROLLBACK PREPARED was sent to the output plugin with
no preceding PREPARE. For the built-in subscriber this breaks
replication (the apply worker fails to find the prepared transaction),
and test_decoding could even crash.
Fix this by detecting an empty transaction (base_snapshot == NULL) in
ReorderBufferFinishPrepared() and cleaning it up without invoking the
commit/rollback prepared callbacks, mirroring the existing empty
transaction handling in ReorderBufferReplay().
On master and PG18, commit 072ee847ad4 changed ReorderBufferPrepare()
to send the prepare whenever it had not already been
sent, which also fires for empty transactions and emits a spurious
PREPARE. On those branches ReorderBufferPrepare() is therefore
additionally guarded with base_snapshot != NULL. This guard and the
Assert(!rbtxn_sent_prepare()) added in ReorderBufferFinishPrepared(),
are not necessary on 17 and earlier: there ReorderBufferPrepare() only
sends a prepare for concurrently-aborted transactions (which never
applies to an empty transaction) and the RBTXN_SENT_PREPARE flag does
not exist.
Back-patch to 14, where decoding of two-phase transactions was introduced.
Bug: 19556
Reported-by: Alexander Kozhemyakin<a.kozhemyakin@postgrespro.ru>
Reviewed-by: Amit Kapila <amit.kapila16@gmail.com>
Discussion: https://postgr.es/m/19556-daa6d7ea65054d48@postgresql.org
Backpatch-through: 14
---
contrib/test_decoding/expected/twophase.out | 25 ++++++++++++
contrib/test_decoding/sql/twophase.sql | 12 ++++++
.../replication/logical/reorderbuffer.c | 28 +++++++++++--
src/test/subscription/t/021_twophase.pl | 40 +++++++++++++++++++
4 files changed, 102 insertions(+), 3 deletions(-)
diff --git a/contrib/test_decoding/expected/twophase.out b/contrib/test_decoding/expected/twophase.out
index 08a7c56b5df..ea3c51f8215 100644
--- a/contrib/test_decoding/expected/twophase.out
+++ b/contrib/test_decoding/expected/twophase.out
@@ -227,6 +227,31 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
COMMIT PREPARED 'test_toast_table_access'
(1 row)
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/contrib/test_decoding/sql/twophase.sql b/contrib/test_decoding/sql/twophase.sql
index 4b9ef0c0c44..834e5282c30 100644
--- a/contrib/test_decoding/sql/twophase.sql
+++ b/contrib/test_decoding/sql/twophase.sql
@@ -125,6 +125,18 @@ COMMIT PREPARED 'test_toast_table_access';
-- consume commit prepared
SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index ead803171e8..9a56acf3a3c 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -2971,11 +2971,14 @@ ReorderBufferPrepare(ReorderBuffer *rb, TransactionId xid,
txn->xact_time.prepare_time, txn->origin_id, txn->origin_lsn);
/*
- * Send a prepare if not already done so. This might occur if we have
- * detected a concurrent abort while replaying the non-streaming
+ * Send a prepare if not already done so, but only if this transaction
+ * made changes to the database (i.e. has a base snapshot), so that later
+ * when rollback prepared is decoded and sent, the downstream should be
+ * able to rollback such a xact. The "not already sent" case can occur if
+ * we have detected a concurrent abort while replaying the non-streaming
* transaction.
*/
- if (!rbtxn_sent_prepare(txn))
+ if (!rbtxn_sent_prepare(txn) && txn->base_snapshot != NULL)
{
rb->prepare(rb, txn, txn->final_lsn);
txn->txn_flags |= RBTXN_SENT_PREPARE;
@@ -3042,6 +3045,25 @@ ReorderBufferFinishPrepared(ReorderBuffer *rb, TransactionId xid,
txn->xact_time.prepare_time, txn->origin_id, txn->origin_lsn);
}
+ /*
+ * If this transaction has no snapshot, it didn't make any changes to the
+ * database, so there's nothing to decode. Note that
+ * ReorderBufferCommitChild will have transferred any snapshots from
+ * subtransactions if there were any.
+ */
+ if (txn->base_snapshot == NULL)
+ {
+ Assert(txn->ninvalidations == 0);
+ Assert(!rbtxn_sent_prepare(txn));
+
+ /*
+ * Removing this txn before a commit might result in the computation
+ * of an incorrect restart_lsn. See SnapBuildProcessRunningXacts.
+ */
+ ReorderBufferCleanupTXN(rb, txn);
+ return;
+ }
+
txn->final_lsn = commit_lsn;
txn->end_lsn = end_lsn;
txn->xact_time.commit_time = commit_time;
diff --git a/src/test/subscription/t/021_twophase.pl b/src/test/subscription/t/021_twophase.pl
index b8e4242d1f1..830f7348186 100644
--- a/src/test/subscription/t/021_twophase.pl
+++ b/src/test/subscription/t/021_twophase.pl
@@ -309,6 +309,46 @@ $result = $node_subscriber->safe_psql('postgres',
"SELECT count(*) FROM pg_prepared_xacts;");
is($result, qq(0), 'transaction is aborted on subscriber');
+###############################
+# Test that an empty prepared transaction is not replicated.
+#
+# A transaction that is assigned an XID but makes no change decoded by logical
+# replication (here, via a row lock) must not be sent to the subscriber.
+# Otherwise the subscriber would receive a PREPARE with no preceding BEGIN
+# PREPARE and error out, breaking replication.
+###############################
+
+# An empty prepared transaction that is committed.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ COMMIT PREPARED 'test_empty_prepared';");
+
+# An empty prepared transaction that is rolled back.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ ROLLBACK PREPARED 'test_empty_prepared';");
+
+# A subsequent normal change must still replicate. Reaching catchup confirms
+# the apply worker was not stalled by the empty prepared transactions above.
+$node_publisher->safe_psql('postgres', "INSERT INTO tab_full VALUES (31);");
+$node_publisher->wait_for_catchup($appname);
+
+# The empty transactions must not have been prepared on the subscriber.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM pg_prepared_xacts;");
+is($result, qq(0), 'empty prepared transaction is not replicated');
+
+# The subsequent change is visible, so replication is healthy.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM tab_full WHERE a = 31;");
+is($result, qq(1), 'replication continues after an empty prepared transaction');
+
###############################
# copy_data=false and two_phase
###############################
--
2.54.0
[text/x-patch] REL16_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch (7.7K, ../../CAD21AoAshLy0Ut08TkaGVdjSHWSA4Y5twcLqy8+jfLPfRYxWQw@mail.gmail.com/5-REL16_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch)
download | inline diff:
From 86d1ec214ba85c307f1825384da647675baebc88 Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Wed, 22 Jul 2026 12:22:34 -0700
Subject: [PATCH v2] Fix logical decoding of empty prepared transactions.
A two-phase transaction that is assigned an XID but produces no change
to be decoded -- for example, one that only acquires row locks via
SELECT ... FOR SHARE -- has no base snapshot in the reorder
buffer. ReorderBufferReplay() already skips such a transaction at
PREPARE time and never invokes the begin_prepare/change/prepare
callbacks for it, but ReorderBufferFinishPrepared() still called the
commit_prepared (or rollback_prepared) callback. As a result a
spurious COMMIT/ROLLBACK PREPARED was sent to the output plugin with
no preceding PREPARE. For the built-in subscriber this breaks
replication (the apply worker fails to find the prepared transaction),
and test_decoding could even crash.
Fix this by detecting an empty transaction (base_snapshot == NULL) in
ReorderBufferFinishPrepared() and cleaning it up without invoking the
commit/rollback prepared callbacks, mirroring the existing empty
transaction handling in ReorderBufferReplay().
On master and PG18, commit 072ee847ad4 changed ReorderBufferPrepare()
to send the prepare whenever it had not already been
sent, which also fires for empty transactions and emits a spurious
PREPARE. On those branches ReorderBufferPrepare() is therefore
additionally guarded with base_snapshot != NULL. This guard and the
Assert(!rbtxn_sent_prepare()) added in ReorderBufferFinishPrepared(),
are not necessary on 17 and earlier: there ReorderBufferPrepare() only
sends a prepare for concurrently-aborted transactions (which never
applies to an empty transaction) and the RBTXN_SENT_PREPARE flag does
not exist.
Back-patch to 14, where decoding of two-phase transactions was introduced.
Bug: 19556
Reported-by: Alexander Kozhemyakin<a.kozhemyakin@postgrespro.ru>
Reviewed-by: Amit Kapila <amit.kapila16@gmail.com>
Discussion: https://postgr.es/m/19556-daa6d7ea65054d48@postgresql.org
Backpatch-through: 14
---
contrib/test_decoding/expected/twophase.out | 25 ++++++++++++
contrib/test_decoding/sql/twophase.sql | 12 ++++++
.../replication/logical/reorderbuffer.c | 18 +++++++++
src/test/subscription/t/021_twophase.pl | 40 +++++++++++++++++++
4 files changed, 95 insertions(+)
diff --git a/contrib/test_decoding/expected/twophase.out b/contrib/test_decoding/expected/twophase.out
index 22d128d431f..4d16d50560b 100644
--- a/contrib/test_decoding/expected/twophase.out
+++ b/contrib/test_decoding/expected/twophase.out
@@ -227,6 +227,31 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
COMMIT PREPARED 'test_toast_table_access'
(1 row)
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/contrib/test_decoding/sql/twophase.sql b/contrib/test_decoding/sql/twophase.sql
index 0ff6ede1f38..0a569ee531d 100644
--- a/contrib/test_decoding/sql/twophase.sql
+++ b/contrib/test_decoding/sql/twophase.sql
@@ -125,6 +125,18 @@ COMMIT PREPARED 'test_toast_table_access';
-- consume commit prepared
SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index 08ef94728eb..8907e5c066b 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -2899,6 +2899,24 @@ ReorderBufferFinishPrepared(ReorderBuffer *rb, TransactionId xid,
txn->xact_time.prepare_time, txn->origin_id, txn->origin_lsn);
}
+ /*
+ * If this transaction has no snapshot, it didn't make any changes to the
+ * database, so there's nothing to decode. Note that
+ * ReorderBufferCommitChild will have transferred any snapshots from
+ * subtransactions if there were any.
+ */
+ if (txn->base_snapshot == NULL)
+ {
+ Assert(txn->ninvalidations == 0);
+
+ /*
+ * Removing this txn before a commit might result in the computation
+ * of an incorrect restart_lsn. See SnapBuildProcessRunningXacts.
+ */
+ ReorderBufferCleanupTXN(rb, txn);
+ return;
+ }
+
txn->final_lsn = commit_lsn;
txn->end_lsn = end_lsn;
txn->xact_time.commit_time = commit_time;
diff --git a/src/test/subscription/t/021_twophase.pl b/src/test/subscription/t/021_twophase.pl
index 087ec7c94ce..1b7114c1512 100644
--- a/src/test/subscription/t/021_twophase.pl
+++ b/src/test/subscription/t/021_twophase.pl
@@ -309,6 +309,46 @@ $result = $node_subscriber->safe_psql('postgres',
"SELECT count(*) FROM pg_prepared_xacts;");
is($result, qq(0), 'transaction is aborted on subscriber');
+###############################
+# Test that an empty prepared transaction is not replicated.
+#
+# A transaction that is assigned an XID but makes no change decoded by logical
+# replication (here, via a row lock) must not be sent to the subscriber.
+# Otherwise the subscriber would receive a PREPARE with no preceding BEGIN
+# PREPARE and error out, breaking replication.
+###############################
+
+# An empty prepared transaction that is committed.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ COMMIT PREPARED 'test_empty_prepared';");
+
+# An empty prepared transaction that is rolled back.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ ROLLBACK PREPARED 'test_empty_prepared';");
+
+# A subsequent normal change must still replicate. Reaching catchup confirms
+# the apply worker was not stalled by the empty prepared transactions above.
+$node_publisher->safe_psql('postgres', "INSERT INTO tab_full VALUES (31);");
+$node_publisher->wait_for_catchup($appname);
+
+# The empty transactions must not have been prepared on the subscriber.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM pg_prepared_xacts;");
+is($result, qq(0), 'empty prepared transaction is not replicated');
+
+# The subsequent change is visible, so replication is healthy.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM tab_full WHERE a = 31;");
+is($result, qq(1), 'replication continues after an empty prepared transaction');
+
###############################
# copy_data=false and two_phase
###############################
--
2.54.0
[text/x-patch] REL17_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch (7.7K, ../../CAD21AoAshLy0Ut08TkaGVdjSHWSA4Y5twcLqy8+jfLPfRYxWQw@mail.gmail.com/6-REL17_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch)
download | inline diff:
From 4ba6ae0b2c8dc4b13d14b5f3226ef436f24e65c8 Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Wed, 22 Jul 2026 12:22:34 -0700
Subject: [PATCH v2] Fix logical decoding of empty prepared transactions.
A two-phase transaction that is assigned an XID but produces no change
to be decoded -- for example, one that only acquires row locks via
SELECT ... FOR SHARE -- has no base snapshot in the reorder
buffer. ReorderBufferReplay() already skips such a transaction at
PREPARE time and never invokes the begin_prepare/change/prepare
callbacks for it, but ReorderBufferFinishPrepared() still called the
commit_prepared (or rollback_prepared) callback. As a result a
spurious COMMIT/ROLLBACK PREPARED was sent to the output plugin with
no preceding PREPARE. For the built-in subscriber this breaks
replication (the apply worker fails to find the prepared transaction),
and test_decoding could even crash.
Fix this by detecting an empty transaction (base_snapshot == NULL) in
ReorderBufferFinishPrepared() and cleaning it up without invoking the
commit/rollback prepared callbacks, mirroring the existing empty
transaction handling in ReorderBufferReplay().
On master and PG18, commit 072ee847ad4 changed ReorderBufferPrepare()
to send the prepare whenever it had not already been
sent, which also fires for empty transactions and emits a spurious
PREPARE. On those branches ReorderBufferPrepare() is therefore
additionally guarded with base_snapshot != NULL. This guard and the
Assert(!rbtxn_sent_prepare()) added in ReorderBufferFinishPrepared(),
are not necessary on 17 and earlier: there ReorderBufferPrepare() only
sends a prepare for concurrently-aborted transactions (which never
applies to an empty transaction) and the RBTXN_SENT_PREPARE flag does
not exist.
Back-patch to 14, where decoding of two-phase transactions was introduced.
Bug: 19556
Reported-by: Alexander Kozhemyakin<a.kozhemyakin@postgrespro.ru>
Reviewed-by: Amit Kapila <amit.kapila16@gmail.com>
Discussion: https://postgr.es/m/19556-daa6d7ea65054d48@postgresql.org
Backpatch-through: 14
---
contrib/test_decoding/expected/twophase.out | 25 ++++++++++++
contrib/test_decoding/sql/twophase.sql | 12 ++++++
.../replication/logical/reorderbuffer.c | 18 +++++++++
src/test/subscription/t/021_twophase.pl | 40 +++++++++++++++++++
4 files changed, 95 insertions(+)
diff --git a/contrib/test_decoding/expected/twophase.out b/contrib/test_decoding/expected/twophase.out
index 08a7c56b5df..ea3c51f8215 100644
--- a/contrib/test_decoding/expected/twophase.out
+++ b/contrib/test_decoding/expected/twophase.out
@@ -227,6 +227,31 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
COMMIT PREPARED 'test_toast_table_access'
(1 row)
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/contrib/test_decoding/sql/twophase.sql b/contrib/test_decoding/sql/twophase.sql
index 4b9ef0c0c44..834e5282c30 100644
--- a/contrib/test_decoding/sql/twophase.sql
+++ b/contrib/test_decoding/sql/twophase.sql
@@ -125,6 +125,18 @@ COMMIT PREPARED 'test_toast_table_access';
-- consume commit prepared
SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index 216fdde0868..b56febd24e4 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -2933,6 +2933,24 @@ ReorderBufferFinishPrepared(ReorderBuffer *rb, TransactionId xid,
txn->xact_time.prepare_time, txn->origin_id, txn->origin_lsn);
}
+ /*
+ * If this transaction has no snapshot, it didn't make any changes to the
+ * database, so there's nothing to decode. Note that
+ * ReorderBufferCommitChild will have transferred any snapshots from
+ * subtransactions if there were any.
+ */
+ if (txn->base_snapshot == NULL)
+ {
+ Assert(txn->ninvalidations == 0);
+
+ /*
+ * Removing this txn before a commit might result in the computation
+ * of an incorrect restart_lsn. See SnapBuildProcessRunningXacts.
+ */
+ ReorderBufferCleanupTXN(rb, txn);
+ return;
+ }
+
txn->final_lsn = commit_lsn;
txn->end_lsn = end_lsn;
txn->xact_time.commit_time = commit_time;
diff --git a/src/test/subscription/t/021_twophase.pl b/src/test/subscription/t/021_twophase.pl
index 94a6d53794e..90073ba0313 100644
--- a/src/test/subscription/t/021_twophase.pl
+++ b/src/test/subscription/t/021_twophase.pl
@@ -309,6 +309,46 @@ $result = $node_subscriber->safe_psql('postgres',
"SELECT count(*) FROM pg_prepared_xacts;");
is($result, qq(0), 'transaction is aborted on subscriber');
+###############################
+# Test that an empty prepared transaction is not replicated.
+#
+# A transaction that is assigned an XID but makes no change decoded by logical
+# replication (here, via a row lock) must not be sent to the subscriber.
+# Otherwise the subscriber would receive a PREPARE with no preceding BEGIN
+# PREPARE and error out, breaking replication.
+###############################
+
+# An empty prepared transaction that is committed.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ COMMIT PREPARED 'test_empty_prepared';");
+
+# An empty prepared transaction that is rolled back.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ ROLLBACK PREPARED 'test_empty_prepared';");
+
+# A subsequent normal change must still replicate. Reaching catchup confirms
+# the apply worker was not stalled by the empty prepared transactions above.
+$node_publisher->safe_psql('postgres', "INSERT INTO tab_full VALUES (31);");
+$node_publisher->wait_for_catchup($appname);
+
+# The empty transactions must not have been prepared on the subscriber.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM pg_prepared_xacts;");
+is($result, qq(0), 'empty prepared transaction is not replicated');
+
+# The subsequent change is visible, so replication is healthy.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM tab_full WHERE a = 31;");
+is($result, qq(1), 'replication continues after an empty prepared transaction');
+
###############################
# copy_data=false and two_phase
###############################
--
2.54.0
[text/x-patch] REL19_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch (8.6K, ../../CAD21AoAshLy0Ut08TkaGVdjSHWSA4Y5twcLqy8+jfLPfRYxWQw@mail.gmail.com/7-REL19_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch)
download | inline diff:
From 9403f6e69292546ca0b34a52edf42a88a4b11efd Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Wed, 22 Jul 2026 12:22:34 -0700
Subject: [PATCH v2] Fix logical decoding of empty prepared transactions.
A two-phase transaction that is assigned an XID but produces no change
to be decoded -- for example, one that only acquires row locks via
SELECT ... FOR SHARE -- has no base snapshot in the reorder
buffer. ReorderBufferReplay() already skips such a transaction at
PREPARE time and never invokes the begin_prepare/change/prepare
callbacks for it, but ReorderBufferFinishPrepared() still called the
commit_prepared (or rollback_prepared) callback. As a result a
spurious COMMIT/ROLLBACK PREPARED was sent to the output plugin with
no preceding PREPARE. For the built-in subscriber this breaks
replication (the apply worker fails to find the prepared transaction),
and test_decoding could even crash.
Fix this by detecting an empty transaction (base_snapshot == NULL) in
ReorderBufferFinishPrepared() and cleaning it up without invoking the
commit/rollback prepared callbacks, mirroring the existing empty
transaction handling in ReorderBufferReplay().
On master and PG18, commit 072ee847ad4 changed ReorderBufferPrepare()
to send the prepare whenever it had not already been
sent, which also fires for empty transactions and emits a spurious
PREPARE. On those branches ReorderBufferPrepare() is therefore
additionally guarded with base_snapshot != NULL. This guard and the
Assert(!rbtxn_sent_prepare()) added in ReorderBufferFinishPrepared(),
are not necessary on 17 and earlier: there ReorderBufferPrepare() only
sends a prepare for concurrently-aborted transactions (which never
applies to an empty transaction) and the RBTXN_SENT_PREPARE flag does
not exist.
Back-patch to 14, where decoding of two-phase transactions was introduced.
Bug: 19556
Reported-by: Alexander Kozhemyakin<a.kozhemyakin@postgrespro.ru>
Reviewed-by: Amit Kapila <amit.kapila16@gmail.com>
Discussion: https://postgr.es/m/19556-daa6d7ea65054d48@postgresql.org
Backpatch-through: 14
---
contrib/test_decoding/expected/twophase.out | 25 ++++++++++++
contrib/test_decoding/sql/twophase.sql | 12 ++++++
.../replication/logical/reorderbuffer.c | 28 +++++++++++--
src/test/subscription/t/021_twophase.pl | 40 +++++++++++++++++++
4 files changed, 102 insertions(+), 3 deletions(-)
diff --git a/contrib/test_decoding/expected/twophase.out b/contrib/test_decoding/expected/twophase.out
index 08a7c56b5df..ea3c51f8215 100644
--- a/contrib/test_decoding/expected/twophase.out
+++ b/contrib/test_decoding/expected/twophase.out
@@ -227,6 +227,31 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
COMMIT PREPARED 'test_toast_table_access'
(1 row)
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/contrib/test_decoding/sql/twophase.sql b/contrib/test_decoding/sql/twophase.sql
index 4b9ef0c0c44..834e5282c30 100644
--- a/contrib/test_decoding/sql/twophase.sql
+++ b/contrib/test_decoding/sql/twophase.sql
@@ -125,6 +125,18 @@ COMMIT PREPARED 'test_toast_table_access';
-- consume commit prepared
SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index ead803171e8..9a56acf3a3c 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -2971,11 +2971,14 @@ ReorderBufferPrepare(ReorderBuffer *rb, TransactionId xid,
txn->xact_time.prepare_time, txn->origin_id, txn->origin_lsn);
/*
- * Send a prepare if not already done so. This might occur if we have
- * detected a concurrent abort while replaying the non-streaming
+ * Send a prepare if not already done so, but only if this transaction
+ * made changes to the database (i.e. has a base snapshot), so that later
+ * when rollback prepared is decoded and sent, the downstream should be
+ * able to rollback such a xact. The "not already sent" case can occur if
+ * we have detected a concurrent abort while replaying the non-streaming
* transaction.
*/
- if (!rbtxn_sent_prepare(txn))
+ if (!rbtxn_sent_prepare(txn) && txn->base_snapshot != NULL)
{
rb->prepare(rb, txn, txn->final_lsn);
txn->txn_flags |= RBTXN_SENT_PREPARE;
@@ -3042,6 +3045,25 @@ ReorderBufferFinishPrepared(ReorderBuffer *rb, TransactionId xid,
txn->xact_time.prepare_time, txn->origin_id, txn->origin_lsn);
}
+ /*
+ * If this transaction has no snapshot, it didn't make any changes to the
+ * database, so there's nothing to decode. Note that
+ * ReorderBufferCommitChild will have transferred any snapshots from
+ * subtransactions if there were any.
+ */
+ if (txn->base_snapshot == NULL)
+ {
+ Assert(txn->ninvalidations == 0);
+ Assert(!rbtxn_sent_prepare(txn));
+
+ /*
+ * Removing this txn before a commit might result in the computation
+ * of an incorrect restart_lsn. See SnapBuildProcessRunningXacts.
+ */
+ ReorderBufferCleanupTXN(rb, txn);
+ return;
+ }
+
txn->final_lsn = commit_lsn;
txn->end_lsn = end_lsn;
txn->xact_time.commit_time = commit_time;
diff --git a/src/test/subscription/t/021_twophase.pl b/src/test/subscription/t/021_twophase.pl
index b8e4242d1f1..830f7348186 100644
--- a/src/test/subscription/t/021_twophase.pl
+++ b/src/test/subscription/t/021_twophase.pl
@@ -309,6 +309,46 @@ $result = $node_subscriber->safe_psql('postgres',
"SELECT count(*) FROM pg_prepared_xacts;");
is($result, qq(0), 'transaction is aborted on subscriber');
+###############################
+# Test that an empty prepared transaction is not replicated.
+#
+# A transaction that is assigned an XID but makes no change decoded by logical
+# replication (here, via a row lock) must not be sent to the subscriber.
+# Otherwise the subscriber would receive a PREPARE with no preceding BEGIN
+# PREPARE and error out, breaking replication.
+###############################
+
+# An empty prepared transaction that is committed.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ COMMIT PREPARED 'test_empty_prepared';");
+
+# An empty prepared transaction that is rolled back.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ ROLLBACK PREPARED 'test_empty_prepared';");
+
+# A subsequent normal change must still replicate. Reaching catchup confirms
+# the apply worker was not stalled by the empty prepared transactions above.
+$node_publisher->safe_psql('postgres', "INSERT INTO tab_full VALUES (31);");
+$node_publisher->wait_for_catchup($appname);
+
+# The empty transactions must not have been prepared on the subscriber.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM pg_prepared_xacts;");
+is($result, qq(0), 'empty prepared transaction is not replicated');
+
+# The subsequent change is visible, so replication is healthy.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM tab_full WHERE a = 31;");
+is($result, qq(1), 'replication continues after an empty prepared transaction');
+
###############################
# copy_data=false and two_phase
###############################
--
2.54.0
[text/x-patch] master_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch (8.6K, ../../CAD21AoAshLy0Ut08TkaGVdjSHWSA4Y5twcLqy8+jfLPfRYxWQw@mail.gmail.com/8-master_v2-0001-Fix-logical-decoding-of-empty-prepared-transactio.patch)
download | inline diff:
From ac79c0582a96a6df7c59dff4ed91c6b419df96ae Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Wed, 22 Jul 2026 12:22:34 -0700
Subject: [PATCH v2] Fix logical decoding of empty prepared transactions.
A two-phase transaction that is assigned an XID but produces no change
to be decoded -- for example, one that only acquires row locks via
SELECT ... FOR SHARE -- has no base snapshot in the reorder
buffer. ReorderBufferReplay() already skips such a transaction at
PREPARE time and never invokes the begin_prepare/change/prepare
callbacks for it, but ReorderBufferFinishPrepared() still called the
commit_prepared (or rollback_prepared) callback. As a result a
spurious COMMIT/ROLLBACK PREPARED was sent to the output plugin with
no preceding PREPARE. For the built-in subscriber this breaks
replication (the apply worker fails to find the prepared transaction),
and test_decoding could even crash.
Fix this by detecting an empty transaction (base_snapshot == NULL) in
ReorderBufferFinishPrepared() and cleaning it up without invoking the
commit/rollback prepared callbacks, mirroring the existing empty
transaction handling in ReorderBufferReplay().
On master and PG18, commit 072ee847ad4 changed ReorderBufferPrepare()
to send the prepare whenever it had not already been
sent, which also fires for empty transactions and emits a spurious
PREPARE. On those branches ReorderBufferPrepare() is therefore
additionally guarded with base_snapshot != NULL. This guard and the
Assert(!rbtxn_sent_prepare()) added in ReorderBufferFinishPrepared(),
are not necessary on 17 and earlier: there ReorderBufferPrepare() only
sends a prepare for concurrently-aborted transactions (which never
applies to an empty transaction) and the RBTXN_SENT_PREPARE flag does
not exist.
Back-patch to 14, where decoding of two-phase transactions was introduced.
Bug: 19556
Reported-by: Alexander Kozhemyakin<a.kozhemyakin@postgrespro.ru>
Reviewed-by: Amit Kapila <amit.kapila16@gmail.com>
Discussion: https://postgr.es/m/19556-daa6d7ea65054d48@postgresql.org
Backpatch-through: 14
---
contrib/test_decoding/expected/twophase.out | 25 ++++++++++++
contrib/test_decoding/sql/twophase.sql | 12 ++++++
.../replication/logical/reorderbuffer.c | 28 +++++++++++--
src/test/subscription/t/021_twophase.pl | 40 +++++++++++++++++++
4 files changed, 102 insertions(+), 3 deletions(-)
diff --git a/contrib/test_decoding/expected/twophase.out b/contrib/test_decoding/expected/twophase.out
index 08a7c56b5df..ea3c51f8215 100644
--- a/contrib/test_decoding/expected/twophase.out
+++ b/contrib/test_decoding/expected/twophase.out
@@ -227,6 +227,31 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
COMMIT PREPARED 'test_toast_table_access'
(1 row)
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+ id | data
+----+------
+ 1 |
+(1 row)
+
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/contrib/test_decoding/sql/twophase.sql b/contrib/test_decoding/sql/twophase.sql
index 4b9ef0c0c44..834e5282c30 100644
--- a/contrib/test_decoding/sql/twophase.sql
+++ b/contrib/test_decoding/sql/twophase.sql
@@ -125,6 +125,18 @@ COMMIT PREPARED 'test_toast_table_access';
-- consume commit prepared
SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+-- Test that an empty prepared transaction should not be decoded, whether it
+-- is committed or rolled back.
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+COMMIT PREPARED 'test_empty_transaction';
+BEGIN;
+SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE;
+PREPARE TRANSACTION 'test_empty_transaction';
+ROLLBACK PREPARED 'test_empty_transaction';
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
-- Test 8:
-- cleanup and make sure results are also empty
DROP TABLE test_prepared1;
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index 059ed860314..170e134a535 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -2979,11 +2979,14 @@ ReorderBufferPrepare(ReorderBuffer *rb, TransactionId xid,
txn->prepare_time, txn->origin_id, txn->origin_lsn);
/*
- * Send a prepare if not already done so. This might occur if we have
- * detected a concurrent abort while replaying the non-streaming
+ * Send a prepare if not already done so, but only if this transaction
+ * made changes to the database (i.e. has a base snapshot), so that later
+ * when rollback prepared is decoded and sent, the downstream should be
+ * able to rollback such a xact. The "not already sent" case can occur if
+ * we have detected a concurrent abort while replaying the non-streaming
* transaction.
*/
- if (!rbtxn_sent_prepare(txn))
+ if (!rbtxn_sent_prepare(txn) && txn->base_snapshot != NULL)
{
rb->prepare(rb, txn, txn->final_lsn);
txn->txn_flags |= RBTXN_SENT_PREPARE;
@@ -3050,6 +3053,25 @@ ReorderBufferFinishPrepared(ReorderBuffer *rb, TransactionId xid,
txn->prepare_time, txn->origin_id, txn->origin_lsn);
}
+ /*
+ * If this transaction has no snapshot, it didn't make any changes to the
+ * database, so there's nothing to decode. Note that
+ * ReorderBufferCommitChild will have transferred any snapshots from
+ * subtransactions if there were any.
+ */
+ if (txn->base_snapshot == NULL)
+ {
+ Assert(txn->ninvalidations == 0);
+ Assert(!rbtxn_sent_prepare(txn));
+
+ /*
+ * Removing this txn before a commit might result in the computation
+ * of an incorrect restart_lsn. See SnapBuildProcessRunningXacts.
+ */
+ ReorderBufferCleanupTXN(rb, txn);
+ return;
+ }
+
txn->final_lsn = commit_lsn;
txn->end_lsn = end_lsn;
txn->commit_time = commit_time;
diff --git a/src/test/subscription/t/021_twophase.pl b/src/test/subscription/t/021_twophase.pl
index 4404d7b5449..c1e8e069628 100644
--- a/src/test/subscription/t/021_twophase.pl
+++ b/src/test/subscription/t/021_twophase.pl
@@ -309,6 +309,46 @@ $result = $node_subscriber->safe_psql('postgres',
"SELECT count(*) FROM pg_prepared_xacts;");
is($result, qq(0), 'transaction is aborted on subscriber');
+###############################
+# Test that an empty prepared transaction is not replicated.
+#
+# A transaction that is assigned an XID but makes no change decoded by logical
+# replication (here, via a row lock) must not be sent to the subscriber.
+# Otherwise the subscriber would receive a PREPARE with no preceding BEGIN
+# PREPARE and error out, breaking replication.
+###############################
+
+# An empty prepared transaction that is committed.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ COMMIT PREPARED 'test_empty_prepared';");
+
+# An empty prepared transaction that is rolled back.
+$node_publisher->safe_psql(
+ 'postgres', "
+ BEGIN;
+ SELECT a FROM tab_full WHERE a = 1 FOR SHARE;
+ PREPARE TRANSACTION 'test_empty_prepared';
+ ROLLBACK PREPARED 'test_empty_prepared';");
+
+# A subsequent normal change must still replicate. Reaching catchup confirms
+# the apply worker was not stalled by the empty prepared transactions above.
+$node_publisher->safe_psql('postgres', "INSERT INTO tab_full VALUES (31);");
+$node_publisher->wait_for_catchup($appname);
+
+# The empty transactions must not have been prepared on the subscriber.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM pg_prepared_xacts;");
+is($result, qq(0), 'empty prepared transaction is not replicated');
+
+# The subsequent change is visible, so replication is healthy.
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM tab_full WHERE a = 31;");
+is($result, qq(1), 'replication continues after an empty prepared transaction');
+
###############################
# copy_data=false and two_phase
###############################
--
2.54.0
^ permalink raw reply [nested|flat] 10+ messages in thread
* Re: BUG #19556: Segmentation fault in test_decoding
@ 2026-07-28 19:36 Masahiko Sawada <sawada.mshk@gmail.com>
parent: Masahiko Sawada <sawada.mshk@gmail.com>
0 siblings, 0 replies; 10+ messages in thread
From: Masahiko Sawada @ 2026-07-28 19:36 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: a.kozhemyakin@postgrespro.ru; pgsql-bugs@lists.postgresql.org
On Mon, Jul 27, 2026 at 2:28 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
>
> On Thu, Jul 23, 2026 at 11:24 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> >
> > On Wed, Jul 22, 2026 at 11:50 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > On Thu, Jul 23, 2026 at 7:47 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> > > >
> > > > Thank you for the patch. I like the approach the proposed patch does.
> > > > I've made a patch for PG18+ that fixes both issues with regression
> > > > tests. For PG17 or earlier, the patch doesn't need the changes in
> > > > ReorderBufferPrepare() but has the same regression tests.
> > > >
> > >
> > > The patch LGTM. One minor point: Shall we retain the earlier comments
> > > (We send the prepare for the concurrently aborted xacts so that later
> > > when rollback prepared is decoded and sent, the downstream should be
> > > able to rollback such a xact. See comments atop DecodePrepare.)? This
> > > makes it clear why we are sending prepare for concurrent abort cases.
> >
> > Thank you for reviewing the 0001 patch! Yes, I agree to retain the
> > comment. I'll update the patch and push it early next week.
> >
>
> I prepared the patches for all branches. I'm going to push them
> tomorrow if there is no further comment.
Pushed.
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 10+ messages in thread
end of thread, other threads:[~2026-07-28 19:36 UTC | newest]
Thread overview: 10+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2026-07-17 06:24 BUG #19556: Segmentation fault in test_decoding PG Bug reporting form <noreply@postgresql.org>
2026-07-19 07:30 ` Masahiko Sawada <sawada.mshk@gmail.com>
2026-07-22 02:51 ` Masahiko Sawada <sawada.mshk@gmail.com>
2026-07-22 06:07 ` Amit Kapila <amit.kapila16@gmail.com>
2026-07-22 09:13 ` Amit Kapila <amit.kapila16@gmail.com>
2026-07-23 02:16 ` Masahiko Sawada <sawada.mshk@gmail.com>
2026-07-23 06:50 ` Amit Kapila <amit.kapila16@gmail.com>
2026-07-23 18:24 ` Masahiko Sawada <sawada.mshk@gmail.com>
2026-07-27 21:28 ` Masahiko Sawada <sawada.mshk@gmail.com>
2026-07-28 19:36 ` Masahiko Sawada <sawada.mshk@gmail.com>
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox