From 319d73d301d6800c602617fe41661f48a4c257cf Mon Sep 17 00:00:00 2001
From: Antonin Houska <ah@cybertec.at>
Date: Wed, 2 Sep 2026 19:17:53 +0200
Subject: [PATCH v2 1/2] Fix heap_update() ignoring TABLE_UPDATE_NO_LOGICAL for
 TOAST tuples.

heap_update() honored TABLE_UPDATE_NO_LOGICAL when logging the main
tuple, but not for the tuples it writes to the TOAST relation. Passing
no options down to the tuple toaster was correct until updates gained
the flag: inserts have propagated theirs ever since suppression was
introduced for heap rewrites.

REPACK (CONCURRENTLY) is the only user of the flag, and relies on it
to keep the changes it applies to the transient heap out of the
logical stream.  Logical decoding therefore still reassembled the
TOAST value of such an update, and then dereferenced the new tuple of
a change that carries none, crashing the backend.  This is reachable
only if an output plugin asks for the changes made by heap
rewrites. In core that is just test_decoding with include-rewrites.

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

Reported-by: Thom Brown <thom@linux.com>
Author: Antonin Houska <ah@cybertec.at>
Reviewed-by: Masahiko Sawada <sawada.mshk@gmail.com>
Discussion: https://postgr.es/m/CAA-aLv7L_-dOuHXjLh0Di66dExdOb=uTOzR=jtrqCmV0Wxyd2Q@mail.gmail.com
Backpatch-through: 19
---
 src/backend/access/heap/heapam.c              |  8 ++-
 src/test/modules/injection_points/Makefile    |  3 +
 .../expected/repack_decode.out                | 36 +++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../injection_points/specs/repack_decode.spec | 61 +++++++++++++++++++
 5 files changed, 107 insertions(+), 2 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_decode.out
 create mode 100644 src/test/modules/injection_points/specs/repack_decode.spec

diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index 72d6541734c..10766d330a9 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -3961,8 +3961,12 @@ l2:
 		 */
 		if (need_toast)
 		{
-			/* Note we always use WAL and FSM during updates */
-			heaptup = heap_toast_insert_or_update(relation, newtup, &oldtup, 0);
+			/*
+			 * If logical decoding is not needed, suppress it for the TOAST
+			 * tuples too. We never skip the FSM here.
+			 */
+			heaptup = heap_toast_insert_or_update(relation, newtup, &oldtup,
+												  walLogical ? 0 : HEAP_INSERT_NO_LOGICAL);
 			newtupsize = MAXALIGN(heaptup->t_len);
 		}
 		else
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 3b136adf126..c0d3956e147 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -9,6 +9,8 @@ EXTENSION = injection_points
 DATA = injection_points--1.0.sql
 PGFILEDESC = "injection_points - facility for injection points"
 
+EXTRA_INSTALL = contrib/test_decoding
+
 REGRESS = injection_points hashagg reindex_conc vacuum
 REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 
@@ -16,6 +18,7 @@ ISOLATION = basic \
 	    inplace \
 	    reindex_concurrently_deferred \
 	    repack \
+	    repack_decode \
 	    repack_temporal \
 	    repack_temporal_multirange \
 	    repack_toast \
diff --git a/src/test/modules/injection_points/expected/repack_decode.out b/src/test/modules/injection_points/expected/repack_decode.out
new file mode 100644
index 00000000000..0de24241bfb
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_decode.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_wait_before_lock s2_changes s2_wakeup_before_lock s1_decode
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_toast;
+ <waiting ...>
+step s2_changes: 
+	UPDATE repack_toast SET t = gen_external() 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: 
+	SELECT count(*) FROM pg_logical_slot_peek_changes('s', NULL, NULL, 'include-rewrites', '1');
+
+count
+-----
+    9
+(1 row)
+
+pg_drop_replication_slot
+------------------------
+                        
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index aff516b901a..24372551796 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -47,6 +47,7 @@ tests += {
       'inplace',
       'reindex_concurrently_deferred',
       'repack',
+      'repack_decode',
       'repack_temporal',
       'repack_temporal_multirange',
       'repack_toast',
diff --git a/src/test/modules/injection_points/specs/repack_decode.spec b/src/test/modules/injection_points/specs/repack_decode.spec
new file mode 100644
index 00000000000..31288cf75e3
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_decode.spec
@@ -0,0 +1,61 @@
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	BEGIN;
+	-- Generate a string of random characters that is not likely to be
+	-- compressed, but is big enough to be stored externally.
+	CREATE FUNCTION gen_external()
+	RETURNS text
+	LANGUAGE sql as $$
+		SELECT string_agg(chr(65 + trunc(25 * random())::int), '')
+		FROM generate_series(1, 2048) s(x);
+	$$;
+	COMMIT;
+
+	SELECT pg_create_logical_replication_slot('s', 'test_decoding');
+
+	CREATE TABLE repack_toast(i int PRIMARY KEY, t text);
+	INSERT INTO repack_toast(i, t) VALUES (1, gen_external());
+}
+
+teardown
+{
+    	DROP TABLE repack_toast;
+	DROP EXTENSION injection_points;
+	DROP FUNCTION gen_external();
+	SELECT pg_drop_replication_slot('s');
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step s1_wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_toast;
+}
+step s1_decode
+{
+	SELECT count(*) FROM pg_logical_slot_peek_changes('s', NULL, NULL, 'include-rewrites', '1');
+}
+
+session s2
+step s2_changes
+{
+	UPDATE repack_toast SET t = gen_external() WHERE i=1;
+}
+step s2_wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+permutation
+	s1_wait_before_lock
+	s2_changes
+	s2_wakeup_before_lock
+	s1_decode
+
-- 
2.55.0

