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
