From 9d22c0faa034bcabdffb10607299bb429a60c4fb Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Fri, 4 Sep 2026 22:27:51 -0700
Subject: [PATCH v2 2/2] Fix logical decoding to ignore updates without a new
 tuple.

REPACK (CONCURRENTLY) suppresses logical decoding of the changes it
applies to the transient heap. For an update, suppression keeps the
tuple data out of the WAL record, but the record itself is still
written, and decoding turned it into a change carrying neither a new
nor an old tuple. An output plugin that asks for the changes made by
heap rewrites therefore outputs an UPDATE with no data at all,
reported under the name of the table being repacked.

Ignore such records, as decoding already does for inserts; deletes
have a WAL flag of their own for this.

Backpatch to v19, where REPACK (CONCURRENTLY) was introduced.

Reported-by: Thom Brown <thom@linux.com>
Reviewed-by:
Discussion: https://postgr.es/m/CAA-aLv7L_-dOuHXjLh0Di66dExdOb=uTOzR=jtrqCmV0Wxyd2Q@mail.gmail.com
Backpatch-through: 19
---
 src/backend/replication/logical/decode.c      |  8 ++++
 .../expected/repack_decode.out                | 48 ++++++++++++++++++-
 .../injection_points/specs/repack_decode.spec | 24 ++++++++++
 3 files changed, 79 insertions(+), 1 deletion(-)

diff --git a/src/backend/replication/logical/decode.c b/src/backend/replication/logical/decode.c
index c944be4ac83..81bfe6b6b5b 100644
--- a/src/backend/replication/logical/decode.c
+++ b/src/backend/replication/logical/decode.c
@@ -981,6 +981,14 @@ DecodeUpdate(LogicalDecodingContext *ctx, XLogRecordBuffer *buf)
 
 	xlrec = (xl_heap_update *) XLogRecGetData(r);
 
+	/*
+	 * Ignore update records without a new tuple.  This happens when the
+	 * caller of heap_update() asked for the change not to be decoded, as
+	 * REPACK (CONCURRENTLY) does for the transient heap.
+	 */
+	if (!(xlrec->flags & XLH_UPDATE_CONTAINS_NEW_TUPLE))
+		return;
+
 	/* only interested in our database */
 	XLogRecGetBlockTag(r, 0, &target_locator, NULL, NULL);
 	if (target_locator.dbOid != ctx->slot->data.database)
diff --git a/src/test/modules/injection_points/expected/repack_decode.out b/src/test/modules/injection_points/expected/repack_decode.out
index 0de24241bfb..b766ed544fe 100644
--- a/src/test/modules/injection_points/expected/repack_decode.out
+++ b/src/test/modules/injection_points/expected/repack_decode.out
@@ -26,7 +26,53 @@ step s1_decode:
 
 count
 -----
-    9
+    8
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+pg_drop_replication_slot
+------------------------
+                        
+(1 row)
+
+
+starting permutation: s1_wait_before_lock s2_short_change s2_wakeup_before_lock s1_decode_updates
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_toast;
+ <waiting ...>
+step s2_short_change: 
+	UPDATE repack_toast SET t = 'short' WHERE i=1;
+
+step s2_wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s1_wait_before_lock: <... completed>
+step s1_decode_updates: 
+	SELECT data FROM pg_logical_slot_peek_changes('s', NULL, NULL, 'include-rewrites', '1')
+	WHERE data LIKE '%UPDATE%';
+
+data                                                           
+---------------------------------------------------------------
+table public.repack_toast: UPDATE: i[integer]:1 t[text]:'short'
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
 (1 row)
 
 pg_drop_replication_slot
diff --git a/src/test/modules/injection_points/specs/repack_decode.spec b/src/test/modules/injection_points/specs/repack_decode.spec
index 31288cf75e3..da326fb6052 100644
--- a/src/test/modules/injection_points/specs/repack_decode.spec
+++ b/src/test/modules/injection_points/specs/repack_decode.spec
@@ -42,12 +42,28 @@ step s1_decode
 {
 	SELECT count(*) FROM pg_logical_slot_peek_changes('s', NULL, NULL, 'include-rewrites', '1');
 }
+# Show the decoded updates.  The row loaded by setup carries a random TOAST
+# value, so only the updates are stable enough to display.
+step s1_decode_updates
+{
+	SELECT data FROM pg_logical_slot_peek_changes('s', NULL, NULL, 'include-rewrites', '1')
+	WHERE data LIKE '%UPDATE%';
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
 
 session s2
 step s2_changes
 {
 	UPDATE repack_toast SET t = gen_external() WHERE i=1;
 }
+# Change the row using a value small enough to stay in-line.
+step s2_short_change
+{
+	UPDATE repack_toast SET t = 'short' WHERE i=1;
+}
 step s2_wakeup_before_lock
 {
 	SELECT injection_points_wakeup('repack-concurrently-before-lock');
@@ -59,3 +75,11 @@ permutation
 	s2_wakeup_before_lock
 	s1_decode
 
+# The changes REPACK applies to the transient heap must not be reported to an
+# output plugin, not even as records that carry no tuple.  Unlike the
+# permutation above, no TOAST value is involved here.
+permutation
+	s1_wait_before_lock
+	s2_short_change
+	s2_wakeup_before_lock
+	s1_decode_updates
-- 
2.55.0

