postgres.git / summary / log / commit / refs

commit    c2d34db0a5eced16cf8d7e1004674b20b8fa383d
Author:   Masahiko Sawada <msawada@postgresql.org>
Date:     Tue Jul 28 19:33:50 2026 +0000

    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 v18 and newer versions, 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 v17 and older
    versions: 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 v14, 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 ++++++++++++ src/backend/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; [parent: 15fd7a3e2eeb]