From 069ef9104702a73e4ac8881b178fc22d7d720657 Mon Sep 17 00:00:00 2001
From: Atish Prakash <125atishprak@gmail.com>
Date: Sun, 8 Nov 2026 21:06:11 +0530
Subject: [PATCH v3] Don't mark a transaction serialized when nothing was
 spilled

ReorderBufferSerializeTXN() set RBTXN_IS_SERIALIZED on every transaction
it visited, including subtransactions with no changes to write.  Such a
transaction keeps an invalid final_lsn, because final_lsn is only advanced
as changes are written, so ReorderBufferRestoreCleanup() can later fail its
assertion on it.  This happens when ReorderBufferCheckAndTruncateAbortedTXN()
truncates an aborted transaction, and when a transaction is truncated after
being streamed once a partial change completes.

It also made spill_txns undercount: when such a subtransaction later spilled
real changes, it was not counted, as it was already marked serialized.

Set the flag only when changes were written, after the statistics update
that checks it.

Test case by Zhijie Hou.

Bug: #19839
Reported-by: Atish Prakash <125atishprak@gmail.com>
Author: Atish Prakash <125atishprak@gmail.com>
Reviewed-by: Zhijie Hou <houzhijie22@gmail.com>
Backpatch-through: 14
---
 contrib/test_decoding/expected/stream.out     | 24 +++++++++++++++++++
 contrib/test_decoding/sql/stream.sql          | 15 ++++++++++++
 .../replication/logical/reorderbuffer.c       |  5 ++--
 3 files changed, 42 insertions(+), 2 deletions(-)

diff --git a/contrib/test_decoding/expected/stream.out b/contrib/test_decoding/expected/stream.out
index 0ec5c93361..9c164847b0 100644
--- a/contrib/test_decoding/expected/stream.out
+++ b/contrib/test_decoding/expected/stream.out
@@ -157,6 +157,30 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
  COMMIT
 (3 rows)
 
+-- bug #19839
+--
+-- A subtransaction without decoded changes must not be marked as serialized
+-- when its parent transaction spills to disk during a partial TOAST change
+-- (stream_test.data has EXTERNAL storage set above).  Otherwise, truncating
+-- the transaction after streaming fails on the subtransaction's invalid
+-- final_lsn.
+BEGIN;
+SAVEPOINT s;
+SELECT 1 FROM stream_test LIMIT 1 FOR UPDATE;
+ ?column? 
+----------
+        1
+(1 row)
+
+RELEASE SAVEPOINT s;
+INSERT INTO stream_test VALUES (repeat('a', 100000));
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+ count 
+-------
+     4
+(1 row)
+
 DROP TABLE stream_test;
 SELECT pg_drop_replication_slot('regression_slot');
  pg_drop_replication_slot 
diff --git a/contrib/test_decoding/sql/stream.sql b/contrib/test_decoding/sql/stream.sql
index 5e45a8e9b6..f315933556 100644
--- a/contrib/test_decoding/sql/stream.sql
+++ b/contrib/test_decoding/sql/stream.sql
@@ -82,5 +82,20 @@ ROLLBACK;
 INSERT INTO stream_test VALUES ('after-abort');
 SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
 
+-- bug #19839
+--
+-- A subtransaction without decoded changes must not be marked as serialized
+-- when its parent transaction spills to disk during a partial TOAST change
+-- (stream_test.data has EXTERNAL storage set above).  Otherwise, truncating
+-- the transaction after streaming fails on the subtransaction's invalid
+-- final_lsn.
+BEGIN;
+SAVEPOINT s;
+SELECT 1 FROM stream_test LIMIT 1 FOR UPDATE;
+RELEASE SAVEPOINT s;
+INSERT INTO stream_test VALUES (repeat('a', 100000));
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+
 DROP TABLE stream_test;
 SELECT pg_drop_replication_slot('regression_slot');
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index 4684398531..8af25c865c 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -4200,7 +4200,7 @@ ReorderBufferSerializeTXN(ReorderBuffer *rb, ReorderBufferTXN *txn)
 	/* Update the memory counter */
 	ReorderBufferChangeMemoryUpdate(rb, NULL, txn, false, size);
 
-	/* update the statistics iff we have spilled anything */
+	/* update the statistics and mark the transaction serialized iff we spilled */
 	if (spilled)
 	{
 		rb->spillCount += 1;
@@ -4211,12 +4211,13 @@ ReorderBufferSerializeTXN(ReorderBuffer *rb, ReorderBufferTXN *txn)
 
 		/* update the decoding stats */
 		UpdateDecodingStats((LogicalDecodingContext *) rb->private_data);
+
+		txn->txn_flags |= RBTXN_IS_SERIALIZED;
 	}
 
 	Assert(spilled == txn->nentries_mem);
 	Assert(dlist_is_empty(&txn->changes));
 	txn->nentries_mem = 0;
-	txn->txn_flags |= RBTXN_IS_SERIALIZED;
 
 	if (fd != -1)
 		CloseTransientFile(fd);
-- 
2.53.0

