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 f40e9e4fe99caf9b7fdda6881ad6039df04a050e 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/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                  | 164 ++++++++++++++++++
 .../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 +-
 22 files changed, 578 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/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..c7ca7da964d
--- /dev/null
+++ b/src/test/modules/test_ready_for_query/t/001_ready_for_query.pl
@@ -0,0 +1,164 @@
+# 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);
+		}
+	}
+}
+
+# 2. Connect via raw socket and perform StartupPacket
+my $sock = $node->raw_connect();
+my $dbuser = $ENV{PGUSER} || $ENV{USER} || 'postgres';
+
+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