Now it also passes CI

On Thu, Oct 8, 2026 at 11:35 AM Hannu Krosing <[email protected]> wrote:
>
> Hi hackers,
>
> One of the persistent challenges for connection poolers (PgBouncer, pgpool-II,
> Odyssey) and connection routers in transaction-pooling mode is determining
> whether a backend connection is clean enough to be safely recycled back into
> the pool without issuing expensive round-trip cleanup probes (such as
> DISCARD ALL,
> querying pg_prepared_xacts, or checking for active WITH HOLD cursors
> or temporary
> namespaces).
>
> Similarly, with the introduction of replication wait commands (such as
> WAIT FOR LSN / pg_wal_replay_wait()), proxies and clients wishing to achieve
> causal consistency (read-your-own-writes across read replicas) need to know
> the commit LSN of the transaction just completed on the primary backend 
> without
> incurring the round-trip overhead of an extra SELECT
> pg_current_wal_insert_lsn().
>
> To address this, the attached patch introduces an extensible ReadyForQuery 
> ('Z')
> frontend/backend wire protocol message format and a corresponding C hook
> (ready_for_query_hook).
>
> Key highlights:
>
> 1. GUC Parameter: ready_for_query_message
>    - Enum with values 'plain' (default) and 'rich'.
>    - Context PGC_USERSET with GUC_REPORT.
>    - When 'plain', the protocol message is completely unchanged (5-byte 
> length).
>    - When 'rich', the message length header is expanded to include additional
>      length-prefixed key-value pairs:
>      [uint8 key_len][key_bytes][uint8 val_len][val_bytes].
>
> QUESTION: do we want a more detailed setting, like
>   SET ready_for_query_message = "rich:THL"
>   to sent only these three?
>
> 2. Built-in Session State Indicators (in 'rich' mode):
>    - 'T': "1" if temporary tables/schemas are active, "0" otherwise
> (O(1) check).
>    - 'H': "1" if active WITH HOLD cursors exist in the session, "0" otherwise.
>    - 'P': "1" if active 2PC prepared transactions exist, "0" otherwise.
>    - 'L': Last transaction commit LSN as text (from XactLastCommitEnd).
>
> 3. C Hook: ready_for_query_hook
>    - Declared as: typedef void (*ready_for_query_hook_type) (StringInfo buf);
>    - Extensions can register this hook and use ready_for_query_has_key()
>      and ready_for_query_append_kv() to add custom contextual metadata
>      (e.g., custom session flags, tenant tags, or replication state).
>
> 4. Protocol Compatibility:
>    - Unless you set ready_for_query_message=rich the protocol does not
> change at all
>    - libpq (fe-protocol3.c) is updated so that clients ignore any trailing
>      extension bytes in ReadyForQuery messages if they receive them, ensuring
>      full forward and backward compatibility.
>
> 5. Tests & Documentation:
>    - Documentation added in doc/src/sgml/protocol.sgml and
> doc/src/sgml/config.sgml.
>    - Test module src/test/modules/test_ready_for_query includes both
> SQL regression
>      and TAP test suites validating standard/rich modes, startup options,
>      temp tables, cursors, 2PC, LSN, and hook injection.
>
> The patch applies cleanly against current master. Feedback and suggestions are
> welcome.
>
> Regards,
> Hannu Krosing
From dfcc2d3b888373dffcc297b487b72efd91ef3585 Mon Sep 17 00:00:00 2001
From: Hannu Krosing <[email protected]>
Date: Wed, 2 Sep 2026 13:40:37 +0000
Subject: [PATCH] Extend ReadyForQuery wire protocol message and provide C hook

This commit introduces an extensible ReadyForQuery ('Z') frontend/backend
protocol message format and a C hook (ready_for_query_hook) allowing
extensions to append contextual session metadata to the wire protocol.

Under the default setting (ready_for_query_message = 'plain'), the
ReadyForQuery message remains the standard 5-byte packet containing only
the transaction status indicator ('I', 'T', 'E').

When ready_for_query_message = 'rich', the message length is updated and
the message body is extended with length-prefixed key-value pairs
[uint8 key_len][key_bytes][uint8 val_len][val_bytes]. Built-in status
indicators include:
- 'T': Temporary tables or active temporary namespace present ('1'/'0')
- 'H': Active WITH HOLD cursors present ('1'/'0')
- 'P': Active two-phase prepared transactions present ('1'/'0')
- 'L': Last transaction commit LSN as text (e.g. 0/1A2B3C)

Extensions can register ready_for_query_hook to append additional
key-value pairs, using ready_for_query_has_key() to check existing keys
and ready_for_query_append_kv() to add pairs.

libpq is updated to ignore trailing extension bytes in ReadyForQuery
messages, preserving full backward and forward compatibility.

Includes documentation and test module test_ready_for_query with TAP
and SQL regression tests.
---
 doc/src/sgml/config.sgml                      |  20 +++
 doc/src/sgml/protocol.sgml                    |  42 ++++-
 src/backend/access/transam/twophase.c         |  18 ++
 src/backend/tcop/dest.c                       | 100 +++++++++++
 src/backend/utils/misc/guc_parameters.dat     |   8 +
 src/backend/utils/misc/guc_tables.c           |   7 +
 src/backend/utils/misc/postgresql.conf.sample |   1 +
 src/backend/utils/mmgr/portalmem.c            |  29 +++
 src/include/access/twophase.h                 |   1 +
 src/include/tcop/dest.h                       |  19 ++
 src/include/utils/portal.h                    |   1 +
 src/interfaces/libpq/fe-protocol3.c           |  15 +-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/meson.build                  |   1 +
 .../modules/test_ready_for_query/Makefile     |  23 +++
 .../expected/test_ready_for_query.out         |  25 +++
 .../modules/test_ready_for_query/meson.build  |  38 ++++
 .../sql/test_ready_for_query.sql              |   8 +
 .../t/001_ready_for_query.pl                  | 169 ++++++++++++++++++
 .../test_ready_for_query--1.0.sql             |   4 +
 .../test_ready_for_query.c                    |  53 ++++++
 .../test_ready_for_query.control              |   4 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   4 +-
 23 files changed, 584 insertions(+), 7 deletions(-)
 create mode 100644 src/test/modules/test_ready_for_query/Makefile
 create mode 100644 src/test/modules/test_ready_for_query/expected/test_ready_for_query.out
 create mode 100644 src/test/modules/test_ready_for_query/meson.build
 create mode 100644 src/test/modules/test_ready_for_query/sql/test_ready_for_query.sql
 create mode 100644 src/test/modules/test_ready_for_query/t/001_ready_for_query.pl
 create mode 100644 src/test/modules/test_ready_for_query/test_ready_for_query--1.0.sql
 create mode 100644 src/test/modules/test_ready_for_query/test_ready_for_query.c
 create mode 100644 src/test/modules/test_ready_for_query/test_ready_for_query.control

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index e537b3b0043..965438ddb53 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -11776,6 +11776,26 @@ dynamic_library_path = '/usr/local/lib/postgresql:$libdir'
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-ready-for-query-message" xreflabel="ready_for_query_message">
+      <term><varname>ready_for_query_message</varname> (<type>enum</type>)
+      <indexterm>
+       <primary><varname>ready_for_query_message</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Controls the format of the frontend/backend protocol <literal>ReadyForQuery</literal>
+        (<literal>'Z'</literal>) message. When set to <literal>plain</literal> (the default),
+        the standard 5-byte message containing only the transaction status indicator is sent.
+        When set to <literal>rich</literal>, the message length is extended to append
+        a sequence of length-prefixed key-value pairs carrying session state indicators
+        (such as whether temporary tables, with-hold cursors, or prepared transactions are active,
+        and the last transaction commit LSN) and any data supplied by extensions using
+        <literal>ready_for_query_hook</literal>.
+       </para>
+      </listitem>
+     </varlistentry>
+
      </variablelist>
     </sect2>
    </sect1>
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index 8ced69f283b..6cfae7e6967 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -5911,10 +5911,14 @@ psql "dbname=postgres replication=database" -c "IDENTIFY_SYSTEM;"
       </varlistentry>
 
       <varlistentry>
-       <term>Int32(5)</term>
+       <term>Int32</term>
        <listitem>
         <para>
          Length of message contents in bytes, including self.
+         Standard messages have a length of 5. When extended
+         (such as when <varname>ready_for_query_message</varname> is set to
+         <literal>rich</literal>), the length is 5 plus the length of any
+         additional payload.
         </para>
        </listitem>
       </varlistentry>
@@ -5931,6 +5935,42 @@ psql "dbname=postgres replication=database" -c "IDENTIFY_SYSTEM;"
         </para>
        </listitem>
       </varlistentry>
+
+      <varlistentry>
+       <term>Byte1</term>
+       <listitem>
+        <para>
+         Length of key in bytes. Only present in extended messages.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term>Byte<replaceable>n</replaceable></term>
+       <listitem>
+        <para>
+         Key bytes. Only present in extended messages.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term>Byte1</term>
+       <listitem>
+        <para>
+         Length of value in bytes. Only present in extended messages.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry>
+       <term>Byte<replaceable>n</replaceable></term>
+       <listitem>
+        <para>
+         Value bytes. Only present in extended messages.
+        </para>
+       </listitem>
+      </varlistentry>
      </variablelist>
     </listitem>
    </varlistentry>
diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c
index 48e478a4ecb..ca5f1d7df3e 100644
--- a/src/backend/access/transam/twophase.c
+++ b/src/backend/access/transam/twophase.c
@@ -2878,3 +2878,21 @@ TwoPhaseGetOldestXidInCommit(void)
 
 	return oldestRunningXid;
 }
+
+/*
+ * HasActivePreparedTransactions - check if there are any active prepared 2PC transactions
+ */
+bool
+HasActivePreparedTransactions(void)
+{
+	bool		result;
+
+	if (max_prepared_xacts <= 0 || TwoPhaseState == NULL)
+		return false;
+
+	LWLockAcquire(TwoPhaseStateLock, LW_SHARED);
+	result = (TwoPhaseState->numPrepXacts > 0);
+	LWLockRelease(TwoPhaseStateLock);
+
+	return result;
+}
diff --git a/src/backend/tcop/dest.c b/src/backend/tcop/dest.c
index 4c2b5b87522..1467d11542b 100644
--- a/src/backend/tcop/dest.c
+++ b/src/backend/tcop/dest.c
@@ -30,7 +30,10 @@
 
 #include "access/printsimple.h"
 #include "access/printtup.h"
+#include "access/twophase.h"
 #include "access/xact.h"
+#include "access/xlog.h"
+#include "catalog/namespace.h"
 #include "commands/copy.h"
 #include "commands/createas.h"
 #include "commands/explain_dr.h"
@@ -40,6 +43,13 @@
 #include "executor/tstoreReceiver.h"
 #include "libpq/libpq.h"
 #include "libpq/pqformat.h"
+#include "utils/portal.h"
+
+/* GUC variable */
+int			ready_for_query_message = READY_FOR_QUERY_PLAIN;
+
+/* Hook for ReadyForQuery */
+ready_for_query_hook_type ready_for_query_hook = NULL;
 
 
 /* ----------------
@@ -253,6 +263,87 @@ NullCommand(CommandDest dest)
 	}
 }
 
+/*
+ * ready_for_query_has_key
+ *		Check if the specified key is already present in the ReadyForQuery message buffer.
+ */
+bool
+ready_for_query_has_key(StringInfo buf, const char *key, uint8 key_len)
+{
+	int			offset = 1;		/* Skip the initial 1-byte TxStatus indicator */
+
+	while (offset < buf->len)
+	{
+		uint8		klen = (uint8) buf->data[offset++];
+		uint8		vlen;
+
+		if (offset + klen > buf->len)
+			break;
+
+		if (klen == key_len && memcmp(&buf->data[offset], key, key_len) == 0)
+			return true;
+
+		offset += klen;
+		if (offset >= buf->len)
+			break;
+
+		vlen = (uint8) buf->data[offset++];
+
+		offset += vlen;
+	}
+
+	return false;
+}
+
+/*
+ * ready_for_query_append_kv
+ *		Append a key-value pair to the ReadyForQuery message buffer.
+ */
+void
+ready_for_query_append_kv(StringInfo buf,
+						  const char *key, uint8 key_len,
+						  const char *val, uint8 val_len)
+{
+	pq_sendbyte(buf, key_len);
+	pq_sendbytes(buf, key, key_len);
+	pq_sendbyte(buf, val_len);
+	if (val_len > 0)
+		pq_sendbytes(buf, val, val_len);
+}
+
+/*
+ * append_builtin_ready_for_query_status
+ *		Append built-in single-character session status indicators to ReadyForQuery buffer.
+ */
+static void
+append_builtin_ready_for_query_status(StringInfo buf)
+{
+	Oid			tempNamespaceId,
+				tempToastNamespaceId;
+	bool		has_temp;
+	bool		has_cursors;
+	bool		has_prepared;
+	char		lsn_buf[32];
+	int			lsn_len;
+
+	/* 1. Temporary tables / schema ('T') */
+	GetTempNamespaceState(&tempNamespaceId, &tempToastNamespaceId);
+	has_temp = OidIsValid(tempNamespaceId);
+	ready_for_query_append_kv(buf, "T", 1, has_temp ? "1" : "0", 1);
+
+	/* 2. With-hold cursors ('H') */
+	has_cursors = HasActiveWithHoldCursors();
+	ready_for_query_append_kv(buf, "H", 1, has_cursors ? "1" : "0", 1);
+
+	/* 3. Prepared transactions ('P') */
+	has_prepared = HasActivePreparedTransactions();
+	ready_for_query_append_kv(buf, "P", 1, has_prepared ? "1" : "0", 1);
+
+	/* 4. Last Commit LSN ('L') */
+	lsn_len = snprintf(lsn_buf, sizeof(lsn_buf), "%X/%X", LSN_FORMAT_ARGS(XactLastCommitEnd));
+	ready_for_query_append_kv(buf, "L", 1, lsn_buf, (uint8) lsn_len);
+}
+
 /* ----------------
  *		ReadyForQuery - tell dest that we are ready for a new query
  *
@@ -277,6 +368,15 @@ ReadyForQuery(CommandDest dest)
 
 				pq_beginmessage(&buf, PqMsg_ReadyForQuery);
 				pq_sendbyte(&buf, TransactionBlockStatusCode());
+
+				if (ready_for_query_message == READY_FOR_QUERY_RICH)
+				{
+					append_builtin_ready_for_query_status(&buf);
+
+					if (ready_for_query_hook != NULL)
+						ready_for_query_hook(&buf);
+				}
+
 				pq_endmessage(&buf);
 			}
 			/* Flush output at end of cycle in any case. */
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index 380679a76c0..df69b9bddac 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -2438,6 +2438,14 @@
   max => 'DBL_MAX',
 },
 
+{ name => 'ready_for_query_message', type => 'enum', context => 'PGC_USERSET', group => 'CLIENT_CONN_OTHER',
+  short_desc => 'Sets the format of the ReadyForQuery protocol message.',
+  flags => 'GUC_REPORT',
+  variable => 'ready_for_query_message',
+  boot_val => 'READY_FOR_QUERY_PLAIN',
+  options => 'ready_for_query_message_options',
+},
+
 { name => 'recovery_end_command', type => 'string', context => 'PGC_SIGHUP', group => 'WAL_ARCHIVE_RECOVERY',
   short_desc => 'Sets the shell command that will be executed once at the end of recovery.',
   variable => 'recoveryEndCommand',
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 342aaeef59a..9145fc398d2 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -90,6 +90,7 @@
 #include "storage/procnumber.h"
 #include "storage/standby.h"
 #include "tcop/backend_startup.h"
+#include "tcop/dest.h"
 #include "tcop/tcopprot.h"
 #include "portability/instr_time.h"
 #include "tsearch/ts_cache.h"
@@ -430,6 +431,12 @@ static const struct config_enum_entry password_encryption_options[] = {
 	{NULL, 0, false}
 };
 
+static const struct config_enum_entry ready_for_query_message_options[] = {
+	{"plain", READY_FOR_QUERY_PLAIN, false},
+	{"rich", READY_FOR_QUERY_RICH, false},
+	{NULL, 0, false}
+};
+
 static const struct config_enum_entry ssl_protocol_versions_info[] = {
 	{"", PG_TLS_ANY, false},
 	{"TLSv1", PG_TLS1_VERSION, false},
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 01f98de8a9f..d50ba33bae7 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -869,6 +869,7 @@
 #extension_control_path = '$system'
 #gin_fuzzy_search_limit = 0
 #output_plugin_libraries = 'pgoutput, test_decoding'  # approved plugins for logical decoding
+#ready_for_query_message = plain        # plain or rich
 
 
 #------------------------------------------------------------------------------
diff --git a/src/backend/utils/mmgr/portalmem.c b/src/backend/utils/mmgr/portalmem.c
index c7a28b080d4..6da2c8c8ba0 100644
--- a/src/backend/utils/mmgr/portalmem.c
+++ b/src/backend/utils/mmgr/portalmem.c
@@ -1191,6 +1191,35 @@ ThereAreNoReadyPortals(void)
 	return true;
 }
 
+/*
+ * HasActiveWithHoldCursors - check if session has any active WITH HOLD cursors
+ */
+bool
+HasActiveWithHoldCursors(void)
+{
+	HASH_SEQ_STATUS status;
+	PortalHashEnt *hentry;
+
+	if (PortalHashTable == NULL)
+		return false;
+
+	hash_seq_init(&status, PortalHashTable);
+
+	while ((hentry = (PortalHashEnt *) hash_seq_search(&status)) != NULL)
+	{
+		Portal		portal = hentry->portal;
+
+		if (portal->status == PORTAL_READY &&
+			(portal->cursorOptions & CURSOR_OPT_HOLD))
+		{
+			hash_seq_term(&status);
+			return true;
+		}
+	}
+
+	return false;
+}
+
 /*
  * Hold all pinned portals.
  *
diff --git a/src/include/access/twophase.h b/src/include/access/twophase.h
index 1d2ff42c9b7..29e26da2a61 100644
--- a/src/include/access/twophase.h
+++ b/src/include/access/twophase.h
@@ -71,5 +71,6 @@ extern void TwoPhaseTransactionGid(Oid subid, TransactionId xid, char *gid_res,
 extern bool LookupGXactBySubid(Oid subid);
 
 extern TransactionId TwoPhaseGetOldestXidInCommit(void);
+extern bool HasActivePreparedTransactions(void);
 
 #endif							/* TWOPHASE_H */
diff --git a/src/include/tcop/dest.h b/src/include/tcop/dest.h
index 507414421ec..1e97392b1eb 100644
--- a/src/include/tcop/dest.h
+++ b/src/include/tcop/dest.h
@@ -142,9 +142,28 @@ extern void EndCommand(const QueryCompletion *qc, CommandDest dest,
 					   bool force_undecorated_output);
 extern void EndReplicationCommand(const char *commandTag);
 
+#include "lib/stringinfo.h"
+
+typedef enum ReadyForQueryMessageFormat
+{
+	READY_FOR_QUERY_PLAIN,
+	READY_FOR_QUERY_RICH,
+} ReadyForQueryMessageFormat;
+
+/* GUC variable */
+extern PGDLLIMPORT int ready_for_query_message;
+
+/* Hook for ReadyForQuery */
+typedef void (*ready_for_query_hook_type) (StringInfo buf);
+extern PGDLLIMPORT ready_for_query_hook_type ready_for_query_hook;
+
 /* Additional functions that go with destination management, more or less. */
 
 extern void NullCommand(CommandDest dest);
 extern void ReadyForQuery(CommandDest dest);
+extern bool ready_for_query_has_key(StringInfo buf, const char *key, uint8 key_len);
+extern void ready_for_query_append_kv(StringInfo buf,
+									  const char *key, uint8 key_len,
+									  const char *val, uint8 val_len);
 
 #endif							/* DEST_H */
diff --git a/src/include/utils/portal.h b/src/include/utils/portal.h
index a7bedb12c18..cd4c05230e7 100644
--- a/src/include/utils/portal.h
+++ b/src/include/utils/portal.h
@@ -245,6 +245,7 @@ extern PlannedStmt *PortalGetPrimaryStmt(Portal portal);
 extern void PortalCreateHoldStore(Portal portal);
 extern void PortalHashTableDeleteAll(void);
 extern bool ThereAreNoReadyPortals(void);
+extern bool HasActiveWithHoldCursors(void);
 extern void HoldPinnedPortals(void);
 extern void ForgetPortalSnapshots(void);
 
diff --git a/src/interfaces/libpq/fe-protocol3.c b/src/interfaces/libpq/fe-protocol3.c
index 1fd427cb9be..1dc4f5dc87a 100644
--- a/src/interfaces/libpq/fe-protocol3.c
+++ b/src/interfaces/libpq/fe-protocol3.c
@@ -55,7 +55,7 @@ static int	getParameterStatus(PGconn *conn);
 static int	getBackendKeyData(PGconn *conn, int msgLength);
 static int	getNotify(PGconn *conn);
 static int	getCopyStart(PGconn *conn, ExecStatusType copytype);
-static int	getReadyForQuery(PGconn *conn);
+static int	getReadyForQuery(PGconn *conn, int msgLength);
 static void reportErrorPosition(PQExpBuffer msg, const char *query,
 								int loc, int encoding);
 static size_t build_startup_packet(const PGconn *conn, char *packet,
@@ -226,7 +226,7 @@ pqParseInput3(PGconn *conn)
 					conn->asyncStatus = PGASYNC_READY;
 					break;
 				case PqMsg_ReadyForQuery:
-					if (getReadyForQuery(conn))
+					if (getReadyForQuery(conn, msgLength))
 						return;
 					if (conn->pipelineStatus != PQ_PIPELINE_OFF)
 					{
@@ -1773,7 +1773,7 @@ failure:
  * getReadyForQuery - process ReadyForQuery message
  */
 static int
-getReadyForQuery(PGconn *conn)
+getReadyForQuery(PGconn *conn, int msgLength)
 {
 	char		xact_status;
 
@@ -1795,6 +1795,13 @@ getReadyForQuery(PGconn *conn)
 			break;
 	}
 
+	/*
+	 * Skip any extension data so clients ignore payload they are not
+	 * prepared to receive.
+	 */
+	if (msgLength > 1)
+		conn->inCursor += (msgLength - 1);
+
 	return 0;
 }
 
@@ -2346,7 +2353,7 @@ pqFunctionCall3(PGconn *conn, Oid fnid,
 					continue;
 				break;
 			case PqMsg_ReadyForQuery:
-				if (getReadyForQuery(conn))
+				if (getReadyForQuery(conn, msgLength))
 					continue;
 
 				/* consume the message */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 71a2e65ad70..84702bbfc95 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -46,6 +46,7 @@ SUBDIRS = \
 		  test_predtest \
 		  test_radixtree \
 		  test_rbtree \
+		  test_ready_for_query \
 		  test_regex \
 		  test_resowner \
 		  test_rls_hooks \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 77e1a2810e5..a9b160f8799 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -47,6 +47,7 @@ subdir('test_plan_advice')
 subdir('test_predtest')
 subdir('test_radixtree')
 subdir('test_rbtree')
+subdir('test_ready_for_query')
 subdir('test_regex')
 subdir('test_resowner')
 subdir('test_rls_hooks')
diff --git a/src/test/modules/test_ready_for_query/Makefile b/src/test/modules/test_ready_for_query/Makefile
new file mode 100644
index 00000000000..f8fc07eec58
--- /dev/null
+++ b/src/test/modules/test_ready_for_query/Makefile
@@ -0,0 +1,23 @@
+# src/test/modules/test_ready_for_query/Makefile
+
+MODULE_big = test_ready_for_query
+OBJS = \
+	$(WIN32RES) \
+	test_ready_for_query.o
+PGFILEDESC = "test_ready_for_query - test code for ReadyForQuery hook"
+
+EXTENSION = test_ready_for_query
+DATA = test_ready_for_query--1.0.sql
+REGRESS = test_ready_for_query
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_ready_for_query
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_ready_for_query/expected/test_ready_for_query.out b/src/test/modules/test_ready_for_query/expected/test_ready_for_query.out
new file mode 100644
index 00000000000..a04a15208e0
--- /dev/null
+++ b/src/test/modules/test_ready_for_query/expected/test_ready_for_query.out
@@ -0,0 +1,25 @@
+SHOW ready_for_query_message;
+ ready_for_query_message 
+-------------------------
+ plain
+(1 row)
+
+SET ready_for_query_message = 'rich';
+SHOW ready_for_query_message;
+ ready_for_query_message 
+-------------------------
+ rich
+(1 row)
+
+SET ready_for_query_message = 'plain';
+SHOW ready_for_query_message;
+ ready_for_query_message 
+-------------------------
+ plain
+(1 row)
+
+SET ready_for_query_message = 'invalid'; -- should fail
+ERROR:  invalid value for parameter "ready_for_query_message": "invalid"
+HINT:  Available values: plain, rich.
+CREATE EXTENSION test_ready_for_query;
+DROP EXTENSION test_ready_for_query;
diff --git a/src/test/modules/test_ready_for_query/meson.build b/src/test/modules/test_ready_for_query/meson.build
new file mode 100644
index 00000000000..8b6073a2f20
--- /dev/null
+++ b/src/test/modules/test_ready_for_query/meson.build
@@ -0,0 +1,38 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+test_ready_for_query_sources = files(
+  'test_ready_for_query.c',
+)
+
+if host_system == 'windows'
+  test_ready_for_query_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_ready_for_query',
+    '--FILEDESC', 'test_ready_for_query - test code for ReadyForQuery hook',])
+endif
+
+test_ready_for_query = shared_module('test_ready_for_query',
+  test_ready_for_query_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_ready_for_query
+
+test_install_data += files(
+  'test_ready_for_query.control',
+  'test_ready_for_query--1.0.sql',
+)
+
+tests += {
+  'name': 'test_ready_for_query',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'test_ready_for_query',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_ready_for_query.pl',
+    ],
+  },
+}
diff --git a/src/test/modules/test_ready_for_query/sql/test_ready_for_query.sql b/src/test/modules/test_ready_for_query/sql/test_ready_for_query.sql
new file mode 100644
index 00000000000..440fdeeba48
--- /dev/null
+++ b/src/test/modules/test_ready_for_query/sql/test_ready_for_query.sql
@@ -0,0 +1,8 @@
+SHOW ready_for_query_message;
+SET ready_for_query_message = 'rich';
+SHOW ready_for_query_message;
+SET ready_for_query_message = 'plain';
+SHOW ready_for_query_message;
+SET ready_for_query_message = 'invalid'; -- should fail
+CREATE EXTENSION test_ready_for_query;
+DROP EXTENSION test_ready_for_query;
diff --git a/src/test/modules/test_ready_for_query/t/001_ready_for_query.pl b/src/test/modules/test_ready_for_query/t/001_ready_for_query.pl
new file mode 100644
index 00000000000..797e31ad410
--- /dev/null
+++ b/src/test/modules/test_ready_for_query/t/001_ready_for_query.pl
@@ -0,0 +1,169 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test ReadyForQuery wire protocol extension and hook
+
+use strict;
+use warnings;
+
+# Set a hard test-level timeout of 60 seconds to detect and handle any hanging condition
+$SIG{ALRM} = sub { die "Test timed out after 60 seconds\n"; };
+alarm(60);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->append_conf('postgresql.conf',
+	"shared_preload_libraries = 'test_ready_for_query'\nmax_prepared_transactions = 10\n"
+);
+$node->start;
+
+# 1. Test SQL-level GUC settings and extension management
+my $guc_val = $node->safe_psql('postgres', 'SHOW ready_for_query_message;');
+is($guc_val, 'plain', 'default GUC value is plain');
+
+$guc_val = $node->safe_psql('postgres', "SET ready_for_query_message = 'rich'; SHOW ready_for_query_message;");
+is($guc_val, 'rich', 'GUC can be set to rich');
+
+$guc_val = $node->safe_psql('postgres', "SET ready_for_query_message = 'plain'; SHOW ready_for_query_message;");
+is($guc_val, 'plain', 'GUC can be reset to plain');
+
+my ($ret, $stdout, $stderr) = $node->psql('postgres', "SET ready_for_query_message = 'invalid';");
+isnt($ret, 0, 'invalid GUC value rejected');
+like($stderr, qr/invalid value for parameter "ready_for_query_message"/, 'error message on invalid GUC value');
+
+$node->safe_psql('postgres', 'CREATE EXTENSION test_ready_for_query;');
+$node->safe_psql('postgres', 'DROP EXTENSION test_ready_for_query;');
+
+# Helper function to read exactly $n bytes from a socket
+sub read_exact
+{
+	my ($sock, $len) = @_;
+	my $buf = '';
+	while (length($buf) < $len)
+	{
+		my $chunk;
+		my $n = $sock->sysread($chunk, $len - length($buf));
+		die "unexpected EOF on socket" if !defined($n) || $n == 0;
+		$buf .= $chunk;
+	}
+	return $buf;
+}
+
+# Helper function to send simple query 'Q' message
+sub send_query
+{
+	my ($sock, $sql) = @_;
+	my $payload = $sql . "\0";
+	my $len = 4 + length($payload);
+	my $msg = 'Q' . pack('N', $len) . $payload;
+	$sock->syswrite($msg);
+}
+
+# Helper function to read messages until ReadyForQuery ('Z')
+sub read_until_ready_for_query
+{
+	my ($sock) = @_;
+	while (1)
+	{
+		my $mtype = read_exact($sock, 1);
+		my $raw_len = read_exact($sock, 4);
+		my $mlen = unpack('N', $raw_len);
+		my $body_len = $mlen - 4;
+		my $body = $body_len > 0 ? read_exact($sock, $body_len) : '';
+
+		if ($mtype eq 'Z')
+		{
+			my $tx_status = substr($body, 0, 1);
+			my %kv = ();
+			my $offset = 1;
+			while ($offset < length($body))
+			{
+				my $klen = ord(substr($body, $offset++, 1));
+				my $key = substr($body, $offset, $klen);
+				$offset += $klen;
+				my $vlen = ord(substr($body, $offset++, 1));
+				my $val = $vlen > 0 ? substr($body, $offset, $vlen) : '';
+				$offset += $vlen;
+				$kv{$key} = $val;
+			}
+			return ($mlen, $tx_status, \%kv);
+		}
+	}
+}
+
+if (!$node->raw_connect_works())
+{
+	plan skip_all => "this test requires working raw_connect()";
+}
+
+# 2. Connect via raw socket and perform StartupPacket
+my $dbuser = $node->safe_psql('postgres', 'SELECT current_user;');
+my $sock = $node->raw_connect();
+
+my $startup_body = pack('N', 196608) . "user\0$dbuser\0database\0postgres\0\0";
+my $startup_msg = pack('N', 4 + length($startup_body)) . $startup_body;
+$sock->syswrite($startup_msg);
+
+# Read initial ReadyForQuery (default 'plain' mode)
+my ($len1, $tx1, $kv1) = read_until_ready_for_query($sock);
+is($len1, 5, "default ReadyForQuery message length is 5 (plain mode)");
+is($tx1, 'I', "initial transaction status is Idle ('I')");
+is(scalar(keys %$kv1), 0, "no extra key-value pairs in plain mode");
+
+# 3. Switch to 'rich' mode
+send_query($sock, "SET ready_for_query_message = 'rich';");
+my ($len2, $tx2, $kv2) = read_until_ready_for_query($sock);
+cmp_ok($len2, '>', 5, "rich mode ReadyForQuery message length > 5");
+is($tx2, 'I', "transaction status is Idle ('I')");
+is($kv2->{'T'}, '0', "T=0 when no temp tables exist");
+is($kv2->{'H'}, '0', "H=0 when no with-hold cursors exist");
+is($kv2->{'P'}, '0', "P=0 when no prepared transactions exist");
+ok(defined $kv2->{'L'}, "L (LSN) key is present in rich mode");
+
+# 4. Create temporary table and verify 'T' and hook-injected 'temp_tables_info'
+send_query($sock, "CREATE TEMP TABLE t_temp (id int);");
+my ($len3, $tx3, $kv3) = read_until_ready_for_query($sock);
+is($kv3->{'T'}, '1', "T=1 after temporary table creation");
+is($kv3->{'temp_tables_info'}, 'has_temp_namespace', "hook injected temp_tables_info key");
+
+# 5. Create WITH HOLD cursor and verify 'H' and commit LSN 'L'
+send_query($sock, "BEGIN; DECLARE cur CURSOR WITH HOLD FOR SELECT 1; COMMIT;");
+my ($len4, $tx4, $kv4) = read_until_ready_for_query($sock);
+is($kv4->{'H'}, '1', "H=1 after creating cursor WITH HOLD");
+isnt($kv4->{'L'}, '0/0', "commit LSN is non-zero after COMMIT");
+
+# 6. Two-phase prepared transaction 'P'
+send_query($sock, "BEGIN; CREATE TABLE t_prep(x int); PREPARE TRANSACTION 't1';");
+my ($len_prep, $tx_prep, $kv_prep) = read_until_ready_for_query($sock);
+is($kv_prep->{'P'}, '1', "P=1 after PREPARE TRANSACTION");
+
+send_query($sock, "COMMIT PREPARED 't1';");
+my ($len_comm, $tx_comm, $kv_comm) = read_until_ready_for_query($sock);
+is($kv_comm->{'P'}, '0', "P=0 after COMMIT PREPARED");
+
+# 7. Switch back to 'plain' mode
+send_query($sock, "SET ready_for_query_message = 'plain';");
+my ($len5, $tx5, $kv5) = read_until_ready_for_query($sock);
+is($len5, 5, "reverting to plain mode restores ReadyForQuery length to 5");
+is(scalar(keys %$kv5), 0, "no extra key-value pairs in plain mode");
+
+# 8. Test with StartupPacket containing options="-c ready_for_query_message=rich"
+my $sock2 = $node->raw_connect();
+my $startup_body2 = pack('N', 196608) . "user\0$dbuser\0database\0postgres\0options\0-c ready_for_query_message=rich\0\0";
+my $startup_msg2 = pack('N', 4 + length($startup_body2)) . $startup_body2;
+$sock2->syswrite($startup_msg2);
+
+my ($len_opt, $tx_opt, $kv_opt) = read_until_ready_for_query($sock2);
+cmp_ok($len_opt, '>', 5, "startup option enables rich mode on initial ReadyForQuery");
+is($kv_opt->{'T'}, '0', "T=0 on fresh session");
+is($kv_opt->{'H'}, '0', "H=0 on fresh session");
+
+close($sock);
+close($sock2);
+$node->stop;
+
+alarm(0);
+done_testing();
diff --git a/src/test/modules/test_ready_for_query/test_ready_for_query--1.0.sql b/src/test/modules/test_ready_for_query/test_ready_for_query--1.0.sql
new file mode 100644
index 00000000000..d80017aa76f
--- /dev/null
+++ b/src/test/modules/test_ready_for_query/test_ready_for_query--1.0.sql
@@ -0,0 +1,4 @@
+/* src/test/modules/test_ready_for_query/test_ready_for_query--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_ready_for_query" to load this file. \quit
diff --git a/src/test/modules/test_ready_for_query/test_ready_for_query.c b/src/test/modules/test_ready_for_query/test_ready_for_query.c
new file mode 100644
index 00000000000..614589311a5
--- /dev/null
+++ b/src/test/modules/test_ready_for_query/test_ready_for_query.c
@@ -0,0 +1,53 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_ready_for_query.c
+ *		Test code for ReadyForQuery hook and wire protocol extension.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/test_ready_for_query/test_ready_for_query.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "catalog/namespace.h"
+#include "fmgr.h"
+#include "tcop/dest.h"
+
+PG_MODULE_MAGIC;
+
+static ready_for_query_hook_type prev_ready_for_query_hook = NULL;
+
+static void
+test_ready_for_query_hook(StringInfo buf)
+{
+	if (prev_ready_for_query_hook)
+		prev_ready_for_query_hook(buf);
+
+	/*
+	 * Example extension behavior: check if "temp_tables_info" is not already present,
+	 * and if the session has a temporary namespace, append it.
+	 */
+	if (!ready_for_query_has_key(buf, "temp_tables_info", 16))
+	{
+		Oid			tempNamespaceId,
+					tempToastNamespaceId;
+
+		GetTempNamespaceState(&tempNamespaceId, &tempToastNamespaceId);
+		if (OidIsValid(tempNamespaceId))
+		{
+			const char *info = "has_temp_namespace";
+
+			ready_for_query_append_kv(buf, "temp_tables_info", 16, info, (uint8) strlen(info));
+		}
+	}
+}
+
+void
+_PG_init(void)
+{
+	prev_ready_for_query_hook = ready_for_query_hook;
+	ready_for_query_hook = test_ready_for_query_hook;
+}
diff --git a/src/test/modules/test_ready_for_query/test_ready_for_query.control b/src/test/modules/test_ready_for_query/test_ready_for_query.control
new file mode 100644
index 00000000000..75741c2afcb
--- /dev/null
+++ b/src/test/modules/test_ready_for_query/test_ready_for_query.control
@@ -0,0 +1,4 @@
+comment = 'Test module for ReadyForQuery hook'
+default_version = '1.0'
+module_pathname = '$libdir/test_ready_for_query'
+relocatable = true
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index 920d831be9e..9724b5a5384 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -639,7 +639,7 @@ sub init
 	$params{has_archiving} = 0 unless defined $params{has_archiving};
 
 	my $initdb_extra_opts_env = $ENV{PG_TEST_INITDB_EXTRA_OPTS};
-	if (defined $initdb_extra_opts_env)
+	if (defined $initdb_extra_opts_env && length($initdb_extra_opts_env) > 0)
 	{
 		push @{ $params{extra} }, shellwords($initdb_extra_opts_env);
 	}
@@ -670,7 +670,7 @@ sub init
 			'initdb', '--no-sync',
 			'--pgdata' => $pgdata,
 			'--auth' => 'trust',
-			@{ $params{extra} });
+			(defined $params{extra} ? @{ $params{extra} } : ()));
 	}
 	else
 	{
-- 
2.56.0.360.g66cac248cb-goog

Reply via email to