Hi hackers,

Attached is version 3 of the patch to extend the PostgreSQL ReadyForQuery
('Z') frontend/backend wire protocol message and introduce the
ready_for_query_hook C API.

This version incorporates the benchmarking measurements and review
feedback provided by Manu on v2.


Background & Motivation
-----------------------

Connection poolers operating in transaction-pooling mode (e.g. PgBouncer,
pgpool-II, Odyssey) must determine whether a backend connection is safe
to return to the shared pool or reuse for another client without executing
a costly DISCARD ALL round-trip. Similarly, proxies and applications routing
read-after-write queries to standby replicas need the primary's commit LSN
to coordinate with pg_wal_replay_wait() / WAIT FOR LSN without an extra
round-trip query.

This patch allows clients, proxies, and connection poolers to opt in to
a rich ReadyForQuery message containing session state indicators and the
transaction commit LSN, while keeping the default plain mode 100%
byte-identical to master.


Changes in v3 (Addressing Manu's Review)
----------------------------------------

1. Wire Purity in Plain Mode (Removed GUC_REPORT):
   ready_for_query_message no longer has GUC_REPORT. In plain mode, the
   connection startup handshake sends zero additional ParameterStatus
   messages, ensuring startup wire bytes are byte-identical to master.

2. Accurate Temporary Table Lifecycle ('T'):
   In v2, 'T' remained 1 even after temporary objects were removed. In
   v3, 'T' reliably resets to 0 after DROP TABLE, DISCARD TEMP, and
   DISCARD ALL.
   The catalog scan on pg_depend is executed at transaction pre-commit
   (PreCommit_Namespace()) using SnapshotSelf, and only when rich mode
   is enabled AND the transaction actually accessed temporary objects
   (XACT_FLAGS_ACCESSEDTEMPNAMESPACE). Normal queries that do not touch
   temporary objects incur zero catalog scan overhead.
   Added assign_ready_for_query_message() GUC hook so that if
   ready_for_query_message is switched from plain to rich mid-session,
   any pre-existing temporary tables are immediately detected.

3. Session-Local Prepared Statements ('P'):
   In v2, 'P' reflected cluster-wide two-phase commit transactions
   (TwoPhaseState). In v3, 'P' tracks session-local active prepared
   statements (O(1) check via HasActivePreparedStatements()).
   All two-phase transaction changes from previous versions have been
   reverted. 'P' correctly reflects PREPARE, DEALLOCATE, and DISCARD ALL.

4. Binary Commit LSN ('l'):
   Replaced the string key 'L' (formatted with %X/%X) with binary key
   'l', transmitting XactLastCommitEnd as an 8-byte unsigned integer in
   network byte order (big-endian via pg_hton64). This avoids string
   parsing overhead and padding discrepancies across platforms.

5. O(1) With-Hold Cursors ('H'):
   In v2, the 'H' check traversed the full PortalHashTable on every
   ReadyForQuery, scaling as O(cursors). In v3, portalmem.c maintains an
   O(1) active_with_hold_portal_count, making the check strictly O(1) with
   no table walks.

6. PQtrace() / fe-trace.c Compatibility:
   Updated fe-trace.c (pqTraceOutput_ReadyForQuery) to parse and display
   extension key-value pairs without triggering "mismatched message length"
   warnings. All libpq_pipeline trace tests now pass under both plain and
   rich modes.

7. Documentation on Unpatched Client Compatibility:
   Retained PGC_USERSET for flexibility. Added explicit notes in
   config.sgml and protocol.sgml clarifying that unpatched client
   libraries will reject extended ReadyForQuery messages with length
   mismatch errors, so rich mode should only be enabled in sessions
   connecting via compatible clients or connection poolers.

8. Cleaned Tree:
   Reverted unrelated changes in src/test/perl/PostgreSQL/Test/Cluster.pm.


Verification & Testing
----------------------

- test_ready_for_query: All 42 subtests in t/001_ready_for_query.pl pass,
  verifying:
  * Startup wire purity (no extra ParameterStatus in plain mode).
  * Accurate 'T' transitions for CREATE TEMP TABLE, DROP TABLE,
    DISCARD TEMP, and DISCARD ALL.
  * O(1) 'H' behavior with 100 cursors inside a transaction and cursors
    declared WITH HOLD.
  * Binary 'l' unpacking and value matching.
  * 'P' tracking of PREPARE, DEALLOCATE, and DISCARD ALL.
  * Mid-session GUC switching via assign_ready_for_query_message().
- libpq_pipeline: All 24 tests pass cleanly.
- make check-world: Passed 100% across the full PostgreSQL test suite
  with zero assertion failures, memory leaks, or ResourceOwner leaks.

The patch applies cleanly against current master (061065e28f).

Git branch: ready_for_query_hook (db3fd1023de) on
https://github.com/postsql/postgres.git.

Feedback and reviews are welcome!

Regards,
Hannu


On Fri, Oct 9, 2026 at 1:36 AM Manu <[email protected]> wrote:
>
> Hi Hannu,
>
> Glad it was useful.
>
> > Hmm, this is what I get on PostgreSQL 16.4
> > [...] 0/17F5830
>
> On master pg_lsn now zero-pads the low word -- commit 2633dae2e48
> ("Standardize LSN formatting by zero padding", Jul 2025) made
> pg_lsn_out use %X/%08X.  16.4, 17 and 18 still use %X, so L matches
> pg_lsn there; on master it does not, since the patch formats L with
> %X.  If L is meant to mirror pg_lsn, %08X would track the new format.
>
> Happy to re-test once you post the next version.
>
> Regards,
> Manu
From db3fd1023de82510b8f8cc36aa5574ba4fde98ce Mon Sep 17 00:00:00 2001
From: Hannu Krosing <[email protected]>
Date: Wed, 2 Sep 2026 13:40:37 +0000
Subject: [PATCH v3] 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'), with no extra
ParameterStatus messages sent during connection startup.

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 present in the session ('1'/'0'), resetting to
  '0' when all temporary objects are dropped (via DROP TABLE, DISCARD TEMP,
  or DISCARD ALL).
- 'H': Active WITH HOLD cursors present ('1'/'0'), maintained via an O(1)
  counter.
- 'P': Active session-local prepared statements present ('1'/'0').
- 'l': Last transaction commit LSN as an 8-byte unsigned integer in
  network byte order (big-endian).

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 (fe-protocol3.c and fe-trace.c) is updated to support extended
ReadyForQuery messages: fe-protocol3 ignores any trailing extension bytes,
and fe-trace consumes the key-value pairs without length-mismatch warnings.

Includes documentation and test module test_ready_for_query with TAP
and SQL regression tests.
---
 doc/src/sgml/config.sgml                      |  29 ++
 doc/src/sgml/protocol.sgml                    |  51 +++-
 src/backend/access/transam/xact.c             |   1 +
 src/backend/catalog/namespace.c               |  82 ++++++
 src/backend/commands/prepare.c                |  12 +
 src/backend/tcop/dest.c                       | 108 ++++++++
 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            |  20 ++
 src/include/catalog/namespace.h               |   3 +
 src/include/commands/prepare.h                |   1 +
 src/include/tcop/dest.h                       |  19 ++
 src/include/utils/guc_hooks.h                 |   1 +
 src/include/utils/portal.h                    |   1 +
 src/interfaces/libpq/fe-protocol3.c           |  15 +-
 src/interfaces/libpq/fe-trace.c               |  18 +-
 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                  | 255 ++++++++++++++++++
 .../test_ready_for_query--1.0.sql             |   4 +
 .../test_ready_for_query.c                    |  49 ++++
 .../test_ready_for_query.control              |   4 +
 27 files changed, 778 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..ebf370b47bc 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -11776,6 +11776,35 @@ 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:
+        <literal>T</literal> (whether temporary tables are present in the session, <literal>1</literal> or <literal>0</literal>),
+        <literal>H</literal> (whether active cursors declared <literal>WITH HOLD</literal> are present, <literal>1</literal> or <literal>0</literal>),
+        <literal>P</literal> (whether active prepared statements are present, <literal>1</literal> or <literal>0</literal>),
+        and <literal>l</literal> (the last transaction commit LSN as an 8-byte unsigned integer in network byte order),
+        along with any additional key-value pairs supplied by extensions using <literal>ready_for_query_hook</literal>.
+       </para>
+       <para>
+        This parameter can be changed at any time by any user. However, client libraries
+        that do not support extended ReadyForQuery messages will fail with a message length
+        mismatch error if this parameter is enabled. It should therefore only be enabled
+        in sessions connecting through clients or connection poolers that support the
+        extended ReadyForQuery format.
+       </para>
+      </listitem>
+     </varlistentry>
+
      </variablelist>
     </sect2>
    </sect1>
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index 8ced69f283b..510a957fcd8 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,7 +5935,52 @@ 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>
+     <para>
+      When <varname>ready_for_query_message</varname> is set to <literal>rich</literal>,
+      the server includes built-in session state indicators:
+      <literal>T</literal> indicates whether temporary tables are present in the session (<literal>1</literal> for yes, <literal>0</literal> for no);
+      <literal>H</literal> indicates whether active cursors declared <literal>WITH HOLD</literal> are present (<literal>1</literal> for yes, <literal>0</literal> for no);
+      <literal>P</literal> indicates whether active prepared statements are present in the session (<literal>1</literal> for yes, <literal>0</literal> for no);
+      and <literal>l</literal> contains the last transaction commit LSN as an 8-byte unsigned integer in network byte order (big-endian).
+      Extensions can register <literal>ready_for_query_hook</literal> to append further key-value pairs.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index cab99f25e1e..b151fbab527 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -2360,6 +2360,7 @@ CommitTransaction(void)
 	 * cursors, to avoid dangling-reference problems)
 	 */
 	PreCommit_on_commit_actions();
+	PreCommit_Namespace();
 
 	/*
 	 * Synchronize files that are created and not WAL-logged during this
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index 0647a198dea..6d90838d4e3 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -19,14 +19,18 @@
  */
 #include "postgres.h"
 
+#include "access/genam.h"
 #include "access/htup_details.h"
 #include "access/parallel.h"
+#include "access/table.h"
 #include "access/xact.h"
 #include "access/xlog.h"
 #include "catalog/dependency.h"
+#include "catalog/indexing.h"
 #include "catalog/namespace.h"
 #include "catalog/objectaccess.h"
 #include "catalog/pg_authid.h"
+#include "catalog/pg_depend.h"
 #include "catalog/pg_collation.h"
 #include "catalog/pg_conversion.h"
 #include "catalog/pg_database.h"
@@ -50,9 +54,11 @@
 #include "storage/lmgr.h"
 #include "storage/proc.h"
 #include "storage/procarray.h"
+#include "tcop/dest.h"
 #include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/catcache.h"
+#include "utils/fmgroids.h"
 #include "utils/guc_hooks.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
@@ -204,6 +210,8 @@ static Oid	myTempToastNamespace = InvalidOid;
 
 static SubTransactionId myTempNamespaceSubID = InvalidSubTransactionId;
 
+static bool session_has_temp_tables = false;
+
 /*
  * This is the user's textual search path specification --- it's the value
  * of the GUC variable 'search_path'.
@@ -4616,6 +4624,26 @@ AtEOXact_Namespace(bool isCommit, bool parallel)
 		myTempNamespaceSubID = InvalidSubTransactionId;
 	}
 
+	if (!parallel)
+	{
+		if (!isCommit)
+		{
+			if (!OidIsValid(myTempNamespace))
+				session_has_temp_tables = false;
+		}
+	}
+}
+
+/*
+ * Pre-commit cleanup and status check for namespaces.
+ * Called before committing the transaction while still in TRANS_INPROGRESS state.
+ */
+void
+PreCommit_Namespace(void)
+{
+	if (ready_for_query_message == READY_FOR_QUERY_RICH &&
+		(MyXactFlags & XACT_FLAGS_ACCESSEDTEMPNAMESPACE))
+		CheckSessionTempTables();
 }
 
 /*
@@ -4725,6 +4753,60 @@ ResetTempTableNamespace(void)
 {
 	if (OidIsValid(myTempNamespace))
 		RemoveTempRelations(myTempNamespace);
+	session_has_temp_tables = false;
+}
+
+/*
+ * CheckSessionTempTables - check if any objects exist in myTempNamespace.
+ *
+ * This performs an index scan on pg_depend to determine if any objects depend
+ * on the temporary namespace. Only called when ready_for_query_message is rich.
+ */
+void
+CheckSessionTempTables(void)
+{
+	Relation	depRel;
+	SysScanDesc scan;
+	ScanKeyData key[2];
+	HeapTuple	tup;
+	bool		has_objects = false;
+
+	if (!IsTransactionState() || !OidIsValid(myTempNamespace))
+	{
+		session_has_temp_tables = false;
+		return;
+	}
+
+	depRel = table_open(DependRelationId, AccessShareLock);
+
+	ScanKeyInit(&key[0],
+				Anum_pg_depend_refclassid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(NamespaceRelationId));
+	ScanKeyInit(&key[1],
+				Anum_pg_depend_refobjid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(myTempNamespace));
+
+	scan = systable_beginscan(depRel, DependReferenceIndexId, true,
+							  SnapshotSelf, 2, key);
+
+	if (HeapTupleIsValid(tup = systable_getnext(scan)))
+		has_objects = true;
+
+	systable_endscan(scan);
+	table_close(depRel, AccessShareLock);
+
+	session_has_temp_tables = has_objects;
+}
+
+/*
+ * HasSessionTempTables - return whether this session currently has temporary tables
+ */
+bool
+HasSessionTempTables(void)
+{
+	return OidIsValid(myTempNamespace) && session_has_temp_tables;
 }
 
 
diff --git a/src/backend/commands/prepare.c b/src/backend/commands/prepare.c
index 876aad2100a..4efc061a43a 100644
--- a/src/backend/commands/prepare.c
+++ b/src/backend/commands/prepare.c
@@ -760,3 +760,15 @@ build_regtype_array(Oid *param_types, int num_params)
 	result = construct_array_builtin(tmp_ary, num_params, REGTYPEOID);
 	return PointerGetDatum(result);
 }
+
+/*
+ * HasActivePreparedStatements - check if backend has any active prepared statements
+ */
+bool
+HasActivePreparedStatements(void)
+{
+	if (prepared_queries == NULL)
+		return false;
+
+	return hash_get_num_entries(prepared_queries) > 0;
+}
diff --git a/src/backend/tcop/dest.c b/src/backend/tcop/dest.c
index 4c2b5b87522..08ea260feb2 100644
--- a/src/backend/tcop/dest.c
+++ b/src/backend/tcop/dest.c
@@ -31,15 +31,27 @@
 #include "access/printsimple.h"
 #include "access/printtup.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"
 #include "commands/matview.h"
+#include "commands/prepare.h"
 #include "executor/functions.h"
 #include "executor/tqueue.h"
 #include "executor/tstoreReceiver.h"
 #include "libpq/libpq.h"
 #include "libpq/pqformat.h"
+#include "port/pg_bswap.h"
+#include "utils/guc_hooks.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 +265,93 @@ 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)
+{
+	bool		has_temp;
+	bool		has_cursors;
+	bool		has_prepared;
+	uint64		lsn_nbo;
+
+	/* 1. Temporary tables ('T') */
+	has_temp = HasSessionTempTables();
+	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 statements ('P') */
+	has_prepared = HasActivePreparedStatements();
+	ready_for_query_append_kv(buf, "P", 1, has_prepared ? "1" : "0", 1);
+
+	/* 4. Last Commit LSN ('l') as 8-byte uint64 in network byte order */
+	lsn_nbo = pg_hton64((uint64) XactLastCommitEnd);
+	ready_for_query_append_kv(buf, "l", 1, (char *) &lsn_nbo, sizeof(lsn_nbo));
+}
+
+/*
+ * assign_ready_for_query_message - GUC assign hook for ready_for_query_message
+ */
+void
+assign_ready_for_query_message(int newval, void *extra)
+{
+	if (newval == READY_FOR_QUERY_RICH && IsTransactionState())
+		CheckSessionTempTables();
+}
+
 /* ----------------
  *		ReadyForQuery - tell dest that we are ready for a new query
  *
@@ -277,6 +376,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..036ae9383db 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.',
+  variable => 'ready_for_query_message',
+  boot_val => 'READY_FOR_QUERY_PLAIN',
+  options => 'ready_for_query_message_options',
+  assign_hook => 'assign_ready_for_query_message',
+},
+
 { 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..bafc429ea04 100644
--- a/src/backend/utils/mmgr/portalmem.c
+++ b/src/backend/utils/mmgr/portalmem.c
@@ -54,6 +54,7 @@ typedef struct portalhashent
 } PortalHashEnt;
 
 static HTAB *PortalHashTable = NULL;
+static int	active_with_hold_portal_count = 0;
 
 #define PortalHashTableLookup(NAME, PORTAL) \
 do { \
@@ -586,6 +587,12 @@ PortalDrop(Portal portal, bool isTopCommit)
 		tuplestore_end(portal->holdStore);
 		MemoryContextSwitchTo(oldcontext);
 		portal->holdStore = NULL;
+
+		if (portal->cursorOptions & CURSOR_OPT_HOLD)
+		{
+			if (active_with_hold_portal_count > 0)
+				active_with_hold_portal_count--;
+		}
 	}
 
 	/* delete tuplestore storage, if any */
@@ -628,6 +635,8 @@ PortalHashTableDeleteAll(void)
 		hash_seq_term(&status);
 		hash_seq_init(&status, PortalHashTable);
 	}
+
+	active_with_hold_portal_count = 0;
 }
 
 /*
@@ -660,6 +669,8 @@ HoldPortal(Portal portal)
 	portal->createSubid = InvalidSubTransactionId;
 	portal->activeSubid = InvalidSubTransactionId;
 	portal->createLevel = 0;
+
+	active_with_hold_portal_count++;
 }
 
 /*
@@ -1191,6 +1202,15 @@ ThereAreNoReadyPortals(void)
 	return true;
 }
 
+/*
+ * HasActiveWithHoldCursors - check if session has any active WITH HOLD cursors
+ */
+bool
+HasActiveWithHoldCursors(void)
+{
+	return active_with_hold_portal_count > 0;
+}
+
 /*
  * Hold all pinned portals.
  *
diff --git a/src/include/catalog/namespace.h b/src/include/catalog/namespace.h
index 9453a3e4932..7ecb380f9bc 100644
--- a/src/include/catalog/namespace.h
+++ b/src/include/catalog/namespace.h
@@ -184,6 +184,8 @@ extern void GetTempNamespaceState(Oid *tempNamespaceId,
 extern void SetTempNamespaceState(Oid tempNamespaceId,
 								  Oid tempToastNamespaceId);
 extern void ResetTempTableNamespace(void);
+extern void CheckSessionTempTables(void);
+extern bool HasSessionTempTables(void);
 
 extern SearchPathMatcher *GetSearchPathMatcher(MemoryContext context);
 extern SearchPathMatcher *CopySearchPathMatcher(SearchPathMatcher *path);
@@ -196,6 +198,7 @@ extern Oid	FindDefaultConversionProc(int32 for_encoding, int32 to_encoding);
 
 /* initialization & transaction cleanup code */
 extern void InitializeSearchPath(void);
+extern void PreCommit_Namespace(void);
 extern void AtEOXact_Namespace(bool isCommit, bool parallel);
 extern void AtEOSubXact_Namespace(bool isCommit, SubTransactionId mySubid,
 								  SubTransactionId parentSubid);
diff --git a/src/include/commands/prepare.h b/src/include/commands/prepare.h
index 5c98dbb9c33..75db5640d66 100644
--- a/src/include/commands/prepare.h
+++ b/src/include/commands/prepare.h
@@ -58,5 +58,6 @@ extern TupleDesc FetchPreparedStatementResultDesc(PreparedStatement *stmt);
 extern List *FetchPreparedStatementTargetList(PreparedStatement *stmt);
 
 extern void DropAllPreparedStatements(void);
+extern bool HasActivePreparedStatements(void);
 
 #endif							/* PREPARE_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/guc_hooks.h b/src/include/utils/guc_hooks.h
index 06453a18c03..2f9c771a7f3 100644
--- a/src/include/utils/guc_hooks.h
+++ b/src/include/utils/guc_hooks.h
@@ -114,6 +114,7 @@ extern void assign_recovery_target_timeline(const char *newval, void *extra);
 extern bool check_recovery_target_xid(char **newval, void **extra,
 									  GucSource source);
 extern void assign_recovery_target_xid(const char *newval, void *extra);
+extern void assign_ready_for_query_message(int newval, void *extra);
 extern bool check_role(char **newval, void **extra, GucSource source);
 extern void assign_role(const char *newval, void *extra);
 extern const char *show_role(void);
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/interfaces/libpq/fe-trace.c b/src/interfaces/libpq/fe-trace.c
index 2901fa5b451..2ec39627c6d 100644
--- a/src/interfaces/libpq/fe-trace.c
+++ b/src/interfaces/libpq/fe-trace.c
@@ -612,10 +612,24 @@ pqTraceOutput_CopyBothResponse(FILE *f, const char *message, int *cursor, int le
 }
 
 static void
-pqTraceOutput_ReadyForQuery(FILE *f, const char *message, int *cursor)
+pqTraceOutput_ReadyForQuery(FILE *f, const char *message, int *cursor, int length)
 {
 	fprintf(f, "ReadyForQuery\t");
 	pqTraceOutputByte1(f, message, cursor);
+
+	while (*cursor - 1 < length)
+	{
+		int			klen = (unsigned char) message[(*cursor)++];
+
+		pqTraceOutputNchar(f, klen, message, cursor, false);
+		if (*cursor - 1 < length)
+		{
+			int			vlen = (unsigned char) message[(*cursor)++];
+
+			if (vlen > 0)
+				pqTraceOutputNchar(f, vlen, message, cursor, false);
+		}
+	}
 }
 
 /*
@@ -815,7 +829,7 @@ pqTraceOutputMessage(PGconn *conn, const char *message, bool toServer)
 			/* No message content */
 			break;
 		case PqMsg_ReadyForQuery:
-			pqTraceOutput_ReadyForQuery(conn->Pfdebug, message, &logCursor);
+			pqTraceOutput_ReadyForQuery(conn->Pfdebug, message, &logCursor, length);
 			break;
 		default:
 			fprintf(conn->Pfdebug, "Unknown message: %02x", id);
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..e18f5b2def9
--- /dev/null
+++ b/src/test/modules/test_ready_for_query/t/001_ready_for_query.pl
@@ -0,0 +1,255 @@
+# 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'\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')
+# Returns ($mlen, $tx_status, \%kv, \@other_msgs)
+sub read_until_ready_for_query
+{
+	my ($sock) = @_;
+	my @other_msgs = ();
+	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, \@other_msgs);
+		}
+		else
+		{
+			push @other_msgs, { type => $mtype, body => $body };
+		}
+	}
+}
+
+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, $msgs1) = 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");
+
+# Verify plain mode does not send ParameterStatus for ready_for_query_message
+my @param_rfq = grep {
+	$_->{type} eq 'S' && $_->{body} =~ /^ready_for_query_message\0/
+} @$msgs1;
+is(scalar(@param_rfq), 0, "plain mode sends no ParameterStatus for ready_for_query_message");
+
+# 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 statements exist");
+ok(defined $kv2->{'l'}, "l (binary LSN) key is present in rich mode");
+is(length($kv2->{'l'}), 8, "l key value is 8 bytes");
+
+# 4. Temporary table lifecycle: CREATE -> DROP -> DISCARD TEMP -> DISCARD ALL
+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");
+
+# DROP TABLE resets T to 0
+send_query($sock, "DROP TABLE t_temp;");
+my ($len_drop, $tx_drop, $kv_drop) = read_until_ready_for_query($sock);
+is($kv_drop->{'T'}, '0', "T=0 after DROP TABLE");
+ok(!exists $kv_drop->{'temp_tables_info'}, "temp_tables_info gone after DROP TABLE");
+
+# Re-create temp table, then DISCARD TEMP resets T to 0
+send_query($sock, "CREATE TEMP TABLE t_temp2 (id int);");
+my ($len_dt1, $tx_dt1, $kv_dt1) = read_until_ready_for_query($sock);
+is($kv_dt1->{'T'}, '1', "T=1 after re-creating temp table");
+
+send_query($sock, "DISCARD TEMP;");
+my ($len_dt2, $tx_dt2, $kv_dt2) = read_until_ready_for_query($sock);
+is($kv_dt2->{'T'}, '0', "T=0 after DISCARD TEMP");
+
+# Re-create temp table, then DISCARD ALL resets T to 0
+send_query($sock, "CREATE TEMP TABLE t_temp3 (id int);");
+my ($len_da1, $tx_da1, $kv_da1) = read_until_ready_for_query($sock);
+is($kv_da1->{'T'}, '1', "T=1 after third temp table");
+
+send_query($sock, "DISCARD ALL;");
+read_until_ready_for_query($sock);
+
+send_query($sock, "SET ready_for_query_message = 'rich';");
+my ($len_da2, $tx_da2, $kv_da2) = read_until_ready_for_query($sock);
+is($kv_da2->{'T'}, '0', "T=0 after DISCARD ALL");
+
+# 5. Cursors: verify 100 cursors without hold do not set H, and WITH HOLD cursor sets H
+my $c100_sql = "BEGIN; " . join(" ", map { "DECLARE c$_ CURSOR FOR SELECT 1;" } (1..100));
+send_query($sock, $c100_sql);
+my ($len_c100, $tx_c100, $kv_c100) = read_until_ready_for_query($sock);
+is($kv_c100->{'H'}, '0', "H=0 with 100 cursors without HOLD inside transaction");
+send_query($sock, "ROLLBACK;");
+read_until_ready_for_query($sock);
+
+# 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");
+my $lsn64 = unpack('Q>', $kv4->{'l'});
+cmp_ok($lsn64, '>', 0, "binary LSN is non-zero after COMMIT");
+
+# Close cursor resets H to 0
+send_query($sock, "CLOSE cur;");
+my ($len_close, $tx_close, $kv_close) = read_until_ready_for_query($sock);
+is($kv_close->{'H'}, '0', "H=0 after CLOSE cur");
+
+# 6. Prepared statements 'P'
+send_query($sock, "PREPARE p1 AS SELECT 1;");
+my ($len_prep, $tx_prep, $kv_prep) = read_until_ready_for_query($sock);
+is($kv_prep->{'P'}, '1', "P=1 after PREPARE");
+
+send_query($sock, "DEALLOCATE p1;");
+my ($len_dealloc, $tx_dealloc, $kv_dealloc) = read_until_ready_for_query($sock);
+is($kv_dealloc->{'P'}, '0', "P=0 after DEALLOCATE");
+
+send_query($sock, "PREPARE p2 AS SELECT 2;");
+my ($len_p2, $tx_p2, $kv_p2) = read_until_ready_for_query($sock);
+is($kv_p2->{'P'}, '1', "P=1 after PREPARE p2");
+
+send_query($sock, "DEALLOCATE ALL;");
+my ($len_dp, $tx_dp, $kv_dp) = read_until_ready_for_query($sock);
+is($kv_dp->{'P'}, '0', "P=0 after DEALLOCATE ALL");
+
+# 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");
+is($kv_opt->{'P'}, '0', "P=0 on fresh session");
+
+# 9. Test mid-session GUC assign hook: set rich after temp table was already created
+my $sock3 = $node->raw_connect();
+my $startup_body3 = pack('N', 196608) . "user\0$dbuser\0database\0postgres\0\0";
+my $startup_msg3 = pack('N', 4 + length($startup_body3)) . $startup_body3;
+$sock3->syswrite($startup_msg3);
+read_until_ready_for_query($sock3);
+
+# Create temp table in plain mode
+send_query($sock3, "CREATE TEMP TABLE t_preexisting (id int);");
+my ($len_pre, $tx_pre, $kv_pre) = read_until_ready_for_query($sock3);
+is($len_pre, 5, "plain mode RFQ after temp table creation");
+
+# Switch to rich mode: GUC assign hook initializes session_has_temp_tables
+send_query($sock3, "SET ready_for_query_message = 'rich';");
+my ($len_hook, $tx_hook, $kv_hook) = read_until_ready_for_query($sock3);
+cmp_ok($len_hook, '>', 5, "switched to rich mode");
+is($kv_hook->{'T'}, '1', "T=1 detected by GUC assign hook for pre-existing temp table");
+
+send_query($sock3, "DROP TABLE t_preexisting;");
+my ($len_post, $tx_post, $kv_post) = read_until_ready_for_query($sock3);
+is($kv_post->{'T'}, '0', "T=0 after dropping pre-existing temp table");
+
+close($sock);
+close($sock2);
+close($sock3);
+$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..0377cde9617
--- /dev/null
+++ b/src/test/modules/test_ready_for_query/test_ready_for_query.c
@@ -0,0 +1,49 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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 temporary tables, append it.
+	 */
+	if (!ready_for_query_has_key(buf, "temp_tables_info", 16))
+	{
+		if (HasSessionTempTables())
+		{
+			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
-- 
2.56.0.385.gd3acb90ef8-goog

Reply via email to