From 1a8c172228db9f8ffd7add086e3b0a3cc4986b1c Mon Sep 17 00:00:00 2001 From: Amit Kapila Date: Thu, 23 Jul 2026 10:46:42 +0530 Subject: [PATCH 01/43] Reject sequence synchronization against pre-PostgreSQL 19 publishers. Sequence synchronization requires the page_lsn field returned by pg_get_sequence_data(), which was added in PostgreSQL 19. Previously, requesting sequence synchronization against an older publisher (via ALTER SUBSCRIPTION ... REFRESH SEQUENCES or by running ALTER SUBSCRIPTION ... CONNECTION on a disabled subscription with sequences in the INIT state and subsequently enabling the subscription) would cause the sequence synchronization worker to repeatedly fail with a confusing "invalid query response" error. Check the publisher's server version up front in both AlterSubscription_refresh_seq() and copy_sequences(), and error out immediately when it predates PostgreSQL 19. Also document the PostgreSQL 19 publisher requirement for sequence replication in the logical replication documentation and in ALTER SUBSCRIPTION ... REFRESH SEQUENCES. Reported-by: Noah Misch Author: vignesh C Reviewed-by: Shveta Malik Reviewed-by: Hayato Kuroda Reviewed-by: Amit Kapila Backpatch-through: 19 Discussion: https://postgr.es/m/20260710045217.f0.noahmisch@microsoft.com --- doc/src/sgml/logical-replication.sgml | 18 +++++++++++++++++- doc/src/sgml/ref/alter_subscription.sgml | 6 ++++++ src/backend/commands/subscriptioncmds.c | 10 ++++++++++ src/backend/replication/logical/sequencesync.c | 10 ++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml index 690598bff98e5..36298cacb759a 100644 --- a/doc/src/sgml/logical-replication.sgml +++ b/doc/src/sgml/logical-replication.sgml @@ -1818,6 +1818,13 @@ Included in publications: configuration. + + + Sequence synchronization requires the publisher to be running + PostgreSQL 19 or later. + + + Sequence Definition Mismatches @@ -2368,7 +2375,16 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER ALTER SUBSCRIPTION ... REFRESH SEQUENCES or by copying the current data from the publisher (perhaps using pg_dump) or by determining a sufficiently high value - from the tables themselves. + from the tables themselves. Note that + + ALTER SUBSCRIPTION ... REFRESH SEQUENCES only + re-synchronizes sequences that are already known to the subscription + (see ); in particular, it + requires the publisher to be running PostgreSQL + 19 or later. Before relying on it to prepare for a switchover or + failover, confirm that the publisher's version supports sequence + replication and that the sequences of interest are already known to the + subscription. diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml index 8d64744375a50..6fc3e07a2d502 100644 --- a/doc/src/sgml/ref/alter_subscription.sgml +++ b/doc/src/sgml/ref/alter_subscription.sgml @@ -245,6 +245,12 @@ ALTER SUBSCRIPTION name RENAME TO < sequences are subscribed. Run REFRESH PUBLICATION first if the publication's set of sequences has changed. + + + Sequence replication requires the publisher to be running + PostgreSQL 19 or later. + + See for recommendations on how to handle any warnings about sequence definition diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c index 63d288a46302a..7f946c5b45457 100644 --- a/src/backend/commands/subscriptioncmds.c +++ b/src/backend/commands/subscriptioncmds.c @@ -1381,6 +1381,16 @@ AlterSubscription_refresh_seq(Subscription *sub) /* The publisher connection is only needed for the origin check. */ PG_TRY(); { + /* + * Sequence synchronization depends on publisher-side functionality + * introduced in PostgreSQL 19, so it cannot work against an older + * publisher. + */ + if (walrcv_server_version(wrconn) < 190000) + ereport(ERROR, + errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("cannot synchronize sequences if the publisher is running a version earlier than PostgreSQL 19")); + check_publications_origin_sequences(wrconn, sub->publications, true, sub->origin, NULL, 0, sub->name); } diff --git a/src/backend/replication/logical/sequencesync.c b/src/backend/replication/logical/sequencesync.c index 63ad46d7fd7b2..28d4d011a84e0 100644 --- a/src/backend/replication/logical/sequencesync.c +++ b/src/backend/replication/logical/sequencesync.c @@ -444,6 +444,16 @@ copy_sequences(WalReceiverConn *conn) StringInfoData cmd; MemoryContext oldctx; + /* + * Sequence synchronization depends on publisher-side functionality + * introduced in PostgreSQL 19, so it cannot work against an older + * publisher. + */ + if (walrcv_server_version(conn) < 190000) + ereport(ERROR, + errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("cannot synchronize sequences if the publisher is running a version earlier than PostgreSQL 19")); + initStringInfo(&seqstr); initStringInfo(&cmd); From a49b6a61094677f75807e452f333f87d4926083f Mon Sep 17 00:00:00 2001 From: Michael Paquier Date: Thu, 23 Jul 2026 14:37:39 +0900 Subject: [PATCH 02/43] injection_points: Clear waiter slot on error and exit injection_wait() only clears its slot in the waiter array after the wait loop finishes. When the waiting query is canceled or the backend is terminated (wait look has a CHECK_FOR_INTERRUPS), the slot leaks. Later wakeups of the same point then bump the counter of the leaked slot instead of the real waiter, that sleeps forever. Repeated leaks can exhaust all the slots. The code is changed so as the waiting loop is wrapped with PG_ENSURE_ERROR_CLEANUP, so as the injection point slots, that are shared resources, can be cleaned up on ERROR as much as a FATAL. An isolation test is added: cancel one waiter, terminate another waiter, then check that a later waiter still receives a wakeup. Without the fixed code, the test would fail on timeout. Author: Zsolt Parragi Discussion: https://postgr.es/m/CAN4CZFO+KF=cc0-iEg28RhqRBp_fTs6D4b8b7D7DB-pGYP3Ccg@mail.gmail.com Backpatch-through: 17 --- src/test/modules/injection_points/Makefile | 1 + .../expected/wait_cleanup.out | 87 +++++++++++++++++++ .../injection_points/injection_points.c | 31 +++++-- src/test/modules/injection_points/meson.build | 1 + .../injection_points/specs/wait_cleanup.spec | 50 +++++++++++ 5 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 src/test/modules/injection_points/expected/wait_cleanup.out create mode 100644 src/test/modules/injection_points/specs/wait_cleanup.spec diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile index c01d2fb095cc0..fac80f3a4a735 100644 --- a/src/test/modules/injection_points/Makefile +++ b/src/test/modules/injection_points/Makefile @@ -19,6 +19,7 @@ ISOLATION = basic \ repack_temporal_multirange \ repack_toast \ syscache-update-pruned \ + wait_cleanup \ heap_lock_update # some isolation tests require wal_level=replica diff --git a/src/test/modules/injection_points/expected/wait_cleanup.out b/src/test/modules/injection_points/expected/wait_cleanup.out new file mode 100644 index 0000000000000..c5be17428fcb7 --- /dev/null +++ b/src/test/modules/injection_points/expected/wait_cleanup.out @@ -0,0 +1,87 @@ +Parsed test spec with 3 sessions + +starting permutation: wait1 cancel3 noop3 wait2 wakeup3 noop2 detach3 +injection_points_attach +----------------------- + +(1 row) + +step wait1: SELECT injection_points_run('injection-points-wait'); +step cancel3: + SELECT pg_cancel_backend(pid) FROM pg_stat_activity + WHERE wait_event = 'injection-points-wait'; + +step wait1: <... completed> +ERROR: canceling statement due to user request +step cancel3: <... completed> +pg_cancel_backend +----------------- +t +(1 row) + +step noop3: +step wait2: SELECT injection_points_run('injection-points-wait'); +step wakeup3: SELECT injection_points_wakeup('injection-points-wait'); +injection_points_wakeup +----------------------- + +(1 row) + +step wait2: <... completed> +injection_points_run +-------------------- + +(1 row) + +step noop2: +step detach3: SELECT injection_points_detach('injection-points-wait'); +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: wait1 terminate3 noop3 wait2 wakeup3 noop2 detach3 +injection_points_attach +----------------------- + +(1 row) + +step wait1: SELECT injection_points_run('injection-points-wait'); +step terminate3: + SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE wait_event = 'injection-points-wait'; + +step wait1: <... completed> +FATAL: terminating connection due to administrator command +server closed the connection unexpectedly + This probably means the server terminated abnormally + before or while processing the request. + +step terminate3: <... completed> +pg_terminate_backend +-------------------- +t +(1 row) + +step noop3: +step wait2: SELECT injection_points_run('injection-points-wait'); +step wakeup3: SELECT injection_points_wakeup('injection-points-wait'); +injection_points_wakeup +----------------------- + +(1 row) + +step wait2: <... completed> +injection_points_run +-------------------- + +(1 row) + +step noop2: +step detach3: SELECT injection_points_detach('injection-points-wait'); +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/injection_points.c b/src/test/modules/injection_points/injection_points.c index 2d26ecedd5da2..0ed1dc7c8a7d5 100644 --- a/src/test/modules/injection_points/injection_points.c +++ b/src/test/modules/injection_points/injection_points.c @@ -223,6 +223,19 @@ injection_notice(const char *name, const void *private_data, void *arg) elog(NOTICE, "notice triggered for injection point %s", name); } +/* + * Error cleanup callback for injection point waits. + */ +static void +injection_wait_cleanup(int code, Datum arg) +{ + int index = DatumGetInt32(arg); + + SpinLockAcquire(&inj_state->lock); + inj_state->name[index][0] = '\0'; + SpinLockRelease(&inj_state->lock); +} + /* Wait until injection_points_wakeup() is called */ void injection_wait(const char *name, const void *private_data, void *arg) @@ -275,19 +288,21 @@ injection_wait(const char *name, const void *private_data, void *arg) delay_us = INJ_WAIT_INITIAL_US; pgstat_report_wait_start(injection_wait_event); - while (pg_atomic_read_u32(&inj_state->wait_counts[index]) == old_wait_counts) + PG_ENSURE_ERROR_CLEANUP(injection_wait_cleanup, Int32GetDatum(index)); { - CHECK_FOR_INTERRUPTS(); - pg_usleep(delay_us); - if (delay_us < INJ_WAIT_MAX_US) - delay_us = Min(delay_us * 2, INJ_WAIT_MAX_US); + while (pg_atomic_read_u32(&inj_state->wait_counts[index]) == old_wait_counts) + { + CHECK_FOR_INTERRUPTS(); + pg_usleep(delay_us); + if (delay_us < INJ_WAIT_MAX_US) + delay_us = Min(delay_us * 2, INJ_WAIT_MAX_US); + } } + PG_END_ENSURE_ERROR_CLEANUP(injection_wait_cleanup, Int32GetDatum(index)); pgstat_report_wait_end(); /* Remove this injection point from the waiters. */ - SpinLockAcquire(&inj_state->lock); - inj_state->name[index][0] = '\0'; - SpinLockRelease(&inj_state->lock); + injection_wait_cleanup(0, Int32GetDatum(index)); } /* diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build index 59dba1cb02304..163b6374ebcdd 100644 --- a/src/test/modules/injection_points/meson.build +++ b/src/test/modules/injection_points/meson.build @@ -50,6 +50,7 @@ tests += { 'repack_temporal_multirange', 'repack_toast', 'syscache-update-pruned', + 'wait_cleanup', 'heap_lock_update', ], 'runningcheck': false, # see syscache-update-pruned diff --git a/src/test/modules/injection_points/specs/wait_cleanup.spec b/src/test/modules/injection_points/specs/wait_cleanup.spec new file mode 100644 index 0000000000000..ed7d21c4de471 --- /dev/null +++ b/src/test/modules/injection_points/specs/wait_cleanup.spec @@ -0,0 +1,50 @@ +# Check that a canceled or terminated waiter does not leave a stale slot +# behind in the waiter array. A leaked slot would make later wakeups of +# the same injection point bump the leaked slot's counter instead of the +# real waiter's, leaving the real waiter stuck. + +setup +{ + CREATE EXTENSION injection_points; +} +teardown +{ + DROP EXTENSION injection_points; +} + +# The first waiter, that gets canceled or terminated. This does not +# use injection_points_set_local() on purpose: the injection point +# must survive s1's termination so that s3 can still detach it. +session s1 +setup { + SELECT injection_points_attach('injection-points-wait', 'wait'); +} +step wait1 { SELECT injection_points_run('injection-points-wait'); } + +# The second waiter, that receives a wakeup. +session s2 +step wait2 { SELECT injection_points_run('injection-points-wait'); } +step noop2 { } + +# Control session. The blocker annotations on cancel3/terminate3, +# together with noop3, make the tester wait until wait1 has fully +# completed before starting wait2. Otherwise, wait2 could register a +# new waiter slot while s1 still owns the previous one. +session s3 +step cancel3 { + SELECT pg_cancel_backend(pid) FROM pg_stat_activity + WHERE wait_event = 'injection-points-wait'; +} +step terminate3 { + SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE wait_event = 'injection-points-wait'; +} +step wakeup3 { SELECT injection_points_wakeup('injection-points-wait'); } +step detach3 { SELECT injection_points_detach('injection-points-wait'); } +step noop3 { } + +permutation wait1 cancel3(wait1) noop3 wait2 wakeup3 noop2 detach3 + +# The terminate permutation has to stay last: s1's connection is dead +# afterwards, and the tester never reconnects a session. +permutation wait1 terminate3(wait1) noop3 wait2 wakeup3 noop2 detach3 From abbd74ce8738d536e8d99151122b7a650e3b63d5 Mon Sep 17 00:00:00 2001 From: Michael Paquier Date: Thu, 23 Jul 2026 16:09:13 +0900 Subject: [PATCH 03/43] doc: Improve description of pg_stat_activity.backend_type The documentation of pg_stat_activity used an incomplete list of values for backend_type. While on it, it is improved to use an itemized list, now ordered alphabetically, with a short description about each item. Author: Laurenz Albe Reviewed-By: Michael Paquier Reviewed-By: Fujii Masao Discussion: https://postgr.es/m/5e94c0196084f648ae6a00107125494f5804318a.camel@cybertec.at --- doc/src/sgml/monitoring.sgml | 165 ++++++++++++++++++++++++++++++++--- 1 file changed, 155 insertions(+), 10 deletions(-) diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml index d1a20d001e9c8..b087d499041b4 100644 --- a/doc/src/sgml/monitoring.sgml +++ b/doc/src/sgml/monitoring.sgml @@ -1057,16 +1057,161 @@ postgres 27093 0.0 0.0 30096 2752 ? Ss 11:34 0:00 postgres: ser backend_type text - Type of current backend. Possible types are - autovacuum launcher, autovacuum worker, - logical replication launcher, - logical replication worker, - parallel worker, background writer, - client backend, checkpointer, - archiver, standalone backend, - startup, walreceiver, - walsender, walwriter and - walsummarizer. + Type of current backend. Possible types are: + + + + archiver: The WAL archiver, active when + is enabled. + + + + + autovacuum launcher: The background process that + launches autovacuum workers, active when + is on. + + + + + autovacuum worker: A background process running + VACUUM or ANALYZE on a single + table. + + + + + background writer: The background process that + makes sure that there are enough clean buffers in shared buffers. + + + + + checkpointer: The background process that + performs checkpoints + regularly. + + + + + client backend: The server process performing + work for a regular database connection. + + + + + datachecksums launcher: The background process + that launches data checksum workers. + + + + + datachecksums worker: A background process that + calculates data checksums for all pages in one database. + + + + + io worker: A background process performing + asynchronous I/O, active when is set + to worker. + + + + + logical replication apply worker: A background + process that applies data modifications on a logical subscriber. + + + + + logical replication launcher: The background + process that launches logical replication worker processes for + subscriptions. + + + + + logical replication parallel worker: A background + process that applies data modifications on a logical subscriber + for a subscription with streaming = parallel. + + + + + logical replication sequencesync worker: A + background process that replicates sequence data on a logical + subscriber. + + + + + logical replication tablesync worker: A + background process that copies table data on a logical subscriber + for a subscription with copy_data = true. + + + + + parallel worker: A background process that helps + a backend process to perform operations in parallel. + + + + + REPACK decoding worker: A background process that + decodes WAL for REPACK (CONCURRENTLY). + + + + + slotsync worker: The background process that + synchronizes logical replication slots on a streaming replication + standby server, active when + is set to on. + + + + + standalone backend: The backend process when + PostgreSQL was started in + . + + + + + startup: The background process that replays WAL + during crash recovery, archive recovery or streaming replication. + + + + + walreceiver: The background process that receives + WAL records from a WAL sender, active in streaming replication + standby mode. + + + + + walsender: A background process that sends WAL + records to receivers (during streaming replication) or decodes WAL + and sends the decoded information (during logical replication). + + + + + walsummarizer: The background process that + creates summaries from WAL for use with incremental backup, active + when is on. + + + + + walwriter: The background process that persists + WAL records from WAL buffers to disk. + + + In addition, background workers registered by extensions may have additional types. From 544d25b7af958ca6c03e98bfbc1538c295b30601 Mon Sep 17 00:00:00 2001 From: Michael Paquier Date: Thu, 23 Jul 2026 16:48:40 +0900 Subject: [PATCH 04/43] Fix socket_putmessage_noblock() to call socket_putmessage() socket_putmessage_noblock() used pq_putmessage(), which redirects to PqCommMethods->putmessage. In the common cases, this points to socket_putmessage(), but it would become incorrect if PqCommMethods points to a different implementation. This change may look like a bug, but as far as I can see this is mostly cosmetic. The code is able to work currently, as the repalloc() done in the noblock() call ensures that the blocking path of internal_putbytes() is never reached. The issue has gone unnoticed since 2bd9e412f92b. Author: Anthonin Bonnefoy Reviewed-by: Fujii Masao Discussion: https://postgr.es/m/CAO6_Xqpf5+Rzw_-XOOz-d-R5x6_2JHtpnzXP0nrYWiHyZokA_Q@mail.gmail.com --- src/backend/libpq/pqcomm.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/libpq/pqcomm.c b/src/backend/libpq/pqcomm.c index ee9a39107e6f5..aaae7214f1344 100644 --- a/src/backend/libpq/pqcomm.c +++ b/src/backend/libpq/pqcomm.c @@ -1537,7 +1537,7 @@ socket_putmessage_noblock(char msgtype, const char *s, size_t len) PqSendBuffer = repalloc(PqSendBuffer, required); PqSendBufferSize = required; } - res = pq_putmessage(msgtype, s, len); + res = socket_putmessage(msgtype, s, len); Assert(res == 0); /* should not fail when the message fits in * buffer */ } From 937db82a8d6ffb1b3bb292ed7070dae33aa659ba Mon Sep 17 00:00:00 2001 From: Fujii Masao Date: Thu, 23 Jul 2026 19:22:36 +0900 Subject: [PATCH 05/43] doc: Improve pg_stat_recovery documentation Improve the documentation for pg_stat_recovery in several ways: - Mention the view in high-availability.sgml as a way to monitor recovery state and replay progress, alongside the existing recovery information functions. - Clarify that the view returns at most one row, not exactly one row, and no rows to users who lack the pg_read_all_stats privilege. - Correct the description of last_replayed_end_lsn to clarify that it is the end LSN of the last replayed record plus one. - Document that replay_end_tli equals last_replayed_tli when no WAL record is currently being replayed. - Clarify that current_chunk_start_time is NULL until streaming WAL has been received. Backpatch to v19, where pg_stat_recovery was introduced. Author: Fujii Masao Reviewed-by: Michael Paquier Discussion: https://postgr.es/m/CAHGQGwGRavm18HqnQn_f68QB96qk6arhjET1V93OJH09Mgojkg@mail.gmail.com Backpatch-through: 19 --- doc/src/sgml/high-availability.sgml | 13 ++++++++---- doc/src/sgml/monitoring.sgml | 33 +++++++++++++++++------------ src/include/access/xlogrecovery.h | 4 ++-- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/doc/src/sgml/high-availability.sgml b/doc/src/sgml/high-availability.sgml index 6d9636bd125c7..fd338ab154002 100644 --- a/doc/src/sgml/high-availability.sgml +++ b/doc/src/sgml/high-availability.sgml @@ -920,7 +920,10 @@ primary_conninfo = 'host=192.168.1.50 port=5432 user=foo password=foopass' pg_stat_wal_receiver view. A large difference between pg_last_wal_replay_lsn and the view's flushed_lsn indicates that WAL is being - received faster than it can be replayed. + received faster than it can be replayed. Recovery state and replay + progress can also be monitored via the + + pg_stat_recovery view. @@ -1801,9 +1804,11 @@ postgres=# WAIT FOR LSN '0/306EE20'; (In server versions before 14, the in_hot_standby parameter did not exist; a workable substitute method for older servers is SHOW transaction_read_only.) In addition, a set of - functions () allow users to - access information about the standby server. These allow you to write - programs that are aware of the current state of the database. These + functions () and the + + pg_stat_recovery view allow users to + access information about the standby server. These facilities allow you to + write programs that are aware of the current state of the database. They can be used to monitor the progress of recovery, or to allow you to write complex programs that restore the database to particular states. diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml index b087d499041b4..1ce0ef007998d 100644 --- a/doc/src/sgml/monitoring.sgml +++ b/doc/src/sgml/monitoring.sgml @@ -340,7 +340,7 @@ postgres 27093 0.0 0.0 30096 2752 ? Ss 11:34 0:00 postgres: ser pg_stat_recoverypg_stat_recovery - Only one row, showing statistics about the state of recovery. + At most one row, showing statistics about the recovery state. See pg_stat_recovery for details. @@ -2120,9 +2120,11 @@ description | Waiting for a newly initialized WAL file to reach durable storage - The pg_stat_recovery view will contain only + The pg_stat_recovery view will contain at most one row, showing statistics about the recovery state of the startup - process. This view returns no row when the server is not in recovery. + process. This view returns no rows when the server is not in recovery + or the user does not have privileges of the + pg_read_all_stats role. @@ -2164,8 +2166,8 @@ description | Waiting for a newly initialized WAL file to reach durable storage last_replayed_end_lsnpg_lsn - End write-ahead log location of the last successfully replayed - WAL record. + End write-ahead log location, plus one, of the last successfully + replayed WAL record. @@ -2194,18 +2196,20 @@ description | Waiting for a newly initialized WAL file to reach durable storage replay_end_tliinteger - Timeline of the WAL record currently being replayed. + Timeline of the WAL record currently being replayed. When no record + is being actively replayed, equals + last_replayed_tli. - recovery_last_xact_time timestamp with time zone - - - Timestamp of the last transaction commit or abort replayed during - recovery. This is the time at which the commit or abort WAL record - for that transaction was generated on the primary. + recovery_last_xact_time timestamp with time zone + + + Timestamp of the last transaction commit or abort record replayed + during recovery. This is the time at which the commit or abort WAL + record for that transaction was generated on the primary. @@ -2215,8 +2219,9 @@ description | Waiting for a newly initialized WAL file to reach durable storage Time when the startup process observed that replay had caught up - with the latest received WAL chunk. Used in recovery-conflict - timing and replay/apply-lag diagnostics. NULL if not yet + with the latest WAL chunk received from streaming replication. + Used in recovery-conflict timing and replay/apply-lag diagnostics. + NULL if streaming WAL has not yet been received or the time is not available. diff --git a/src/include/access/xlogrecovery.h b/src/include/access/xlogrecovery.h index 9ffd44fcbaebf..a1d8a81dbc198 100644 --- a/src/include/access/xlogrecovery.h +++ b/src/include/access/xlogrecovery.h @@ -112,8 +112,8 @@ typedef struct XLogRecoveryCtlData TimestampTz recoveryLastXTime; /* - * timestamp of when we started replaying the current chunk of WAL data, - * only relevant for replication or archive recovery + * timestamp of when we caught up with the latest WAL chunk received from + * streaming replication */ TimestampTz currentChunkStartTime; /* Recovery pause state */ From 1c9c35890421e96a91129b51f2c6446a6d95af95 Mon Sep 17 00:00:00 2001 From: Fujii Masao Date: Thu, 23 Jul 2026 19:24:55 +0900 Subject: [PATCH 06/43] Validate subscription conninfo on owner change For subscriptions using SERVER, changing the owner can change the effective connection string. However, ALTER SUBSCRIPTION ... OWNER TO did not validate the generated conninfo for the new owner. As a result, ownership could be transferred to a non-superuser whose generated connection string did not satisfy password_required=true. The ownership change succeeded, but the subscription would fail later when the worker or another command tried to connect. Fix this by making ALTER SUBSCRIPTION ... OWNER TO validate the new owner's generated conninfo with walrcv_check_conninfo(). Backpatch to v19, where SERVER subscriptions were introduced. Author: Fujii Masao Reviewed-by: Yuanchao Zhang <145zhangyc@gmail.com> Reviewed-by: Hayato Kuroda Discussion: https://postgr.es/m/CAHGQGwFGa6+wWVgUmZPFwN=fBY59mYPkMK3=TxT=Pv5C1mNNRQ@mail.gmail.com Backpatch-through: 19 --- doc/src/sgml/ref/alter_subscription.sgml | 7 +++++++ src/backend/commands/subscriptioncmds.c | 14 ++++++++++++-- src/test/regress/expected/subscription.out | 17 +++++++++++++++++ src/test/regress/regress.c | 9 +++++++++ src/test/regress/sql/subscription.sql | 16 ++++++++++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml index 6fc3e07a2d502..0f81af5608bf2 100644 --- a/doc/src/sgml/ref/alter_subscription.sgml +++ b/doc/src/sgml/ref/alter_subscription.sgml @@ -53,6 +53,13 @@ ALTER SUBSCRIPTION name RENAME TO < to alter the owner, you must be able to SET ROLE to the new owning role. If the subscription has password_required=false, only superusers can modify it. + If the subscription uses a foreign server, the new owner must have + USAGE privilege on the foreign server, a user mapping + for the new owner or for PUBLIC must exist, and the + connection string generated for the new owner must be valid. If the new + owner is not a superuser and the subscription has + password_required=true, the generated connection string + must include a password. diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c index 7f946c5b45457..d4504b4a0c6fb 100644 --- a/src/backend/commands/subscriptioncmds.c +++ b/src/backend/commands/subscriptioncmds.c @@ -2949,11 +2949,12 @@ AlterSubscriptionOwner_internal(Relation rel, HeapTuple tup, Oid newOwnerId) /* * If the subscription uses a server, check that the new owner has USAGE - * privileges on the server and that a user mapping exists. Note: does not - * re-check the resulting connection string. + * privileges on the server, that a user mapping exists, and that the + * resulting connection string is valid for the new owner. */ if (OidIsValid(form->subserver)) { + char *conninfo; ForeignServer *server = GetForeignServer(form->subserver); aclresult = object_aclcheck(ForeignServerRelationId, server->serverid, newOwnerId, ACL_USAGE); @@ -2966,6 +2967,15 @@ AlterSubscriptionOwner_internal(Relation rel, HeapTuple tup, Oid newOwnerId) /* make sure a user mapping exists */ GetUserMapping(newOwnerId, server->serverid); + + conninfo = ForeignServerConnectionString(newOwnerId, server); + + /* Load the library providing us libpq calls. */ + load_file("libpqwalreceiver", false); + /* Check the connection info string. */ + walrcv_check_conninfo(conninfo, + form->subpasswordrequired && + !superuser_arg(newOwnerId)); } form->subowner = newOwnerId; diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out index d201ad764f05a..1bb785f4f9f5f 100644 --- a/src/test/regress/expected/subscription.out +++ b/src/test/regress/expected/subscription.out @@ -9,6 +9,10 @@ CREATE FUNCTION test_fdw_connection(oid, oid, internal) RETURNS text AS :'regresslib', 'test_fdw_connection' LANGUAGE C; +CREATE FUNCTION test_fdw_connection_no_password(oid, oid, internal) + RETURNS text + AS :'regresslib', 'test_fdw_connection_no_password' + LANGUAGE C; CREATE ROLE regress_subscription_user LOGIN SUPERUSER; CREATE ROLE regress_subscription_user2; CREATE ROLE regress_subscription_user3 IN ROLE pg_create_subscription; @@ -189,6 +193,18 @@ CREATE SUBSCRIPTION regress_testsub6 SERVER test_server WARNING: subscription was created, but is not connected HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications. RESET SESSION AUTHORIZATION; +GRANT USAGE ON FOREIGN SERVER test_server TO regress_subscription_user2; +CREATE USER MAPPING FOR regress_subscription_user2 SERVER test_server OPTIONS(user 'foo'); +ALTER FOREIGN DATA WRAPPER test_fdw CONNECTION test_fdw_connection_no_password; +WARNING: changing the foreign-data wrapper connection function can cause the options for dependent objects to become invalid +-- fail, new owner's generated conninfo must satisfy password_required +ALTER SUBSCRIPTION regress_testsub6 OWNER TO regress_subscription_user2; +ERROR: password is required +DETAIL: Non-superusers must provide a password in the connection string. +ALTER FOREIGN DATA WRAPPER test_fdw CONNECTION test_fdw_connection; +WARNING: changing the foreign-data wrapper connection function can cause the options for dependent objects to become invalid +DROP USER MAPPING FOR regress_subscription_user2 SERVER test_server; +REVOKE USAGE ON FOREIGN SERVER test_server FROM regress_subscription_user2; REVOKE USAGE ON FOREIGN SERVER test_server FROM regress_subscription_user3; SET SESSION AUTHORIZATION regress_subscription_user3; -- ok, lacks USAGE on test_server, but replacing connection anyway @@ -231,6 +247,7 @@ HINT: Use DROP ... CASCADE to drop the dependent objects too. ALTER FOREIGN DATA WRAPPER test_fdw NO CONNECTION; WARNING: removing the foreign-data wrapper connection function will cause dependent subscriptions to fail DROP FUNCTION test_fdw_connection(oid, oid, internal); +DROP FUNCTION test_fdw_connection_no_password(oid, oid, internal); DROP FOREIGN DATA WRAPPER test_fdw; -- fail - invalid connection string during ALTER ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar'; diff --git a/src/test/regress/regress.c b/src/test/regress/regress.c index 9801cdd1d8c3e..14d301b3499ec 100644 --- a/src/test/regress/regress.c +++ b/src/test/regress/regress.c @@ -742,6 +742,15 @@ test_fdw_connection(PG_FUNCTION_ARGS) PG_RETURN_TEXT_P(cstring_to_text("dbname=regress_doesnotexist user=doesnotexist password=secret")); } +PG_FUNCTION_INFO_V1(test_fdw_connection_no_password); +Datum +test_fdw_connection_no_password(PG_FUNCTION_ARGS) +{ + /* Ensure the test fails if no valid user mapping exists. */ + GetUserMapping(PG_GETARG_OID(0), PG_GETARG_OID(1)); + PG_RETURN_TEXT_P(cstring_to_text("dbname=regress_doesnotexist user=doesnotexist")); +} + PG_FUNCTION_INFO_V1(is_catalog_text_unique_index_oid); Datum is_catalog_text_unique_index_oid(PG_FUNCTION_ARGS) diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql index 86c402c59aa6d..f19740fdfb838 100644 --- a/src/test/regress/sql/subscription.sql +++ b/src/test/regress/sql/subscription.sql @@ -12,6 +12,10 @@ CREATE FUNCTION test_fdw_connection(oid, oid, internal) RETURNS text AS :'regresslib', 'test_fdw_connection' LANGUAGE C; +CREATE FUNCTION test_fdw_connection_no_password(oid, oid, internal) + RETURNS text + AS :'regresslib', 'test_fdw_connection_no_password' + LANGUAGE C; CREATE ROLE regress_subscription_user LOGIN SUPERUSER; CREATE ROLE regress_subscription_user2; @@ -136,6 +140,17 @@ CREATE SUBSCRIPTION regress_testsub6 SERVER test_server PUBLICATION testpub WITH (slot_name = 'dummy', connect = false); RESET SESSION AUTHORIZATION; +GRANT USAGE ON FOREIGN SERVER test_server TO regress_subscription_user2; +CREATE USER MAPPING FOR regress_subscription_user2 SERVER test_server OPTIONS(user 'foo'); +ALTER FOREIGN DATA WRAPPER test_fdw CONNECTION test_fdw_connection_no_password; + +-- fail, new owner's generated conninfo must satisfy password_required +ALTER SUBSCRIPTION regress_testsub6 OWNER TO regress_subscription_user2; + +ALTER FOREIGN DATA WRAPPER test_fdw CONNECTION test_fdw_connection; +DROP USER MAPPING FOR regress_subscription_user2 SERVER test_server; +REVOKE USAGE ON FOREIGN SERVER test_server FROM regress_subscription_user2; + REVOKE USAGE ON FOREIGN SERVER test_server FROM regress_subscription_user3; SET SESSION AUTHORIZATION regress_subscription_user3; @@ -182,6 +197,7 @@ DROP FUNCTION test_fdw_connection(oid, oid, internal); ALTER FOREIGN DATA WRAPPER test_fdw NO CONNECTION; DROP FUNCTION test_fdw_connection(oid, oid, internal); +DROP FUNCTION test_fdw_connection_no_password(oid, oid, internal); DROP FOREIGN DATA WRAPPER test_fdw; From c5f1f41b52b60d4d27a9d77e074da553ee98e26e Mon Sep 17 00:00:00 2001 From: Fujii Masao Date: Fri, 24 Jul 2026 15:44:56 +0900 Subject: [PATCH 07/43] Fix EXCEPT publication test to check subscriber Commit fd366065e06 added tests intended to verify that rows inserted on the publisher are replicated to the subscriber when using multiple publications, with one excluding the target table via EXCEPT and another including it. However, the tests queried the publisher instead of the subscriber. Since the rows were inserted directly into the publisher, the checks would always succeed, providing no coverage of replication. Fix this by querying the subscriber so the tests verify the replicated state. Author: Fujii Masao Reviewed-by: Ayush Tiwari Reviewed-by: Amit Kapila Discussion: https://postgr.es/m/CAHGQGwGfXUO7f4t6KNGurYwg6QsnLtpP0K3EACbAwYWtxGfKfQ@mail.gmail.com Backpatch-through: 19 --- src/test/subscription/t/037_except.pl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/subscription/t/037_except.pl b/src/test/subscription/t/037_except.pl index 8c58d282eeed0..43b51c8ff712e 100644 --- a/src/test/subscription/t/037_except.pl +++ b/src/test/subscription/t/037_except.pl @@ -244,7 +244,7 @@ sub test_except_root_partition $node_publisher->wait_for_catchup('tap_sub'); $result = - $node_publisher->safe_psql('postgres', "SELECT * FROM tab1 ORDER BY a"); + $node_subscriber->safe_psql('postgres', "SELECT * FROM tab1 ORDER BY a"); is( $result, qq(1 2), "check replication of a table in the EXCEPT clause of one publication but included by another" @@ -272,7 +272,7 @@ sub test_except_root_partition $node_publisher->wait_for_catchup('tap_sub'); $result = - $node_publisher->safe_psql('postgres', "SELECT * FROM tab1 ORDER BY a"); + $node_subscriber->safe_psql('postgres', "SELECT * FROM tab1 ORDER BY a"); is( $result, qq(1 2), "check replication of a table in the EXCEPT clause of one publication but included by another" From b77868f169adcdf31edbc80d8a875204ed7ba191 Mon Sep 17 00:00:00 2001 From: Fujii Masao Date: Fri, 24 Jul 2026 15:46:46 +0900 Subject: [PATCH 08/43] doc: Add missing CREATE/ALTER PUBLICATION parameter descriptions Document table_name, column_name, and schema_name in the CREATE PUBLICATION and ALTER PUBLICATION reference pages. Also add anchors for the ALTER PUBLICATION parameter list, matching the style already used by CREATE PUBLICATION. Author: Peter Smith Reviewed-by: Fujii Masao Discussion: https://postgr.es/m/CAHut+Ptekz+TO4ui8-fiBm4Y+O2v=HQnkK_cW4G=w9ep8654EA@mail.gmail.com --- doc/src/sgml/ref/alter_publication.sgml | 32 +++++++++++------ doc/src/sgml/ref/create_publication.sgml | 45 ++++++++++++++++++++---- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/doc/src/sgml/ref/alter_publication.sgml b/doc/src/sgml/ref/alter_publication.sgml index 52114a16a391e..d2898d2633eaf 100644 --- a/doc/src/sgml/ref/alter_publication.sgml +++ b/doc/src/sgml/ref/alter_publication.sgml @@ -149,7 +149,7 @@ ALTER PUBLICATION name RENAME TO Parameters - + name @@ -158,15 +158,17 @@ ALTER PUBLICATION name RENAME TO - + table_name Name of an existing table. If ONLY is specified before the - table name, only that table is affected. If ONLY is not - specified, the table and all its descendant tables (if any) are - affected. Optionally, * can be specified after the table - name to explicitly indicate that descendant tables are included. + table_name, only that table + is affected. If ONLY is not specified, the table and + all its descendant tables (if any) are affected. Optionally, + * can be specified after the + table_name to explicitly + indicate that descendant tables are included. @@ -189,7 +191,17 @@ ALTER PUBLICATION name RENAME TO - + + column_name + + + Name of an existing column of + table_name. + + + + + schema_name @@ -198,7 +210,7 @@ ALTER PUBLICATION name RENAME TO - + SET ( publication_parameter [= value] [, ... ] ) @@ -224,7 +236,7 @@ ALTER PUBLICATION name RENAME TO - + new_owner @@ -233,7 +245,7 @@ ALTER PUBLICATION name RENAME TO - + new_name diff --git a/doc/src/sgml/ref/create_publication.sgml b/doc/src/sgml/ref/create_publication.sgml index 85cfcaddafa66..35c28006f601c 100644 --- a/doc/src/sgml/ref/create_publication.sgml +++ b/doc/src/sgml/ref/create_publication.sgml @@ -79,15 +79,45 @@ CREATE PUBLICATION name + + table_name + + + Name of an existing table. + + + + + + column_name + + + Name of an existing column of + table_name. + + + + + + schema_name + + + Name of an existing schema. + + + + FOR TABLE Specifies a list of tables to add to the publication. If - ONLY is specified before the table name, only + ONLY is specified before the + table_name, only that table is added to the publication. If ONLY is not specified, the table and all its descendant tables (if any) are added. - Optionally, * can be specified after the table name to + Optionally, * can be specified after the + table_name to explicitly indicate that descendant tables are included. This does not apply to a partitioned table, however. The partitions of a partitioned table are always implicitly considered part of the @@ -208,11 +238,12 @@ CREATE PUBLICATION name For inherited tables, if ONLY is specified before the - table name, only that table is excluded from the publication. If - ONLY is not specified, the table and all its descendant - tables (if any) are excluded. Optionally, * can be - specified after the table name to explicitly indicate that descendant - tables are excluded. + table_name, only that table + is excluded from the publication. If ONLY is not + specified, the table and all its descendant tables (if any) are excluded. + Optionally, * can be specified after the + table_name to explicitly + indicate that descendant tables are excluded. For partitioned tables, only the root partitioned table may be specified From 13b7a8a0ef56d9decae284b4983894175c17d217 Mon Sep 17 00:00:00 2001 From: Fujii Masao Date: Sat, 25 Jul 2026 10:30:30 +0900 Subject: [PATCH 09/43] Avoid reporting permission-denied publisher sequences as missing Previously, if a sequence synchronization batch contained both a sequence that had been dropped on the publisher and another for which the replication role lacked SELECT privilege, the latter was reported twice: once as a permission failure and again as missing on the publisher. This happened because the permission-denied sequence was not marked as found on the publisher. As a result, when another sequence in the batch was genuinely missing, the later missing-sequence check incorrectly classified the permission-denied sequence as missing as well. Fix this by marking the permission-denied sequence as found before reporting the permission failure, so it is not later reported as missing. Reported-by: Noah Misch Author: Vignesh C Reviewed-by: Fujii Masao Discussion: https://postgr.es/m/CALDaNm3LsUjW7PahuCsbYAxajSF+S328tw5E9rF0erdh7dKOXw@mail.gmail.com Backpatch-through: 19 --- .../replication/logical/sequencesync.c | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/backend/replication/logical/sequencesync.c b/src/backend/replication/logical/sequencesync.c index 28d4d011a84e0..d0370056de311 100644 --- a/src/backend/replication/logical/sequencesync.c +++ b/src/backend/replication/logical/sequencesync.c @@ -308,8 +308,24 @@ get_and_validate_seq_info(TupleTableSlot *slot, Relation *sequence_rel, */ datum = slot_getattr(slot, ++col, &isnull); if (isnull) - return remote_has_select_priv ? COPYSEQ_SKIPPED : - COPYSEQ_PUBLISHER_INSUFFICIENT_PERM; + { + /* + * The sequence was dropped concurrently after it was identified in + * the catalog snapshot. Treat it as skipped (and, since it no longer + * exists on the publisher, ultimately missing). + */ + if (remote_has_select_priv) + return COPYSEQ_SKIPPED; + + /* + * The publisher lacks the SELECT privilege required by + * pg_get_sequence_data(). Since has_sequence_privilege() returned + * false, not NULL, do not classify this sequence as missing on the + * publisher. + */ + seqinfo_local->found_on_pub = true; + return COPYSEQ_PUBLISHER_INSUFFICIENT_PERM; + } seqinfo_local->last_value = DatumGetInt64(datum); From 38afc3dcb25c45b744d4025029ce0a6c90b7059f Mon Sep 17 00:00:00 2001 From: Fujii Masao Date: Sat, 25 Jul 2026 19:08:27 +0900 Subject: [PATCH 10/43] psql: Allow pg_read_all_stats to see database size in \l+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pg_database_size() allows access to users who have either CONNECT privilege on the target database or privileges of the pg_read_all_stats role. However, previously, psql's \l+ checked only for CONNECT, so users with privileges of pg_read_all_stats still saw "No Access" for databases they could not connect to. Fix this by making \l+ also check pg_has_role('pg_read_all_stats', 'USAGE'), matching pg_database_size()'s permission rules. For back branches, emit the pg_read_all_stats check only when connected to PostgreSQL 10 or later, since earlier releases do not have that predefined role. Backpatch to all supported versions. Author: Christoph Berg Reviewed-by: Álvaro Herrera Reviewed-by: Fujii Masao Discussion: https://postgr.es/m/amCo6qRmnfPVk4-V@msg.df7cb.de Backpatch-through: 14 --- doc/src/sgml/ref/psql-ref.sgml | 5 +++-- src/bin/psql/describe.c | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/doc/src/sgml/ref/psql-ref.sgml b/doc/src/sgml/ref/psql-ref.sgml index 56c2692e618cb..3ec0a3c3b3404 100644 --- a/doc/src/sgml/ref/psql-ref.sgml +++ b/doc/src/sgml/ref/psql-ref.sgml @@ -2817,8 +2817,9 @@ SELECT are displayed in expanded mode. If + is appended to the command name, database sizes, default tablespaces, and descriptions are also displayed. - (Size information is only available for databases that the current - user can connect to.) + Size information is available for databases on which the current user has + CONNECT privilege, or if the current user is a superuser + or has privileges of the pg_read_all_stats role. diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c index a2f09c2636990..ad9c8affb4f2f 100644 --- a/src/bin/psql/describe.c +++ b/src/bin/psql/describe.c @@ -986,7 +986,8 @@ listAllDbs(const char *pattern, bool verbose) printACLColumn(&buf, "d.datacl"); if (verbose) appendPQExpBuffer(&buf, - ",\n CASE WHEN pg_catalog.has_database_privilege(d.datname, 'CONNECT')\n" + ",\n CASE WHEN pg_catalog.has_database_privilege(d.datname, 'CONNECT') OR\n" + " pg_catalog.pg_has_role('pg_read_all_stats', 'USAGE')\n" " THEN pg_catalog.pg_size_pretty(pg_catalog.pg_database_size(d.datname))\n" " ELSE 'No Access'\n" " END as \"%s\"" From ce3f19e26218283eaff6436e28113b532bfc4a6f Mon Sep 17 00:00:00 2001 From: Peter Geoghegan Date: Sat, 25 Jul 2026 12:01:35 -0400 Subject: [PATCH 11/43] Fix another empty nbtree index SSI race. Commit f9b7fc65 fixed a race when predicate-locking completely empty btrees: without a buffer lock held, a matching key could be inserted between _bt_search and the PredicateLockRelation call, so the scan would miss concurrently inserted tuples while the writer wouldn't see the reader's predicate lock. That commit only fixed _bt_first's _bt_search path, though. Scans without useful insertion scan keys return early from _bt_first via _bt_endpoint, which still didn't recheck if the relation was empty. To fix, add handling to _bt_endpoint that is analogous to the handling added to _bt_search by commit f9b7fc65. Author: Peter Geoghegan Discussion: https://postgr.es/m/CAH2-WzkNoTn3yXY0iGkSuavJ+sL8EROf+kitW+_2v2tJVWuKmA@mail.gmail.com Backpatch-through: 14 --- src/backend/access/nbtree/nbtsearch.c | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c index aae6acb7f57dd..dfcdd2d4cec07 100644 --- a/src/backend/access/nbtree/nbtsearch.c +++ b/src/backend/access/nbtree/nbtsearch.c @@ -2195,12 +2195,21 @@ _bt_endpoint(IndexScanDesc scan, ScanDirection dir) if (!BufferIsValid(so->currPos.buf)) { /* - * Empty index. Lock the whole relation, as nothing finer to lock - * exists. + * Empty index. Lock the whole relation using the approach explained + * at the same point in the _bt_first path. */ - PredicateLockRelation(rel, scan->xs_snapshot); - _bt_parallel_done(scan); - return false; + if (IsolationIsSerializable()) + { + PredicateLockRelation(rel, scan->xs_snapshot); + so->currPos.buf = _bt_get_endpoint(rel, 0, + ScanDirectionIsBackward(dir)); + } + + if (!BufferIsValid(so->currPos.buf)) + { + _bt_parallel_done(scan); + return false; + } } page = BufferGetPage(so->currPos.buf); From 5168655bc5ed68c0b7d7d3723adbd49c55bafda7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Herrera?= Date: Sat, 25 Jul 2026 19:16:42 +0200 Subject: [PATCH 12/43] Add missing PGDLLIMPORT marker Oversight in commit fb23cc7e81db. Reported-by: Anton Voloshin Discussion: https://postgr.es/m/ad5d772e-09d9-4248-97a4-0011afab9e71@postgrespro.ru --- src/include/postmaster/syslogger.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/include/postmaster/syslogger.h b/src/include/postmaster/syslogger.h index 44409fc2542d5..0e01db63435a6 100644 --- a/src/include/postmaster/syslogger.h +++ b/src/include/postmaster/syslogger.h @@ -85,7 +85,7 @@ extern PGDLLIMPORT int syslogPipe[2]; extern PGDLLIMPORT HANDLE syslogPipe[2]; #endif -extern bool syslogger_setup_done; +extern PGDLLIMPORT bool syslogger_setup_done; extern int SysLogger_Start(int child_slot); From 62c05d6f2fa64cce44e57871b4cfcd7b34589fcf Mon Sep 17 00:00:00 2001 From: Peter Geoghegan Date: Sat, 25 Jul 2026 14:15:33 -0400 Subject: [PATCH 13/43] Add tests for nbtree empty index predicate locking. Add coverage for predicate locking of completely empty nbtree indexes, where we must predicate lock the entire relation (instead of some individual leaf page). Both paths that can find the index empty (and must consider whether it's still empty after PredicateLockRelation returns) are covered by a new isolation test that uses injection points. Catalog relation scans skip the injection points. The waiting session runs catalog queries of its own after arming the (session-local) points, and could otherwise suspend itself with nothing lined up to wake it. Follow-up to bugfix commits ce3f19e2 (the _bt_endpoint fix) and f9b7fc65 (the _bt_first/_bt_search fix). Author: Peter Geoghegan Discussion: https://postgr.es/m/CAH2-WzkNoTn3yXY0iGkSuavJ+sL8EROf+kitW+_2v2tJVWuKmA@mail.gmail.com --- src/backend/access/nbtree/nbtsearch.c | 12 +++ src/test/modules/nbtree/Makefile | 2 + .../nbtree/expected/predicate-empty-index.out | 87 +++++++++++++++++++ src/test/modules/nbtree/meson.build | 5 ++ .../nbtree/specs/predicate-empty-index.spec | 73 ++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 src/test/modules/nbtree/expected/predicate-empty-index.out create mode 100644 src/test/modules/nbtree/specs/predicate-empty-index.spec diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c index dfcdd2d4cec07..8eb245e2fcb45 100644 --- a/src/backend/access/nbtree/nbtsearch.c +++ b/src/backend/access/nbtree/nbtsearch.c @@ -18,10 +18,12 @@ #include "access/nbtree.h" #include "access/relscan.h" #include "access/xact.h" +#include "catalog/catalog.h" #include "executor/instrument_node.h" #include "miscadmin.h" #include "pgstat.h" #include "storage/predicate.h" +#include "utils/injection_point.h" #include "utils/lsyscache.h" #include "utils/rel.h" @@ -1516,6 +1518,11 @@ _bt_first(IndexScanDesc scan, ScanDirection dir) { Assert(!so->needPrimScan); +#ifdef USE_INJECTION_POINTS + if (!IsCatalogRelation(rel)) + INJECTION_POINT("nbtree-first-empty", NULL); +#endif + /* * We only get here if the index is completely empty. Lock relation * because nothing finer to lock exists. Without a buffer lock, it's @@ -2194,6 +2201,11 @@ _bt_endpoint(IndexScanDesc scan, ScanDirection dir) if (!BufferIsValid(so->currPos.buf)) { +#ifdef USE_INJECTION_POINTS + if (!IsCatalogRelation(rel)) + INJECTION_POINT("nbtree-endpoint-empty", NULL); +#endif + /* * Empty index. Lock the whole relation using the approach explained * at the same point in the _bt_first path. diff --git a/src/test/modules/nbtree/Makefile b/src/test/modules/nbtree/Makefile index eec264b16a4ce..72b42d32e23ac 100644 --- a/src/test/modules/nbtree/Makefile +++ b/src/test/modules/nbtree/Makefile @@ -5,6 +5,8 @@ EXTRA_INSTALL = src/test/modules/injection_points contrib/amcheck REGRESS = nbtree_half_dead_pages \ nbtree_incomplete_splits +ISOLATION = predicate-empty-index + ifdef USE_PGXS PG_CONFIG = pg_config PGXS := $(shell $(PG_CONFIG) --pgxs) diff --git a/src/test/modules/nbtree/expected/predicate-empty-index.out b/src/test/modules/nbtree/expected/predicate-empty-index.out new file mode 100644 index 0000000000000..455988e1c0b31 --- /dev/null +++ b/src/test/modules/nbtree/expected/predicate-empty-index.out @@ -0,0 +1,87 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_scan_first s2_scan s2_insert s2_commit s2_wakeup_first s1_insert s1_commit s2_detach +injection_points_attach +----------------------- + +(1 row) + +step s1_scan_first: SELECT id FROM ssi_btree WHERE id = 2 AND pg_backend_pid() <> 0; +step s2_scan: SELECT id FROM ssi_btree; +id +-- +(0 rows) + +step s2_insert: INSERT INTO ssi_btree VALUES (2); +step s2_commit: COMMIT; +step s2_wakeup_first: SELECT injection_points_wakeup('nbtree-first-empty'); +injection_points_wakeup +----------------------- + +(1 row) + +step s1_scan_first: <... completed> +id +-- +(0 rows) + +step s1_insert: INSERT INTO ssi_btree VALUES (1); +ERROR: could not serialize access due to read/write dependencies among transactions +step s1_commit: COMMIT; +step s2_detach: + SELECT injection_points_detach('nbtree-first-empty'); + SELECT injection_points_detach('nbtree-endpoint-empty'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: s1_scan_endpoint s2_scan s2_insert s2_commit s2_wakeup_endpoint s1_insert s1_commit s2_detach +injection_points_attach +----------------------- + +(1 row) + +step s1_scan_endpoint: SELECT id FROM ssi_btree WHERE pg_backend_pid() <> 0 ORDER BY id; +step s2_scan: SELECT id FROM ssi_btree; +id +-- +(0 rows) + +step s2_insert: INSERT INTO ssi_btree VALUES (2); +step s2_commit: COMMIT; +step s2_wakeup_endpoint: SELECT injection_points_wakeup('nbtree-endpoint-empty'); +injection_points_wakeup +----------------------- + +(1 row) + +step s1_scan_endpoint: <... completed> +id +-- +(0 rows) + +step s1_insert: INSERT INTO ssi_btree VALUES (1); +ERROR: could not serialize access due to read/write dependencies among transactions +step s1_commit: COMMIT; +step s2_detach: + SELECT injection_points_detach('nbtree-first-empty'); + SELECT injection_points_detach('nbtree-endpoint-empty'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/nbtree/meson.build b/src/test/modules/nbtree/meson.build index 209c3323b71f0..8cf861cb2fc5d 100644 --- a/src/test/modules/nbtree/meson.build +++ b/src/test/modules/nbtree/meson.build @@ -14,4 +14,9 @@ tests += { 'nbtree_incomplete_splits', ], }, + 'isolation': { + 'specs': [ + 'predicate-empty-index', + ], + }, } diff --git a/src/test/modules/nbtree/specs/predicate-empty-index.spec b/src/test/modules/nbtree/specs/predicate-empty-index.spec new file mode 100644 index 0000000000000..bfcb8bd1ab8f8 --- /dev/null +++ b/src/test/modules/nbtree/specs/predicate-empty-index.spec @@ -0,0 +1,73 @@ +# Test SSI's handling of concurrent insertions into an initially empty +# btree index. +# +# When predicate-locking a completely empty btree there is no page to +# lock, so we lock the whole relation instead. This was racy: without a +# buffer lock held, a concurrent transaction can insert a matching key +# between the descent that found the index empty and the +# PredicateLockRelation() call. The scan then misses the inserted tuple, +# but the writer doesn't see the reader's predicate lock either, allowing +# a write skew anomaly to go undetected. + +setup +{ + CREATE EXTENSION injection_points; + CREATE TABLE ssi_btree (id int PRIMARY KEY); +} + +teardown +{ + DROP TABLE ssi_btree; + DROP EXTENSION injection_points; +} + +session s1 +setup { + BEGIN ISOLATION LEVEL SERIALIZABLE; + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT injection_points_set_local(); + SELECT injection_points_attach('nbtree-first-empty', 'wait'); + SELECT injection_points_attach('nbtree-endpoint-empty', 'wait'); +} +# Scan with a useful insertion scan key: descends via _bt_first/_bt_search. +step s1_scan_first { SELECT id FROM ssi_btree WHERE id = 2 AND pg_backend_pid() <> 0; } +# Scan without useful insertion scan keys: starts at _bt_endpoint(). +step s1_scan_endpoint { SELECT id FROM ssi_btree WHERE pg_backend_pid() <> 0 ORDER BY id; } +step s1_insert { INSERT INTO ssi_btree VALUES (1); } +step s1_commit { COMMIT; } + +# Note: Both scan variants call parallel restricted pg_backend_pid() so that +# the scan runs in the leader process under debug_parallel_query + +session s2 +setup { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step s2_scan { SELECT id FROM ssi_btree; } +step s2_insert { INSERT INTO ssi_btree VALUES (2); } +step s2_commit { COMMIT; } +step s2_wakeup_first { SELECT injection_points_wakeup('nbtree-first-empty'); } +step s2_wakeup_endpoint { SELECT injection_points_wakeup('nbtree-endpoint-empty'); } +step s2_detach { + SELECT injection_points_detach('nbtree-first-empty'); + SELECT injection_points_detach('nbtree-endpoint-empty'); +} + +# _bt_first()/_bt_search() path +permutation s1_scan_first + s2_scan + s2_insert + s2_commit + s2_wakeup_first + s1_insert + s1_commit + s2_detach + +# _bt_endpoint() path +permutation s1_scan_endpoint + s2_scan + s2_insert + s2_commit + s2_wakeup_endpoint + s1_insert + s1_commit + s2_detach From e395fbd32a07557de4ac98088928c1749d4845d8 Mon Sep 17 00:00:00 2001 From: Peter Geoghegan Date: Sat, 25 Jul 2026 17:13:00 -0400 Subject: [PATCH 14/43] Add test coverage for nbtree backwards scans. Backwards scans have unique concurrency rules: rather than unreservedly trusting a saved left link, the scan optimistically rechecks its pointed-to leaf page's right link (i.e. whether it still points back to the page that _bt_readpage just read). Usually, the left sibling of the just-read page won't have changed, in which case the scan can proceed with reading the left sibling as planned. But it's possible that the key space that the scan needs to read next is no longer covered by the original left sibling page due to concurrent page splits and/or page deletions. When that happens, the scan must recover by relocating the new/current left sibling of the just-read page. Test coverage for backwards scans was limited to the happy path. Add an isolation test (and associated injection points) that test the recovery path. This covers several distinct recovery scenarios (concurrent page splits, concurrent page deletions, and minor variants thereof). Author: Peter Geoghegan Reviewed-by: Andrey Borodin Discussion: https://postgr.es/m/CAH2-WzmD+jUBOpFS2jrnqqrdPSAjoxqyL9FPKaE1BtnY=8Nntg@mail.gmail.com --- src/backend/access/nbtree/nbtsearch.c | 21 ++ src/test/modules/nbtree/Makefile | 3 +- .../backwards-scan-concurrent-splits.out | 304 ++++++++++++++++++ src/test/modules/nbtree/meson.build | 2 + .../backwards-scan-concurrent-splits.spec | 123 +++++++ 5 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 src/test/modules/nbtree/expected/backwards-scan-concurrent-splits.out create mode 100644 src/test/modules/nbtree/specs/backwards-scan-concurrent-splits.spec diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c index 8eb245e2fcb45..5964bc9195e1d 100644 --- a/src/backend/access/nbtree/nbtsearch.c +++ b/src/backend/access/nbtree/nbtsearch.c @@ -1984,6 +1984,11 @@ _bt_lock_and_validate_left(Relation rel, BlockNumber *blkno, { BlockNumber origblkno = *blkno; /* detects circular links */ +#ifdef USE_INJECTION_POINTS + if (!IsCatalogRelation(rel)) + INJECTION_POINT("nbtree-walk-left", NULL); +#endif + for (;;) { Buffer buf; @@ -2018,6 +2023,12 @@ _bt_lock_and_validate_left(Relation rel, BlockNumber *blkno, } if (P_RIGHTMOST(opaque) || ++tries > 4) break; + +#ifdef USE_INJECTION_POINTS + if (!IsCatalogRelation(rel)) + INJECTION_POINT("nbtree-walk-left-step-right", NULL); +#endif + /* step right */ *blkno = opaque->btpo_next; buf = _bt_relandgetbuf(rel, buf, *blkno, BT_READ); @@ -2035,6 +2046,11 @@ _bt_lock_and_validate_left(Relation rel, BlockNumber *blkno, opaque = BTPageGetOpaque(page); if (P_ISDELETED(opaque)) { +#ifdef USE_INJECTION_POINTS + if (!IsCatalogRelation(rel)) + INJECTION_POINT("nbtree-walk-left-deleted", NULL); +#endif + /* * It was deleted. Move right to first nondeleted page (there * must be one); that is the page that has acquired the deleted @@ -2082,6 +2098,11 @@ _bt_lock_and_validate_left(Relation rel, BlockNumber *blkno, /* Start from scratch with new lastcurrblkno's blkno/prev link */ *blkno = origblkno = opaque->btpo_prev; _bt_relbuf(rel, buf); + +#ifdef USE_INJECTION_POINTS + if (!IsCatalogRelation(rel)) + INJECTION_POINT("nbtree-walk-left-restart", NULL); +#endif } return InvalidBuffer; diff --git a/src/test/modules/nbtree/Makefile b/src/test/modules/nbtree/Makefile index 72b42d32e23ac..20a1ca6a92ba4 100644 --- a/src/test/modules/nbtree/Makefile +++ b/src/test/modules/nbtree/Makefile @@ -5,7 +5,8 @@ EXTRA_INSTALL = src/test/modules/injection_points contrib/amcheck REGRESS = nbtree_half_dead_pages \ nbtree_incomplete_splits -ISOLATION = predicate-empty-index +ISOLATION = backwards-scan-concurrent-splits \ + predicate-empty-index ifdef USE_PGXS PG_CONFIG = pg_config diff --git a/src/test/modules/nbtree/expected/backwards-scan-concurrent-splits.out b/src/test/modules/nbtree/expected/backwards-scan-concurrent-splits.out new file mode 100644 index 0000000000000..906c10d10aa0c --- /dev/null +++ b/src/test/modules/nbtree/expected/backwards-scan-concurrent-splits.out @@ -0,0 +1,304 @@ +Parsed test spec with 2 sessions + +starting permutation: b_attach b_scan i_insert_dups i_detach b_detach +step b_attach: + SELECT injection_points_attach('nbtree-walk-left', 'wait'); + SELECT injection_points_attach('nbtree-walk-left-step-right', 'notice'); + SELECT injection_points_attach('nbtree-walk-left-restart', 'notice'); + SELECT injection_points_attach('nbtree-walk-left-deleted', 'notice'); + +injection_points_attach +----------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step b_scan: SELECT col FROM backwards_scan_tbl + WHERE col % 100 = 1 AND pg_backend_pid() <> 0 + ORDER BY col DESC; +step i_insert_dups: INSERT INTO backwards_scan_tbl SELECT 100 FROM generate_series(1, 60); +step i_detach: + SELECT injection_points_detach('nbtree-walk-left'); + SELECT injection_points_wakeup('nbtree-walk-left'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_wakeup +----------------------- + +(1 row) + +scan_session: NOTICE: notice triggered for injection point nbtree-walk-left-step-right +step b_scan: <... completed> +col +--- +601 +501 +401 +301 +201 +101 + 1 +(7 rows) + +step b_detach: + SELECT injection_points_detach('nbtree-walk-left-step-right'); + SELECT injection_points_detach('nbtree-walk-left-restart'); + SELECT injection_points_detach('nbtree-walk-left-deleted'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_detach +----------------------- + +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: b_attach b_scan i_insert i_detach b_detach +step b_attach: + SELECT injection_points_attach('nbtree-walk-left', 'wait'); + SELECT injection_points_attach('nbtree-walk-left-step-right', 'notice'); + SELECT injection_points_attach('nbtree-walk-left-restart', 'notice'); + SELECT injection_points_attach('nbtree-walk-left-deleted', 'notice'); + +injection_points_attach +----------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step b_scan: SELECT col FROM backwards_scan_tbl + WHERE col % 100 = 1 AND pg_backend_pid() <> 0 + ORDER BY col DESC; +step i_insert: INSERT INTO backwards_scan_tbl SELECT i FROM generate_series(-2000, 700) i; +step i_detach: + SELECT injection_points_detach('nbtree-walk-left'); + SELECT injection_points_wakeup('nbtree-walk-left'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_wakeup +----------------------- + +(1 row) + +scan_session: NOTICE: notice triggered for injection point nbtree-walk-left-step-right +scan_session: NOTICE: notice triggered for injection point nbtree-walk-left-step-right +scan_session: NOTICE: notice triggered for injection point nbtree-walk-left-step-right +scan_session: NOTICE: notice triggered for injection point nbtree-walk-left-step-right +scan_session: NOTICE: notice triggered for injection point nbtree-walk-left-restart +step b_scan: <... completed> +col +--- +601 +501 +401 +301 +201 +101 + 1 +(7 rows) + +step b_detach: + SELECT injection_points_detach('nbtree-walk-left-step-right'); + SELECT injection_points_detach('nbtree-walk-left-restart'); + SELECT injection_points_detach('nbtree-walk-left-deleted'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_detach +----------------------- + +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: b_attach d_delete_left b_scan vacuum_tbl i_detach b_detach +step b_attach: + SELECT injection_points_attach('nbtree-walk-left', 'wait'); + SELECT injection_points_attach('nbtree-walk-left-step-right', 'notice'); + SELECT injection_points_attach('nbtree-walk-left-restart', 'notice'); + SELECT injection_points_attach('nbtree-walk-left-deleted', 'notice'); + +injection_points_attach +----------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step d_delete_left: DELETE FROM backwards_scan_tbl WHERE col < 601; +step b_scan: SELECT col FROM backwards_scan_tbl + WHERE col % 100 = 1 AND pg_backend_pid() <> 0 + ORDER BY col DESC; +step vacuum_tbl: VACUUM backwards_scan_tbl; +step i_detach: + SELECT injection_points_detach('nbtree-walk-left'); + SELECT injection_points_wakeup('nbtree-walk-left'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_wakeup +----------------------- + +(1 row) + +scan_session: NOTICE: notice triggered for injection point nbtree-walk-left-step-right +step b_scan: <... completed> +col +--- +601 +(1 row) + +step b_detach: + SELECT injection_points_detach('nbtree-walk-left-step-right'); + SELECT injection_points_detach('nbtree-walk-left-restart'); + SELECT injection_points_detach('nbtree-walk-left-deleted'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_detach +----------------------- + +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: b_attach_nosr i_grow d_delete_mid b_scan_999 vacuum_tbl i_detach b_detach_nosr +step b_attach_nosr: + SELECT injection_points_attach('nbtree-walk-left', 'wait'); + SELECT injection_points_attach('nbtree-walk-left-restart', 'notice'); + SELECT injection_points_attach('nbtree-walk-left-deleted', 'notice'); + +injection_points_attach +----------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step i_grow: INSERT INTO backwards_scan_tbl SELECT i FROM generate_series(701, 2200) i; +step d_delete_mid: DELETE FROM backwards_scan_tbl WHERE col BETWEEN 367 AND 2100; +step b_scan_999: SELECT col FROM backwards_scan_tbl + WHERE col <= 999 AND col % 100 = 1 AND pg_backend_pid() <> 0 + ORDER BY col DESC; +step vacuum_tbl: VACUUM backwards_scan_tbl; +step i_detach: + SELECT injection_points_detach('nbtree-walk-left'); + SELECT injection_points_wakeup('nbtree-walk-left'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_wakeup +----------------------- + +(1 row) + +scan_session: NOTICE: notice triggered for injection point nbtree-walk-left-deleted +scan_session: NOTICE: notice triggered for injection point nbtree-walk-left-restart +step b_scan_999: <... completed> +col +--- +301 +201 +101 + 1 +(4 rows) + +step b_detach_nosr: + SELECT injection_points_detach('nbtree-walk-left-restart'); + SELECT injection_points_detach('nbtree-walk-left-deleted'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/nbtree/meson.build b/src/test/modules/nbtree/meson.build index 8cf861cb2fc5d..b5dc026392eac 100644 --- a/src/test/modules/nbtree/meson.build +++ b/src/test/modules/nbtree/meson.build @@ -16,7 +16,9 @@ tests += { }, 'isolation': { 'specs': [ + 'backwards-scan-concurrent-splits', 'predicate-empty-index', ], + 'runningcheck': false, # see syscache-update-pruned }, } diff --git a/src/test/modules/nbtree/specs/backwards-scan-concurrent-splits.spec b/src/test/modules/nbtree/specs/backwards-scan-concurrent-splits.spec new file mode 100644 index 0000000000000..62c0cf25a3504 --- /dev/null +++ b/src/test/modules/nbtree/specs/backwards-scan-concurrent-splits.spec @@ -0,0 +1,123 @@ +# Backwards scan isolation test +# +# Backwards scans cannot unreservedly trust their saved left link: by the time +# the scan follows it, concurrent page splits and/or page deletions may have +# left it pointing to a page that is no longer the correct page for the scan +# to read next. The scan checks for this by verifying that the pointed-to +# page's right link still points back to the page that the scan just read, and +# recovers when it doesn't (see nbtree/README for details). +# +# Each permutation makes the scan wait "between pages" at the nbtree-walk-left +# injection point while the concurrent session splits and/or deletes pages, +# then wakes it, forcing the scan to take one of its recovery paths. The +# notice-mode injection points confirm which recovery steps ran. +# +# Note: the permutations' expected notifications (and the leaf pages that each +# concurrent session step splits or deletes) assume the default 8KB BLCKSZ. + +setup +{ + CREATE EXTENSION injection_points; + CREATE TABLE backwards_scan_tbl(col int4) WITH (autovacuum_enabled = off); + CREATE INDEX ON backwards_scan_tbl(col) WITH (deduplicate_items = off); + INSERT INTO backwards_scan_tbl SELECT i FROM generate_series(0, 700) i; +} +setup +{ + VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) backwards_scan_tbl; +} +teardown +{ + DROP EXTENSION injection_points; + DROP TABLE backwards_scan_tbl; +} + +session scan_session +setup { + SELECT injection_points_set_local(); + SET enable_seqscan=off; + SET enable_sort=off; +} +step b_attach { + SELECT injection_points_attach('nbtree-walk-left', 'wait'); + SELECT injection_points_attach('nbtree-walk-left-step-right', 'notice'); + SELECT injection_points_attach('nbtree-walk-left-restart', 'notice'); + SELECT injection_points_attach('nbtree-walk-left-deleted', 'notice'); +} +# Variant that doesn't attach to nbtree-walk-left-step-right, for +# permutations whose number of step right attempts varies with the amount of +# free space that index tuples' varying alignment padding leaves on each page +step b_attach_nosr { + SELECT injection_points_attach('nbtree-walk-left', 'wait'); + SELECT injection_points_attach('nbtree-walk-left-restart', 'notice'); + SELECT injection_points_attach('nbtree-walk-left-deleted', 'notice'); +} +# Note: Both scan variants call parallel restricted pg_backend_pid() so that +# the scan runs in the leader process under debug_parallel_query +step b_scan { SELECT col FROM backwards_scan_tbl + WHERE col % 100 = 1 AND pg_backend_pid() <> 0 + ORDER BY col DESC; } +step b_scan_999 { SELECT col FROM backwards_scan_tbl + WHERE col <= 999 AND col % 100 = 1 AND pg_backend_pid() <> 0 + ORDER BY col DESC; } +step b_detach { + SELECT injection_points_detach('nbtree-walk-left-step-right'); + SELECT injection_points_detach('nbtree-walk-left-restart'); + SELECT injection_points_detach('nbtree-walk-left-deleted'); +} +step b_detach_nosr { + SELECT injection_points_detach('nbtree-walk-left-restart'); + SELECT injection_points_detach('nbtree-walk-left-deleted'); +} + +session concurrent_session +step i_insert { INSERT INTO backwards_scan_tbl SELECT i FROM generate_series(-2000, 700) i; } +step i_insert_dups { INSERT INTO backwards_scan_tbl SELECT 100 FROM generate_series(1, 60); } +step i_grow { INSERT INTO backwards_scan_tbl SELECT i FROM generate_series(701, 2200) i; } +step d_delete_left { DELETE FROM backwards_scan_tbl WHERE col < 601; } +step d_delete_mid { DELETE FROM backwards_scan_tbl WHERE col BETWEEN 367 AND 2100; } +step vacuum_tbl { VACUUM backwards_scan_tbl; } +step i_detach { + SELECT injection_points_detach('nbtree-walk-left'); + SELECT injection_points_wakeup('nbtree-walk-left'); +} + +# A single concurrent page split. When the backwards scan session wakes up, +# its search recovers by stepping right just once. +permutation b_attach + b_scan + i_insert_dups + i_detach + b_detach + +# Many concurrent page splits. When the backwards scan session wakes up, its +# search steps right the maximum number of times before giving up and +# starting over with the right sibling page's current left link. +permutation b_attach + b_scan + i_insert + i_detach + b_detach + +# Concurrent deletion of all pages to the left of the page that the scan just +# read. When the backwards scan session wakes up, its search determines that +# the scan has no page to the left to move to, ending the scan. +permutation b_attach + d_delete_left + b_scan + vacuum_tbl + i_detach + b_detach + +# Concurrent deletion of the page that the scan just read (which the scan can +# only safely rely on when a search locates its left sibling using its saved +# right link, which the deleted page's right sibling has acquired). The scan +# just read a page whose tuples all pointed to dead-to-all heap tuples, which +# VACUUM deletes during the scan's wait, along with all nearby pages. +permutation b_attach_nosr + i_grow + d_delete_mid + b_scan_999 + vacuum_tbl + i_detach + b_detach_nosr From e01accdb50ce8f879182edb1b50f3bc9bd78bfa7 Mon Sep 17 00:00:00 2001 From: Peter Geoghegan Date: Sun, 26 Jul 2026 12:49:56 -0400 Subject: [PATCH 15/43] Add _bt_set_startikey row compare test coverage. Add pg_regress tests that exercise the row compare logic that commit 7d9cd2df added to _bt_set_startikey. Also add tests that exercise the _bt_set_startikey SAOP array path. Author: Peter Geoghegan Discussion: https://postgr.es/m/CAH2-Wz=KjQsD2W2a=b51uH905=0mF6Le4evhWkN2FL1+uRPhUg@mail.gmail.com Backpatch-through: 19 --- src/test/regress/expected/btree_index.out | 157 ++++++++++++++++++++++ src/test/regress/sql/btree_index.sql | 85 ++++++++++++ 2 files changed, 242 insertions(+) diff --git a/src/test/regress/expected/btree_index.out b/src/test/regress/expected/btree_index.out index 21dc9b5783a7c..3a83e9a05347d 100644 --- a/src/test/regress/expected/btree_index.out +++ b/src/test/regress/expected/btree_index.out @@ -308,6 +308,163 @@ ORDER BY proname, proargtypes, pronamespace; ---------+-------------+-------------- (0 rows) +-- +-- Test RowCompare handling within _bt_set_startikey, which decides whether +-- every tuple on a page (a page beyond the scan's first) must satisfy the +-- scan's RowCompare qual. +-- +-- The index mixes an ASC column with a DESC column (so RowCompare members +-- don't all use the same inequality strategy and are not marked required), +-- uses a low fillfactor (so scans read several pages), and disables +-- deduplication (so the "b" NULLs span more than one page). +create temp table btree_rowcompare_tab (a int, b int, c int); +insert into btree_rowcompare_tab + select a, b, b from generate_series(1, 3) a, generate_series(1, 150) b; +insert into btree_rowcompare_tab + select 2, null, null from generate_series(1, 50); +create index btree_rowcompare_idx on btree_rowcompare_tab (a, b desc, c) + with (fillfactor = 10, deduplicate_items = off); +vacuum analyze btree_rowcompare_tab; +set enable_seqscan to false; +set enable_bitmapscan to false; +-- RowCompare satisfied by every tuple on many pages (decided by its first +-- member on "a = 3" pages, and by its final member on "a = 2" pages) +explain (costs off) +select count(*) from btree_rowcompare_tab where (a, b) >= (2, 75); + QUERY PLAN +-------------------------------------------------------------------------- + Aggregate + -> Index Only Scan using btree_rowcompare_idx on btree_rowcompare_tab + Index Cond: (ROW(a, b) >= ROW(2, 75)) +(3 rows) + +select count(*) from btree_rowcompare_tab where (a, b) >= (2, 75); + count +------- + 226 +(1 row) + +-- Reaches the RowCompare's unsatisfiable NULL member argument on "a = 2" +-- pages (the "a = 2" key positions the scan within the "a = 2" group, which +-- the RowCompare qual alone would not). The combined quals are +-- contradictory, but preprocessing cannot detect that. +explain (costs off) +select count(*) from btree_rowcompare_tab where a = 2 and (a, b) >= (2, null); + QUERY PLAN +-------------------------------------------------------------------------- + Aggregate + -> Index Only Scan using btree_rowcompare_idx on btree_rowcompare_tab + Index Cond: ((ROW(a, b) >= ROW(2, NULL::integer)) AND (a = 2)) +(3 rows) + +select count(*) from btree_rowcompare_tab where a = 2 and (a, b) >= (2, null); + count +------- + 0 +(1 row) + +-- RowCompare's row omits the index's second column, so on pages whose "b" +-- values change _bt_set_startikey can't prove that every tuple satisfies the +-- RowCompare. +explain (costs off) +select count(*) from btree_rowcompare_tab where (a, c) >= (2, 100); + QUERY PLAN +-------------------------------------------------------------------------- + Aggregate + -> Index Only Scan using btree_rowcompare_idx on btree_rowcompare_tab + Index Cond: (ROW(a, c) >= ROW(2, 100)) +(3 rows) + +select count(*) from btree_rowcompare_tab where (a, c) >= (2, 100); + count +------- + 201 +(1 row) + +-- Variant that uses the remaining inequality strategies +explain (costs off) +select count(*) from btree_rowcompare_tab where (a, b) < (2, 10); + QUERY PLAN +-------------------------------------------------------------------------- + Aggregate + -> Index Only Scan using btree_rowcompare_idx on btree_rowcompare_tab + Index Cond: (ROW(a, b) < ROW(2, 10)) +(3 rows) + +select count(*) from btree_rowcompare_tab where (a, b) < (2, 10); + count +------- + 159 +(1 row) + +drop table btree_rowcompare_tab; +-- +-- Test SAOP array handling within _bt_set_startikey +-- +create temp table btree_saop_tab (a int, b int, c int); +insert into btree_saop_tab + select a, b, b from generate_series(1, 3) a, generate_series(1, 150) b; +insert into btree_saop_tab + select 2, 0, 7 from generate_series(1, 60); +create index btree_saop_idx on btree_saop_tab (a, b desc, c) + with (fillfactor = 10, deduplicate_items = off); +vacuum analyze btree_saop_tab; +-- SAOP on the leading column: pages beyond each primitive scan's first page +-- have a single "a" value that a binary search finds in the array, so the +-- scan starts past the SAOP key (forcing the nonrequired key protocol) +explain (costs off) +select count(*) from btree_saop_tab where a in (1, 3) and b >= 100; + QUERY PLAN +--------------------------------------------------------------------- + Aggregate + -> Index Only Scan using btree_saop_idx on btree_saop_tab + Index Cond: ((a = ANY ('{1,3}'::integer[])) AND (b >= 100)) +(3 rows) + +select count(*) from btree_saop_tab where a in (1, 3) and b >= 100; + count +------- + 102 +(1 row) + +-- Skip array on "b" precedes the "c" SAOP; pages whose "b" values change +-- prevent starting past the "c" SAOP key +explain (costs off) +select count(*) from btree_saop_tab where a = 2 and c in (101, 105); + QUERY PLAN +---------------------------------------------------------------------- + Aggregate + -> Index Only Scan using btree_saop_idx on btree_saop_tab + Index Cond: ((a = 2) AND (c = ANY ('{101,105}'::integer[]))) +(3 rows) + +select count(*) from btree_saop_tab where a = 2 and c in (101, 105); + count +------- + 2 +(1 row) + +-- "c" SAOP follows the "b" inequality; on pages that lie wholly within the +-- duplicate "(2, 0, 7)" run, the scan starts past all of its scan keys, +-- including the SAOP key +explain (costs off) +select count(*) from btree_saop_tab where a = 2 and b < 1 and c in (6, 7); + QUERY PLAN +------------------------------------------------------------------------------ + Aggregate + -> Index Only Scan using btree_saop_idx on btree_saop_tab + Index Cond: ((a = 2) AND (b < 1) AND (c = ANY ('{6,7}'::integer[]))) +(3 rows) + +select count(*) from btree_saop_tab where a = 2 and b < 1 and c in (6, 7); + count +------- + 60 +(1 row) + +reset enable_seqscan; +reset enable_bitmapscan; +drop table btree_saop_tab; -- -- Performs a recheck of > key following array advancement on previous (left -- sibling) page that used a high key whose attribute value corresponding to diff --git a/src/test/regress/sql/btree_index.sql b/src/test/regress/sql/btree_index.sql index 6aaaa386abcec..a08bb101c2020 100644 --- a/src/test/regress/sql/btree_index.sql +++ b/src/test/regress/sql/btree_index.sql @@ -216,6 +216,91 @@ SELECT proname, proargtypes, pronamespace AND pronamespace IN (1, 2, 3) AND proargtypes IN ('26 23', '5077') ORDER BY proname, proargtypes, pronamespace; +-- +-- Test RowCompare handling within _bt_set_startikey, which decides whether +-- every tuple on a page (a page beyond the scan's first) must satisfy the +-- scan's RowCompare qual. +-- +-- The index mixes an ASC column with a DESC column (so RowCompare members +-- don't all use the same inequality strategy and are not marked required), +-- uses a low fillfactor (so scans read several pages), and disables +-- deduplication (so the "b" NULLs span more than one page). +create temp table btree_rowcompare_tab (a int, b int, c int); +insert into btree_rowcompare_tab + select a, b, b from generate_series(1, 3) a, generate_series(1, 150) b; +insert into btree_rowcompare_tab + select 2, null, null from generate_series(1, 50); +create index btree_rowcompare_idx on btree_rowcompare_tab (a, b desc, c) + with (fillfactor = 10, deduplicate_items = off); +vacuum analyze btree_rowcompare_tab; + +set enable_seqscan to false; +set enable_bitmapscan to false; + +-- RowCompare satisfied by every tuple on many pages (decided by its first +-- member on "a = 3" pages, and by its final member on "a = 2" pages) +explain (costs off) +select count(*) from btree_rowcompare_tab where (a, b) >= (2, 75); +select count(*) from btree_rowcompare_tab where (a, b) >= (2, 75); + +-- Reaches the RowCompare's unsatisfiable NULL member argument on "a = 2" +-- pages (the "a = 2" key positions the scan within the "a = 2" group, which +-- the RowCompare qual alone would not). The combined quals are +-- contradictory, but preprocessing cannot detect that. +explain (costs off) +select count(*) from btree_rowcompare_tab where a = 2 and (a, b) >= (2, null); +select count(*) from btree_rowcompare_tab where a = 2 and (a, b) >= (2, null); + +-- RowCompare's row omits the index's second column, so on pages whose "b" +-- values change _bt_set_startikey can't prove that every tuple satisfies the +-- RowCompare. +explain (costs off) +select count(*) from btree_rowcompare_tab where (a, c) >= (2, 100); +select count(*) from btree_rowcompare_tab where (a, c) >= (2, 100); + +-- Variant that uses the remaining inequality strategies +explain (costs off) +select count(*) from btree_rowcompare_tab where (a, b) < (2, 10); +select count(*) from btree_rowcompare_tab where (a, b) < (2, 10); + +drop table btree_rowcompare_tab; + +-- +-- Test SAOP array handling within _bt_set_startikey +-- +create temp table btree_saop_tab (a int, b int, c int); +insert into btree_saop_tab + select a, b, b from generate_series(1, 3) a, generate_series(1, 150) b; +insert into btree_saop_tab + select 2, 0, 7 from generate_series(1, 60); +create index btree_saop_idx on btree_saop_tab (a, b desc, c) + with (fillfactor = 10, deduplicate_items = off); +vacuum analyze btree_saop_tab; + +-- SAOP on the leading column: pages beyond each primitive scan's first page +-- have a single "a" value that a binary search finds in the array, so the +-- scan starts past the SAOP key (forcing the nonrequired key protocol) +explain (costs off) +select count(*) from btree_saop_tab where a in (1, 3) and b >= 100; +select count(*) from btree_saop_tab where a in (1, 3) and b >= 100; + +-- Skip array on "b" precedes the "c" SAOP; pages whose "b" values change +-- prevent starting past the "c" SAOP key +explain (costs off) +select count(*) from btree_saop_tab where a = 2 and c in (101, 105); +select count(*) from btree_saop_tab where a = 2 and c in (101, 105); + +-- "c" SAOP follows the "b" inequality; on pages that lie wholly within the +-- duplicate "(2, 0, 7)" run, the scan starts past all of its scan keys, +-- including the SAOP key +explain (costs off) +select count(*) from btree_saop_tab where a = 2 and b < 1 and c in (6, 7); +select count(*) from btree_saop_tab where a = 2 and b < 1 and c in (6, 7); + +reset enable_seqscan; +reset enable_bitmapscan; +drop table btree_saop_tab; + -- -- Performs a recheck of > key following array advancement on previous (left -- sibling) page that used a high key whose attribute value corresponding to From 0962f9e344390c69e44bc55675510b2fa2b3f778 Mon Sep 17 00:00:00 2001 From: Michael Paquier Date: Mon, 27 Jul 2026 09:42:50 +0900 Subject: [PATCH 16/43] Use direct hash lookup in logicalrep_partmap_invalidate_cb() This replaces an O(N) hash_seq_search() loop by an O(1) lookup, removing a TODO item, making the invalidation callback faster when dealing with many relations. This can work because LogicalRepPartMap is keyed by a partition OID, and a relmapentry's localreloid matches with it. An assertion is added in logicalrep_partition_open() to enforce the fact that localreloid matches with the hash key. Author: DaeMyung Kang Discussion: https://postgr.es/m/20260417174450.4158878-1-charsyam@gmail.com --- src/backend/replication/logical/relation.c | 23 +++++++++------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c index 296cbaede3018..8749826425648 100644 --- a/src/backend/replication/logical/relation.c +++ b/src/backend/replication/logical/relation.c @@ -543,20 +543,14 @@ logicalrep_partmap_invalidate_cb(Datum arg, Oid reloid) if (reloid != InvalidOid) { - HASH_SEQ_STATUS status; - - hash_seq_init(&status, LogicalRepPartMap); - - /* TODO, use inverse lookup hashtable? */ - while ((entry = (LogicalRepPartMapEntry *) hash_seq_search(&status)) != NULL) - { - if (entry->relmapentry.localreloid == reloid) - { - entry->relmapentry.localrelvalid = false; - hash_seq_term(&status); - break; - } - } + /* + * LogicalRepPartMap is keyed by partition OID, matching with + * entry->relmapentry.localreloid (see logicalrep_partition_open), so + * we can invalidate via a direct hash lookup. + */ + entry = hash_search(LogicalRepPartMap, &reloid, HASH_FIND, NULL); + if (entry != NULL) + entry->relmapentry.localrelvalid = false; } else { @@ -675,6 +669,7 @@ logicalrep_partition_open(LogicalRepRelMapEntry *root, */ if (found && entry->localrelvalid) { + Assert(entry->localreloid == partOid); entry->localrel = partrel; return entry; } From 87f08dbf3499929f4941f224f52ccd6a20081f00 Mon Sep 17 00:00:00 2001 From: Michael Paquier Date: Mon, 27 Jul 2026 09:58:04 +0900 Subject: [PATCH 17/43] Update .gitignore in test/modules/nbtree Noticed while doing some routine work. Oversight in e395fbd32a07. --- src/test/modules/nbtree/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/modules/nbtree/.gitignore b/src/test/modules/nbtree/.gitignore index 5dcb3ff972350..0de307e70a6c6 100644 --- a/src/test/modules/nbtree/.gitignore +++ b/src/test/modules/nbtree/.gitignore @@ -1,4 +1,6 @@ # Generated subdirectories /log/ +/output_iso/ /results/ /tmp_check/ +/tmp_check_iso/ From f4c850d11afc60a4fc4bc782fc40d7b752bf8d7f Mon Sep 17 00:00:00 2001 From: Richard Guo Date: Mon, 27 Jul 2026 10:21:18 +0900 Subject: [PATCH 18/43] Fix deparsing of JSON_ARRAY(subquery) with a FORMAT clause Commit 8d829f5a0 introduced the JSCTOR_JSON_ARRAY_QUERY constructor type so that ruleutils.c could deparse JSON_ARRAY(subquery) using its original syntax, storing the transformed subquery in a new orig_query field. However, the input FORMAT clause of JSON_ARRAY(subquery FORMAT ...) was not preserved for deparsing. The format was recorded only in the executable expression kept in the func field, which ruleutils.c does not inspect, so it is silently dropped. This is more than cosmetic, because FORMAT JSON changes the result: without it a text value is treated as a string to be quoted, while with it the value is treated as already-formatted JSON. To fix, record the input FORMAT in a new deparse-only field of JsonConstructorExpr, alongside orig_query, and emit it in ruleutils.c. Bump catalog version. Author: Chao Li Reviewed-by: Ewan Young Reviewed-by: Richard Guo Discussion: https://postgr.es/m/4C89B193-7D54-4705-9CF9-F0D484B9E099@gmail.com Backpatch-through: 19 --- src/backend/parser/parse_expr.c | 3 +++ src/backend/utils/adt/ruleutils.c | 1 + src/include/catalog/catversion.h | 2 +- src/include/nodes/primnodes.h | 5 +++++ src/test/regress/expected/sqljson.out | 7 +++++++ src/test/regress/sql/sqljson.sql | 8 ++++++++ 6 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c index e6ea34a780937..30c889f505f64 100644 --- a/src/backend/parser/parse_expr.c +++ b/src/backend/parser/parse_expr.c @@ -3808,6 +3808,8 @@ transformJsonObjectConstructor(ParseState *pstate, JsonObjectConstructor *ctor) * - orig_query: the transformed Query of the user's original subquery, so * that ruleutils.c can deparse the original JSON_ARRAY(SELECT ...) syntax * for view definitions. + * + * - format: the input FORMAT clause, so that ruleutils.c can deparse it. */ static Node * transformJsonArrayQueryConstructor(ParseState *pstate, @@ -3944,6 +3946,7 @@ transformJsonArrayQueryConstructor(ParseState *pstate, false, ctor->absent_on_null, ctor->location); ((JsonConstructorExpr *) result)->orig_query = (Node *) query; + ((JsonConstructorExpr *) result)->format = ctor->format; return result; } diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c index 1b44b7a78d24c..043e43b630964 100644 --- a/src/backend/utils/adt/ruleutils.c +++ b/src/backend/utils/adt/ruleutils.c @@ -12291,6 +12291,7 @@ get_json_constructor(JsonConstructorExpr *ctor, deparse_context *context, context->prettyFlags, context->wrapColumn, context->indentLevel); + get_json_format(ctor->format, buf); get_json_constructor_options(ctor, buf); appendStringInfoChar(buf, ')'); diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h index d0399cc1cbeec..83d462f4d4aae 100644 --- a/src/include/catalog/catversion.h +++ b/src/include/catalog/catversion.h @@ -57,6 +57,6 @@ */ /* yyyymmddN */ -#define CATALOG_VERSION_NO 202607201 +#define CATALOG_VERSION_NO 202607271 #endif diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h index cacef7d41517c..1f71266651116 100644 --- a/src/include/nodes/primnodes.h +++ b/src/include/nodes/primnodes.h @@ -1718,6 +1718,10 @@ typedef enum JsonConstructorType * orig_query holds the user's original subquery for JSON_ARRAY(query), used * only by ruleutils.c for deparsing; it is not walked because func is * authoritative for all other purposes. + * + * format likewise holds the input FORMAT clause of JSON_ARRAY(query), which + * is otherwise only represented inside func; it is used only by ruleutils.c + * for deparsing. */ typedef struct JsonConstructorExpr { @@ -1728,6 +1732,7 @@ typedef struct JsonConstructorExpr Expr *coercion; /* coercion to RETURNING type */ JsonReturning *returning; /* RETURNING clause */ Node *orig_query; /* original subquery for deparsing */ + JsonFormat *format; /* input FORMAT for JSON_ARRAY(query) */ bool absent_on_null; /* ABSENT ON NULL? */ bool unique; /* WITH UNIQUE KEYS? (JSON_OBJECT[AGG] only) */ ParseLoc location; diff --git a/src/test/regress/expected/sqljson.out b/src/test/regress/expected/sqljson.out index 091a0b98574ae..d72278d67caa0 100644 --- a/src/test/regress/expected/sqljson.out +++ b/src/test/regress/expected/sqljson.out @@ -1233,6 +1233,13 @@ CREATE OR REPLACE VIEW public.json_array_subquery_view AS SELECT JSON_ARRAY( SELECT foo.i FROM ( VALUES (1), (2), (NULL::integer), (4)) foo(i) RETURNING text) AS "json_array" DROP VIEW json_array_subquery_view; +-- JSON_ARRAY(subquery) with an input FORMAT clause +CREATE VIEW json_array_subquery_view AS +SELECT JSON_ARRAY(SELECT '{"a": 1}'::text FORMAT JSON); +\sv json_array_subquery_view +CREATE OR REPLACE VIEW public.json_array_subquery_view AS + SELECT JSON_ARRAY( SELECT '{"a": 1}'::text AS text FORMAT JSON RETURNING json) AS "json_array" +DROP VIEW json_array_subquery_view; -- Test mutability of JSON_OBJECTAGG, JSON_ARRAYAGG, JSON_ARRAY, JSON_OBJECT create type comp1 as (a int, b date); create domain d_comp1 as comp1; diff --git a/src/test/regress/sql/sqljson.sql b/src/test/regress/sql/sqljson.sql index 2550da15c4523..96217a5593552 100644 --- a/src/test/regress/sql/sqljson.sql +++ b/src/test/regress/sql/sqljson.sql @@ -443,6 +443,14 @@ SELECT JSON_ARRAY(SELECT i FROM (VALUES (1), (2), (NULL), (4)) foo(i) RETURNING DROP VIEW json_array_subquery_view; +-- JSON_ARRAY(subquery) with an input FORMAT clause +CREATE VIEW json_array_subquery_view AS +SELECT JSON_ARRAY(SELECT '{"a": 1}'::text FORMAT JSON); + +\sv json_array_subquery_view + +DROP VIEW json_array_subquery_view; + -- Test mutability of JSON_OBJECTAGG, JSON_ARRAYAGG, JSON_ARRAY, JSON_OBJECT create type comp1 as (a int, b date); create domain d_comp1 as comp1; From b8d9cf512c1259f97f9896593cc1c8352c1118ac Mon Sep 17 00:00:00 2001 From: Amit Kapila Date: Mon, 27 Jul 2026 09:06:05 +0530 Subject: [PATCH 19/43] Fix issues in logical replication sequence synchronization. 1. Stop a running sequence synchronization worker when ALTER SUBSCRIPTION ... DISABLE is executed. The worker did not reread its subscription after starting a transaction, so it kept running with a stale copy and missed the disable. It now calls maybe_reread_subscription() after StartTransactionCommand(), matching the apply worker. 2. Restore the invariant that publisher-side synchronization slots are dropped last during ALTER SUBSCRIPTION ... REFRESH PUBLICATION. The slot-drop loop now runs after the sequence-removal loop, so the non-transactional slot drops happen only after all catalog changes that could still be rolled back on error. 3. Restore psql tab completion for ALTER SUBSCRIPTION ... REFRESH PUBLICATION WITH (. 4. Make pg_stat_subscription report NULL for the fields that do not apply to a sequence synchronization worker, which does not stream from a walsender, and update the documentation accordingly. 5. Update the pg_subscription_rel.srsublsn catalog documentation to describe its semantics for sequence rows. Reported-by: Noah Misch Author: vignesh C Reviewed-by: Hayato Kuroda Reviewed-by: Amit Kapila Backpatch-through: 19, where it was introduced Discussion: https://postgr.es/m/20260710045217.f0.noahmisch@microsoft.com --- doc/src/sgml/catalogs.sgml | 6 +- doc/src/sgml/monitoring.sgml | 19 ++++--- src/backend/commands/subscriptioncmds.c | 56 +++++++++---------- .../replication/logical/sequencesync.c | 2 + src/backend/replication/logical/worker.c | 14 ++++- src/bin/psql/tab-complete.in.c | 3 + 6 files changed, 61 insertions(+), 39 deletions(-) diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml index 4b474c139174d..6066c4784f4be 100644 --- a/doc/src/sgml/catalogs.sgml +++ b/doc/src/sgml/catalogs.sgml @@ -8893,7 +8893,11 @@ SCRAM-SHA-256$<iteration count>:&l Remote LSN of the state change used for synchronization coordination when in s or r states, - otherwise null + otherwise null. For sequences, this instead holds the publisher + sequence's page LSN as of the last synchronization, which does not + track replication progress the way it does for tables; see + for how it is used to detect + out-of-sync sequences. diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml index 1ce0ef007998d..a209e891b181a 100644 --- a/doc/src/sgml/monitoring.sgml +++ b/doc/src/sgml/monitoring.sgml @@ -2473,8 +2473,8 @@ description | Waiting for a newly initialized WAL file to reach durable storage Process ID of the leader apply worker if this process is a parallel - apply worker; NULL if this process is a leader apply worker or a table - synchronization worker + apply worker; NULL if this process is a leader apply worker, a table + synchronization worker or a sequence synchronization worker @@ -2484,7 +2484,8 @@ description | Waiting for a newly initialized WAL file to reach durable storage OID of the relation that the worker is synchronizing; NULL for the - leader apply worker and parallel apply workers + leader apply worker, parallel apply workers and the sequence + synchronization worker @@ -2494,7 +2495,8 @@ description | Waiting for a newly initialized WAL file to reach durable storage Last write-ahead log location received, the initial value of - this field being 0; NULL for parallel apply workers + this field being 0; NULL for parallel apply workers and the sequence + synchronization worker @@ -2504,7 +2506,7 @@ description | Waiting for a newly initialized WAL file to reach durable storage Send time of last message received from origin WAL sender; NULL for - parallel apply workers + parallel apply workers and the sequence synchronization worker @@ -2514,7 +2516,7 @@ description | Waiting for a newly initialized WAL file to reach durable storage Receipt time of last message received from origin WAL sender; NULL for - parallel apply workers + parallel apply workers and the sequence synchronization worker @@ -2524,7 +2526,7 @@ description | Waiting for a newly initialized WAL file to reach durable storage Last write-ahead log location reported to origin WAL sender; NULL for - parallel apply workers + parallel apply workers and the sequence synchronization worker @@ -2534,7 +2536,8 @@ description | Waiting for a newly initialized WAL file to reach durable storage Time of last write-ahead log location reported to origin WAL - sender; NULL for parallel apply workers + sender; NULL for parallel apply workers and the sequence synchronization + worker diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c index d4504b4a0c6fb..013ac46db0725 100644 --- a/src/backend/commands/subscriptioncmds.c +++ b/src/backend/commands/subscriptioncmds.c @@ -1288,34 +1288,6 @@ AlterSubscription_refresh(Subscription *sub, bool copy_data, } } - /* - * Drop the tablesync slots associated with removed tables. This has - * to be at the end because otherwise if there is an error while doing - * the database operations we won't be able to rollback dropped slots. - */ - foreach_ptr(SubRemoveRels, sub_remove_rel, sub_remove_rels) - { - if (sub_remove_rel->state != SUBREL_STATE_READY && - sub_remove_rel->state != SUBREL_STATE_SYNCDONE) - { - char syncslotname[NAMEDATALEN] = {0}; - - /* - * For READY/SYNCDONE states we know the tablesync slot has - * already been dropped by the tablesync worker. - * - * For other states, there is no certainty, maybe the slot - * does not exist yet. Also, if we fail after removing some of - * the slots, next time, it will again try to drop already - * dropped slots and fail. For these reasons, we allow - * missing_ok = true for the drop. - */ - ReplicationSlotNameForTablesync(sub->oid, sub_remove_rel->relid, - syncslotname, sizeof(syncslotname)); - ReplicationSlotDropAtPubNode(wrconn, syncslotname, true); - } - } - /* * Next remove state for sequences we should not care about anymore * using the data we collected above @@ -1343,6 +1315,34 @@ AlterSubscription_refresh(Subscription *sub, bool copy_data, sub->name)); } } + + /* + * Drop the tablesync slots associated with removed tables. This has + * to be at the end because otherwise if there is an error while doing + * the database operations we won't be able to rollback dropped slots. + */ + foreach_ptr(SubRemoveRels, sub_remove_rel, sub_remove_rels) + { + if (sub_remove_rel->state != SUBREL_STATE_READY && + sub_remove_rel->state != SUBREL_STATE_SYNCDONE) + { + char syncslotname[NAMEDATALEN] = {0}; + + /* + * For READY/SYNCDONE states we know the tablesync slot has + * already been dropped by the tablesync worker. + * + * For other states, there is no certainty, maybe the slot + * does not exist yet. Also, if we fail after removing some of + * the slots, next time, it will again try to drop already + * dropped slots and fail. For these reasons, we allow + * missing_ok = true for the drop. + */ + ReplicationSlotNameForTablesync(sub->oid, sub_remove_rel->relid, + syncslotname, sizeof(syncslotname)); + ReplicationSlotDropAtPubNode(wrconn, syncslotname, true); + } + } } PG_FINALLY(); { diff --git a/src/backend/replication/logical/sequencesync.c b/src/backend/replication/logical/sequencesync.c index d0370056de311..fe506a98c2052 100644 --- a/src/backend/replication/logical/sequencesync.c +++ b/src/backend/replication/logical/sequencesync.c @@ -495,6 +495,7 @@ copy_sequences(WalReceiverConn *conn) TupleTableSlot *slot; StartTransactionCommand(); + maybe_reread_subscription(); for (int idx = cur_batch_base_index; idx < n_seqinfos; idx++) { @@ -724,6 +725,7 @@ LogicalRepSyncSequences(void) StringInfoData app_name; StartTransactionCommand(); + maybe_reread_subscription(); rel = table_open(SubscriptionRelRelationId, AccessShareLock); diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c index 0ff5cef63cdda..0bd19074010d7 100644 --- a/src/backend/replication/logical/worker.c +++ b/src/backend/replication/logical/worker.c @@ -5985,8 +5985,18 @@ SetupApplyOrSyncWorker(int worker_slot) */ /* Initialise stats to a sanish value */ - MyLogicalRepWorker->last_send_time = MyLogicalRepWorker->last_recv_time = - MyLogicalRepWorker->reply_time = GetCurrentTimestamp(); + if (am_sequencesync_worker()) + { + MyLogicalRepWorker->last_send_time = + MyLogicalRepWorker->last_recv_time = + MyLogicalRepWorker->reply_time = 0; + } + else + { + MyLogicalRepWorker->last_send_time = + MyLogicalRepWorker->last_recv_time = + MyLogicalRepWorker->reply_time = GetCurrentTimestamp(); + } /* Load the libpq-specific functions */ load_file("libpqwalreceiver", false); diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c index 1cacc8c3ea2cc..17dcabe755fb7 100644 --- a/src/bin/psql/tab-complete.in.c +++ b/src/bin/psql/tab-complete.in.c @@ -2354,6 +2354,9 @@ match_previous_words(int pattern_id, /* ALTER SUBSCRIPTION REFRESH */ else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "REFRESH")) COMPLETE_WITH("PUBLICATION", "SEQUENCES"); + /* ALTER SUBSCRIPTION REFRESH PUBLICATION */ + else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "REFRESH", "PUBLICATION")) + COMPLETE_WITH("WITH ("); /* ALTER SUBSCRIPTION REFRESH PUBLICATION WITH ( */ else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "REFRESH", "PUBLICATION", "WITH", "(")) COMPLETE_WITH("copy_data"); From 2cad308cb8f1c88f97f617ef9281f08c2f722277 Mon Sep 17 00:00:00 2001 From: Heikki Linnakangas Date: Mon, 27 Jul 2026 15:29:51 +0300 Subject: [PATCH 20/43] pg_resetwal: do not allow zero next multixact offset Offset 0 is the "invalid" marker in pg_multixact/offsets since offsets went 64-bit and the allocator stopped skipping it. pg_resetwal could still produce it via -O 0 or guessed control values, breaking the first multixact created after the reset ("MultiXact n has invalid offset", and vacuum of the affected table fails from then on). Reject -O 0 like -m and -o already do, and guess 1 like initdb does. Author: Zsolt Parragi Discussion: https://www.postgresql.org/message-id/CAN4CZFNoO6MUkg526TmA=mC_RjY2gp4VKCnvK6y12v3ppOkhJA@mail.gmail.com Backpatch-through: 19 --- src/bin/pg_resetwal/pg_resetwal.c | 6 +++++- src/bin/pg_resetwal/t/001_basic.pl | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/bin/pg_resetwal/pg_resetwal.c b/src/bin/pg_resetwal/pg_resetwal.c index d072e7c2ea471..1542a56ca4b1f 100644 --- a/src/bin/pg_resetwal/pg_resetwal.c +++ b/src/bin/pg_resetwal/pg_resetwal.c @@ -303,6 +303,10 @@ main(int argc, char *argv[]) pg_log_error_hint("Try \"%s --help\" for more information.", progname); exit(1); } + + /* offset 0 means "invalid" in pg_multixact/offsets */ + if (next_mxoff_val == 0) + pg_fatal("next multitransaction offset (-O) must not be 0"); next_mxoff_given = true; break; @@ -700,7 +704,7 @@ GuessControlValues(void) FullTransactionIdFromEpochAndXid(0, FirstNormalTransactionId); ControlFile.checkPointCopy.nextOid = FirstGenbkiObjectId; ControlFile.checkPointCopy.nextMulti = FirstMultiXactId; - ControlFile.checkPointCopy.nextMultiOffset = 0; + ControlFile.checkPointCopy.nextMultiOffset = 1; ControlFile.checkPointCopy.oldestXid = FirstNormalTransactionId; ControlFile.checkPointCopy.oldestXidDB = InvalidOid; ControlFile.checkPointCopy.oldestMulti = FirstMultiXactId; diff --git a/src/bin/pg_resetwal/t/001_basic.pl b/src/bin/pg_resetwal/t/001_basic.pl index d686584eb9674..cff0b6423f390 100644 --- a/src/bin/pg_resetwal/t/001_basic.pl +++ b/src/bin/pg_resetwal/t/001_basic.pl @@ -145,6 +145,10 @@ [ 'pg_resetwal', '-O' => '-1', $node->data_dir ], qr/error: invalid argument for option -O/, 'fails with -O value -1'); +command_fails_like( + [ 'pg_resetwal', '-O' => '0', $node->data_dir ], + qr/must not be 0/, + 'fails with -O value 0'); # --wal-segsize command_fails_like( [ 'pg_resetwal', '--wal-segsize' => 'foo', $node->data_dir ], From 7090c696cc9ea96a278e679e8bbfe9b051740105 Mon Sep 17 00:00:00 2001 From: Tom Lane Date: Mon, 27 Jul 2026 09:37:04 -0400 Subject: [PATCH 21/43] Deparse FOR PORTION OF using the range column's current name. Commit 8e72d914c recorded the range column's name in ForPortionOfExpr and used that for deparsing FOR PORTION OF. This gives the wrong answer if the ForPortionOfExpr is saved in a rule or SQL function and then the column gets renamed. Drop the ForPortionOfExpr.range_name field; instead fetch the current column name from the catalogs when needed. Also drop ForPortionOfState.fp_rangeName, which wasn't being used anywhere. Full disclosure: an earlier draft of this patch was made with Claude Opus 4.8. Reported-by: John Naylor Author: Tom Lane Reviewed-by: Richard Guo Reviewed-by: Chao Li Discussion: https://postgr.es/m/CANWCAZYFEpJ5Oi45gi4q9Y6LYa4_oiAXxuNNWe-1ym-i0fF8Pw@mail.gmail.com Backpatch-through: 19 --- src/backend/executor/nodeModifyTable.c | 2 -- src/backend/optimizer/plan/planner.c | 4 ++- src/backend/parser/analyze.c | 1 - src/backend/utils/adt/ruleutils.c | 15 ++++++--- src/include/catalog/catversion.h | 2 +- src/include/nodes/execnodes.h | 1 - src/include/nodes/primnodes.h | 1 - src/test/regress/expected/for_portion_of.out | 33 ++++++++++++++++++++ src/test/regress/sql/for_portion_of.sql | 16 ++++++++++ 9 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c index b9781eb3b95ba..1dbf0ffff9ed6 100644 --- a/src/backend/executor/nodeModifyTable.c +++ b/src/backend/executor/nodeModifyTable.c @@ -5641,7 +5641,6 @@ ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags) /* Create state for FOR PORTION OF operation */ fpoState = makeNode(ForPortionOfState); - fpoState->fp_rangeName = forPortionOf->range_name; fpoState->fp_rangeType = forPortionOf->rangeType; fpoState->fp_rangeAttno = forPortionOf->rangeVar->varattno; fpoState->fp_targetRange = targetRange; @@ -5928,7 +5927,6 @@ ExecInitForPortionOf(ModifyTableState *mtstate, EState *estate, leafState = makeNode(ForPortionOfState); - leafState->fp_rangeName = fpoState->fp_rangeName; leafState->fp_rangeType = fpoState->fp_rangeType; leafState->fp_targetRange = fpoState->fp_targetRange; map = ExecGetChildToRootMap(resultRelInfo); diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c index 3225185d16f8f..a0ff9159ae08c 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -871,7 +871,9 @@ subquery_planner(PlannerGlobal *glob, Query *parse, char *plan_name, ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("cannot use generated column \"%s\" in FOR PORTION OF", - forPortionOf->range_name))); + get_attname(rte->relid, + forPortionOf->rangeVar->varattno, + false)))); } /* diff --git a/src/backend/parser/analyze.c b/src/backend/parser/analyze.c index 562e4facd74f4..581457c69c916 100644 --- a/src/backend/parser/analyze.c +++ b/src/backend/parser/analyze.c @@ -1606,7 +1606,6 @@ transformForPortionOfClause(ParseState *pstate, else result->rangeTargetList = NIL; - result->range_name = forPortionOf->range_name; result->location = forPortionOf->location; result->targetLocation = forPortionOf->target_location; diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c index 043e43b630964..908134594cb1a 100644 --- a/src/backend/utils/adt/ruleutils.c +++ b/src/backend/utils/adt/ruleutils.c @@ -527,6 +527,7 @@ static void get_rte_alias(RangeTblEntry *rte, int varno, bool use_as, static void get_column_alias_list(deparse_columns *colinfo, deparse_context *context); static void get_for_portion_of(ForPortionOfExpr *forPortionOf, + RangeTblEntry *rte, deparse_context *context); static void get_from_clause_coldeflist(RangeTblFunction *rtfunc, deparse_columns *colinfo, @@ -7556,7 +7557,7 @@ get_update_query_def(Query *query, deparse_context *context) generate_relation_name(rte->relid, NIL)); /* Print the FOR PORTION OF, if needed */ - get_for_portion_of(query->forPortionOf, context); + get_for_portion_of(query->forPortionOf, rte, context); /* Print the relation alias, if needed */ get_rte_alias(rte, query->resultRelation, false, context); @@ -7763,7 +7764,7 @@ get_delete_query_def(Query *query, deparse_context *context) generate_relation_name(rte->relid, NIL)); /* Print the FOR PORTION OF, if needed */ - get_for_portion_of(query->forPortionOf, context); + get_for_portion_of(query->forPortionOf, rte, context); /* Print the relation alias, if needed */ get_rte_alias(rte, query->resultRelation, false, context); @@ -13479,12 +13480,18 @@ get_rte_alias(RangeTblEntry *rte, int varno, bool use_as, * alias and SET will be on their own line with a leading space. */ static void -get_for_portion_of(ForPortionOfExpr *forPortionOf, deparse_context *context) +get_for_portion_of(ForPortionOfExpr *forPortionOf, RangeTblEntry *rte, + deparse_context *context) { if (forPortionOf) { + char *range_name; + + range_name = get_attname(rte->relid, + forPortionOf->rangeVar->varattno, + false); appendStringInfo(context->buf, " FOR PORTION OF %s", - quote_identifier(forPortionOf->range_name)); + quote_identifier(range_name)); /* * Try to write it as FROM ... TO ... if we received it that way, diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h index 83d462f4d4aae..f20ea73ed3996 100644 --- a/src/include/catalog/catversion.h +++ b/src/include/catalog/catversion.h @@ -57,6 +57,6 @@ */ /* yyyymmddN */ -#define CATALOG_VERSION_NO 202607271 +#define CATALOG_VERSION_NO 202607273 #endif diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h index e64fd8c7ea300..e95ac3eda3572 100644 --- a/src/include/nodes/execnodes.h +++ b/src/include/nodes/execnodes.h @@ -476,7 +476,6 @@ typedef struct ForPortionOfState { NodeTag type; - char *fp_rangeName; /* the column named in FOR PORTION OF */ Oid fp_rangeType; /* the base type (not domain) of the FOR * PORTION OF expression */ int fp_rangeAttno; /* the attno of the range column */ diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h index 1f71266651116..44f828cbb372e 100644 --- a/src/include/nodes/primnodes.h +++ b/src/include/nodes/primnodes.h @@ -2438,7 +2438,6 @@ typedef struct ForPortionOfExpr { NodeTag type; Var *rangeVar; /* Range column */ - char *range_name; /* Range name */ Node *targetFrom; /* FOR PORTION OF FROM bound, if given */ Node *targetTo; /* FOR PORTION OF TO bound, if given */ Node *targetRange; /* FOR PORTION OF bounds as a range/multirange */ diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out index 0e217f104efea..3c592f90e70ce 100644 --- a/src/test/regress/expected/for_portion_of.out +++ b/src/test/regress/expected/for_portion_of.out @@ -2255,6 +2255,39 @@ SELECT * FROM fpo_rule ORDER BY f1; (2 rows) DROP TABLE fpo_rule; +-- Deparsing FOR PORTION OF must use the range column's current name, +-- not the name it had when the rule was created. +CREATE TABLE fpo_rename (f1 bigint, f2 int4range); +CREATE TABLE fpo_rename_src (x int); +CREATE RULE fpo_rename_rule1 AS ON UPDATE TO fpo_rename_src + DO INSTEAD UPDATE fpo_rename FOR PORTION OF f2 FROM 3 TO 6 SET f1 = 2; +CREATE RULE fpo_rename_rule2 AS ON DELETE TO fpo_rename_src + DO INSTEAD DELETE FROM fpo_rename FOR PORTION OF f2 (int4range(3, 6)); +\d+ fpo_rename_src + Table "public.fpo_rename_src" + Column | Type | Collation | Nullable | Default | Storage | Stats target | Description +--------+---------+-----------+----------+---------+---------+--------------+------------- + x | integer | | | | plain | | +Rules: + fpo_rename_rule1 AS + ON UPDATE TO fpo_rename_src DO INSTEAD UPDATE fpo_rename FOR PORTION OF f2 FROM 3 TO 6 SET f1 = 2 + fpo_rename_rule2 AS + ON DELETE TO fpo_rename_src DO INSTEAD DELETE FROM fpo_rename FOR PORTION OF f2 (int4range(3, 6)) + +ALTER TABLE fpo_rename RENAME COLUMN f1 TO ff1; +ALTER TABLE fpo_rename RENAME COLUMN f2 TO ff2; +\d+ fpo_rename_src + Table "public.fpo_rename_src" + Column | Type | Collation | Nullable | Default | Storage | Stats target | Description +--------+---------+-----------+----------+---------+---------+--------------+------------- + x | integer | | | | plain | | +Rules: + fpo_rename_rule1 AS + ON UPDATE TO fpo_rename_src DO INSTEAD UPDATE fpo_rename FOR PORTION OF ff2 FROM 3 TO 6 SET ff1 = 2 + fpo_rename_rule2 AS + ON DELETE TO fpo_rename_src DO INSTEAD DELETE FROM fpo_rename FOR PORTION OF ff2 (int4range(3, 6)) + +DROP TABLE fpo_rename, fpo_rename_src; -- UPDATE/DELETE FOR PORTION OF on a GENERATED VIRTUAL range column: CREATE TABLE fpo_gen_virtual ( a int, diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql index a8d29a76b2266..5f7b04fdf6b40 100644 --- a/src/test/regress/sql/for_portion_of.sql +++ b/src/test/regress/sql/for_portion_of.sql @@ -1495,6 +1495,22 @@ SELECT * FROM fpo_rule ORDER BY f1; DROP TABLE fpo_rule; +-- Deparsing FOR PORTION OF must use the range column's current name, +-- not the name it had when the rule was created. +CREATE TABLE fpo_rename (f1 bigint, f2 int4range); +CREATE TABLE fpo_rename_src (x int); +CREATE RULE fpo_rename_rule1 AS ON UPDATE TO fpo_rename_src + DO INSTEAD UPDATE fpo_rename FOR PORTION OF f2 FROM 3 TO 6 SET f1 = 2; +CREATE RULE fpo_rename_rule2 AS ON DELETE TO fpo_rename_src + DO INSTEAD DELETE FROM fpo_rename FOR PORTION OF f2 (int4range(3, 6)); + +\d+ fpo_rename_src +ALTER TABLE fpo_rename RENAME COLUMN f1 TO ff1; +ALTER TABLE fpo_rename RENAME COLUMN f2 TO ff2; +\d+ fpo_rename_src + +DROP TABLE fpo_rename, fpo_rename_src; + -- UPDATE/DELETE FOR PORTION OF on a GENERATED VIRTUAL range column: CREATE TABLE fpo_gen_virtual ( a int, From 0fd30e2119ede879080cef426abf4f9b304e3f51 Mon Sep 17 00:00:00 2001 From: Masahiko Sawada Date: Mon, 27 Jul 2026 09:10:45 -0700 Subject: [PATCH 22/43] Fix race condition when enabling logical decoding concurrently. With wal_level = 'replica', logical decoding is enabled on demand when the first logical replication slot is created: When enabling logical decoding, EnableLogicalDecoding() flips the shared logical_decoding_enabled flag and writes an XLOG_LOGICAL_DECODING_STATUS_CHANGE record so that standbys follow the status change. The initial "already enabled?" check and the WAL record write happen under two separate acquisitions of LogicalDecodingControlLock, since the lock must be released while waiting for the ProcSignalBarrier: processes absorbing the barrier acquire the same lock in shared mode. Consequently, if two backends concurrently created the first logical slots, both could pass the initial check and both write a status-change record. The redundant record lands after the decoding start point already reserved by the other backend's slot, so decoding that slot processes the record and fails with "unexpected logical decoding status change", as xlog_decode() assumes that no such record can appear within the WAL range any slot decodes. Fix by re-checking the status after re-acquiring the lock, so that only the backend that actually performs the disabled->enabled transition writes the WAL record. Reported-by: Srinath Reddy Sadipiralla Author: Srinath Reddy Sadipiralla Reviewed-by: Masahiko Sawada Reviewed-by: Amit Kapila Discussion: https://postgr.es/m/CAFC+b6oYzmAgp7F0ivrhfZT46-CjvCTrU9pWuMNcem-52YjOTw@mail.gmail.com Backpatch-through: 19 --- src/backend/replication/logical/logicalctl.c | 11 ++++ .../recovery/t/051_effective_wal_level.pl | 61 ++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/backend/replication/logical/logicalctl.c b/src/backend/replication/logical/logicalctl.c index 624965ef95dce..4a690a631dae3 100644 --- a/src/backend/replication/logical/logicalctl.c +++ b/src/backend/replication/logical/logicalctl.c @@ -384,6 +384,17 @@ EnableLogicalDecoding(void) LWLockAcquire(LogicalDecodingControlLock, LW_EXCLUSIVE); + /* + * Re-check whether logical decoding got enabled while we waited for the + * barrier above. + */ + if (LogicalDecodingCtl->logical_decoding_enabled) + { + LogicalDecodingCtl->pending_disable = false; + LWLockRelease(LogicalDecodingControlLock); + return; + } + START_CRIT_SECTION(); /* diff --git a/src/test/recovery/t/051_effective_wal_level.pl b/src/test/recovery/t/051_effective_wal_level.pl index d4bc7f0aa407c..2cf2ea6546ddc 100644 --- a/src/test/recovery/t/051_effective_wal_level.pl +++ b/src/test/recovery/t/051_effective_wal_level.pl @@ -326,11 +326,12 @@ sub wait_for_logical_decoding_disabled $standby3->stop; -# Test the race condition at end of the recovery between the startup and logical -# decoding status change. This test requires injection points enabled. if ( $ENV{enable_injection_points} eq 'yes' && $primary->check_extension('injection_points')) { + # Test the race condition at end of the recovery between the startup and logical + # decoding status change. This test requires injection points enabled. + # Initialize standby4 and start it. my $standby4 = PostgreSQL::Test::Cluster->new('standby4'); $standby4->init_from_backup($primary, 'my_backup', has_streaming => 1); @@ -381,9 +382,63 @@ sub wait_for_logical_decoding_disabled test_wal_level($primary, "replica|replica", "effective_wal_level got decreased to 'replica' on primary"); + # Test that concurrent activations don't write redundant status-change records. + + # Start a psql session and stop it in the middle of the activation process. + my $psql_create_slot = $primary->background_psql('postgres'); + $psql_create_slot->query_until( + qr/create_slot_1/, + q(\echo create_slot_1 +select injection_points_set_local(); +select injection_points_attach('logical-decoding-activation', 'wait'); +select pg_create_logical_replication_slot('slot_1', 'test_decoding'); +)); + $primary->wait_for_event('client backend', 'logical-decoding-activation'); + note("injection_point 'logical-decoding-activation' is reached"); + + # A second backend concurrently enables logical decoding and finishes creating + # its slot, writing the status-change record. The slot reserves its decoding + # start point after its own status-change record. + $primary->safe_psql('postgres', + qq[select pg_create_logical_replication_slot('slot_2', 'test_decoding')] + ); + test_wal_level($primary, "replica|logical", + "logical decoding enabled by the first of two concurrent activations" + ); + + # Resume the first backend to complete the slot creation. It must not write + # a second redundant status-change record as logical decoding is already + # enabled. + $primary->safe_psql('postgres', + qq[select injection_points_wakeup('logical-decoding-activation')]); + + # Let the released backend finish creating its slot. + $psql_create_slot->quit; + + # Decode from slot_2, whose start point precedes where a redundant + # status-change record would have been written; this fails in xlog_decode() + # if one exists. + is( $primary->safe_psql( + 'postgres', + qq[SELECT count(*) FROM pg_logical_slot_get_changes('slot_2', NULL, NULL, 'skip-empty-xacts', '1')] + ), + 0, + 'decoding a concurrently-created slot succeeds'); + + # Restore the disabled state for the tests that follow. + $primary->safe_psql( + 'postgres', + qq[ +select pg_drop_replication_slot('slot_1'); +select pg_drop_replication_slot('slot_2'); +]); + wait_for_logical_decoding_disabled($primary); + + # Test a race when logical decoding activation is concurrently interrupted. + # Start a psql session to test the case where the activation process is # interrupted. - my $psql_create_slot = $primary->background_psql('postgres'); + $psql_create_slot = $primary->background_psql('postgres'); # Start the logical decoding activation process upon creating the logical # slot, but it will wait due to the injection point. From 5a3b22eb304806d5e492e6b62a34e77d6e060573 Mon Sep 17 00:00:00 2001 From: Tom Lane Date: Mon, 27 Jul 2026 16:19:24 -0400 Subject: [PATCH 23/43] Further improve the names generated for indexes on expressions. Commit 181b6185c failed to do anything useful with a whole-row Var, deeming it "fishy". But it is legal to put such a Var into an expression index column, so let's expand it as the name of the table. Another problem reachable via that one is that we could generate an empty index column name, which isn't really legal although by chance nothing complained about it. It's not clear whether any other such cases remain, but as cheap insurance let's use "expr" if the tree walk fails to generate any text. Reported-by: Chauhan Dhruv Author: Chauhan Dhruv Co-authored-by: Tom Lane Discussion: https://postgr.es/m/CANWwWcp_DCJjq8pomeqp6W=fbygvzXXQO028VDJ9_6sLPjQnVA@mail.gmail.com --- src/backend/commands/indexcmds.c | 25 ++++++++++++++++------ src/test/regress/expected/create_index.out | 16 +++++++++++--- src/test/regress/sql/create_index.sql | 10 ++++++--- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/backend/commands/indexcmds.c b/src/backend/commands/indexcmds.c index 713bb5d10f19f..b71e588a953f8 100644 --- a/src/backend/commands/indexcmds.c +++ b/src/backend/commands/indexcmds.c @@ -2874,6 +2874,9 @@ ChooseIndexExpressionName(Relation rel, Node *indexExpr) context.buf = &buf; /* Walk the tree, stopping when we have enough text */ (void) ChooseIndexExpressionName_walker(indexExpr, &context); + /* Fall back to "expr" if the walk found nothing, to avoid an empty name */ + if (buf.len == 0) + appendStringInfoString(&buf, "expr"); /* Ensure generated names are shorter than NAMEDATALEN */ nlen = pg_mbcliplen(buf.data, buf.len, NAMEDATALEN - 1); buf.data[nlen] = '\0'; @@ -2891,19 +2894,29 @@ ChooseIndexExpressionName_walker(Node *node, { Var *var = (Var *) node; TupleDesc tupdesc = RelationGetDescr(context->rel); - Form_pg_attribute att; + const char *varname; /* Paranoia: ignore the Var if it looks fishy */ if (var->varno != 1 || var->varlevelsup != 0 || - var->varattno <= 0 || var->varattno > tupdesc->natts) + var->varattno < 0 || var->varattno > tupdesc->natts) return false; - att = TupleDescAttr(tupdesc, var->varattno - 1); - if (att->attisdropped) - return false; /* even more paranoia; shouldn't happen */ + if (var->varattno > 0) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, var->varattno - 1); + + if (att->attisdropped) + return false; /* even more paranoia; shouldn't happen */ + varname = NameStr(att->attname); + } + else + { + /* Whole-row Var: use the table's name */ + varname = RelationGetRelationName(context->rel); + } if (context->buf->len > 0) appendStringInfoChar(context->buf, '_'); - appendStringInfoString(context->buf, NameStr(att->attname)); + appendStringInfoString(context->buf, varname); /* Done if we've already reached NAMEDATALEN */ return (context->buf->len >= NAMEDATALEN); diff --git a/src/test/regress/expected/create_index.out b/src/test/regress/expected/create_index.out index 1145d57726dda..7b2640f0e042d 100644 --- a/src/test/regress/expected/create_index.out +++ b/src/test/regress/expected/create_index.out @@ -1374,6 +1374,11 @@ ERROR: duplicate key value violates unique constraint "func_index_index" DETAIL: Key ((f1 || f2))=(ABCDEF) already exists. -- but this shouldn't: INSERT INTO func_index_heap VALUES('QWERTY'); +-- this should fail because of unsafe column type (anonymous record) +create index on func_index_heap ((f1 || f2), (row(f1, f2))); +ERROR: column "f1_f21" has pseudo-type record +-- but this is allowed: +create index on func_index_heap ((func_index_heap.*)); -- while we're here, see that the metadata looks sane \d func_index_heap Table "public.func_index_heap" @@ -1382,6 +1387,7 @@ INSERT INTO func_index_heap VALUES('QWERTY'); f1 | text | | | f2 | text | | | Indexes: + "func_index_heap_func_index_heap_idx" btree ((func_index_heap.*)) "func_index_index" UNIQUE, btree ((f1 || f2)) \d func_index_index @@ -1391,9 +1397,13 @@ Indexes: f1_f2 | text | yes | (f1 || f2) unique, btree, for table "public.func_index_heap" --- this should fail because of unsafe column type (anonymous record) -create index on func_index_heap ((f1 || f2), (row(f1, f2))); -ERROR: column "f1_f21" has pseudo-type record +\d func_index_heap_func_index_heap_idx + Index "public.func_index_heap_func_index_heap_idx" + Column | Type | Key? | Definition +-----------------+-----------------+------+--------------------- + func_index_heap | func_index_heap | yes | (func_index_heap.*) +btree, for table "public.func_index_heap" + -- -- Test unique index with included columns -- diff --git a/src/test/regress/sql/create_index.sql b/src/test/regress/sql/create_index.sql index 8e59f6bcd01a8..88ca3c80875c4 100644 --- a/src/test/regress/sql/create_index.sql +++ b/src/test/regress/sql/create_index.sql @@ -457,12 +457,16 @@ INSERT INTO func_index_heap VALUES('ABCD', 'EF'); -- but this shouldn't: INSERT INTO func_index_heap VALUES('QWERTY'); +-- this should fail because of unsafe column type (anonymous record) +create index on func_index_heap ((f1 || f2), (row(f1, f2))); + +-- but this is allowed: +create index on func_index_heap ((func_index_heap.*)); + -- while we're here, see that the metadata looks sane \d func_index_heap \d func_index_index - --- this should fail because of unsafe column type (anonymous record) -create index on func_index_heap ((f1 || f2), (row(f1, f2))); +\d func_index_heap_func_index_heap_idx -- From 74276e685dd01a8834f05f7d8b29a140a264e127 Mon Sep 17 00:00:00 2001 From: Michael Paquier Date: Tue, 28 Jul 2026 08:33:23 +0900 Subject: [PATCH 24/43] Fix propagation of indimmediate flag in index_create_copy() index_create_copy is used to create copy definitions of existing indexes. Currently, it passes 0 as constr_flags to index_create(), which results in the copied index to always be created as immediate (indimmediate set to true). For deferrable unique constraints, it means that the transient index used during the phase 2 of REINDEX CONCURRENTLY forces immediate constraint checks on concurrent inserts, which can cause unexpected constraint violations based on the definition of the parent table, inconsistently set in the copied index. To fix this without violating the contract of constr_flags (which should only be used when creating constraints) and without relaxing the strict assertion in index_create(), this introduces a new index creation flag: INDEX_CREATE_DEFERRABLE. If set, a copied index's indimmediate is set to false, meaning that unique constraints are not enforced immediately on insertion, but at transaction commit time. An isolation test for REINDEX CONCURRENTLY is added, based on an injection point waiting after phase 1 of the operation, where an index copy has been built and is able to accept DMLs for its validation in phase 2. The test is tentatively backpatched down to v17. INJECTION_POINT() is outside a transaction context, which should be fine on HEAD since 8daeaa9b642c but I suspect may cause issues in v19 and older branches due to the wait facility depending on condition variables and a DSM setup, but let's see what the buildfarm tells. Author: Nitin Motiani Discussion: https://postgr.es/m/CAH5HC97JmjPpgiQOqW9xm8qXhNiu7zZ1Qh+FfhEESJuDv69kuQ@mail.gmail.com Backpatch-through: 14 --- src/backend/catalog/index.c | 16 +++++- src/backend/commands/indexcmds.c | 2 + src/include/catalog/index.h | 1 + src/test/modules/injection_points/Makefile | 1 + .../reindex_concurrently_deferred.out | 41 +++++++++++++++ src/test/modules/injection_points/meson.build | 1 + .../specs/reindex_concurrently_deferred.spec | 50 +++++++++++++++++++ 7 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 src/test/modules/injection_points/expected/reindex_concurrently_deferred.out create mode 100644 src/test/modules/injection_points/specs/reindex_concurrently_deferred.spec diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c index 31ef84d0a1663..7c1b94407a9be 100644 --- a/src/backend/catalog/index.c +++ b/src/backend/catalog/index.c @@ -717,6 +717,9 @@ UpdateIndexRelation(Oid indexoid, * create a partitioned index (table must be partitioned) * INDEX_CREATE_SUPPRESS_PROGRESS: * don't report progress during the index build. + * INDEX_CREATE_DEFERRABLE: + * index supports a deferrable constraint, mark it as + * non-immediate (indimmediate = false). * * constr_flags: flags passed to index_constraint_create * (only if INDEX_CREATE_ADD_CONSTRAINT is set) @@ -1051,7 +1054,8 @@ index_create(Relation heapRelation, indexInfo, collationIds, opclassIds, coloptions, isprimary, is_exclusion, - (constr_flags & INDEX_CONSTR_CREATE_DEFERRABLE) == 0, + (constr_flags & INDEX_CONSTR_CREATE_DEFERRABLE) == 0 && + (flags & INDEX_CREATE_DEFERRABLE) == 0, !concurrent && !invalid, !concurrent); @@ -1324,6 +1328,7 @@ index_create_copy(Relation heapRelation, uint16 flags, List *indexColNames = NIL; List *indexExprs = NIL; List *indexPreds = NIL; + Form_pg_index indexForm; indexRelation = index_open(oldIndexId, RowExclusiveLock); @@ -1343,6 +1348,13 @@ index_create_copy(Relation heapRelation, uint16 flags, indexTuple = SearchSysCache1(INDEXRELID, ObjectIdGetDatum(oldIndexId)); if (!HeapTupleIsValid(indexTuple)) elog(ERROR, "cache lookup failed for index %u", oldIndexId); + + indexForm = (Form_pg_index) GETSTRUCT(indexTuple); + + /* Old index is deferrable, do the same for the new index */ + if (!indexForm->indimmediate) + flags |= INDEX_CREATE_DEFERRABLE; + indclassDatum = SysCacheGetAttrNotNull(INDEXRELID, indexTuple, Anum_pg_index_indclass); indclass = (oidvector *) DatumGetPointer(indclassDatum); @@ -1477,7 +1489,7 @@ index_create_copy(Relation heapRelation, uint16 flags, stattargets, reloptionsDatum, flags, - 0, + 0, /* constr_flags */ true, /* allow table to be a system catalog? */ false, /* is_internal? */ NULL); diff --git a/src/backend/commands/indexcmds.c b/src/backend/commands/indexcmds.c index b71e588a953f8..3790b8e1252c9 100644 --- a/src/backend/commands/indexcmds.c +++ b/src/backend/commands/indexcmds.c @@ -4296,6 +4296,8 @@ ReindexRelationConcurrently(const ReindexStmt *stmt, Oid relationOid, const Rein CommitTransactionCommand(); } + INJECTION_POINT("reindex-conc-index-built", NULL); + StartTransactionCommand(); /* diff --git a/src/include/catalog/index.h b/src/include/catalog/index.h index 9aee822634781..b952ad071d31b 100644 --- a/src/include/catalog/index.h +++ b/src/include/catalog/index.h @@ -72,6 +72,7 @@ extern void index_check_primary_key(Relation heapRel, #define INDEX_CREATE_PARTITIONED (1 << 5) #define INDEX_CREATE_INVALID (1 << 6) #define INDEX_CREATE_SUPPRESS_PROGRESS (1 << 7) +#define INDEX_CREATE_DEFERRABLE (1 << 8) extern Oid index_create(Relation heapRelation, const char *indexRelationName, diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile index fac80f3a4a735..25a3ddd890d18 100644 --- a/src/test/modules/injection_points/Makefile +++ b/src/test/modules/injection_points/Makefile @@ -14,6 +14,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress ISOLATION = basic \ inplace \ + reindex_concurrently_deferred \ repack \ repack_temporal \ repack_temporal_multirange \ diff --git a/src/test/modules/injection_points/expected/reindex_concurrently_deferred.out b/src/test/modules/injection_points/expected/reindex_concurrently_deferred.out new file mode 100644 index 0000000000000..39924fa24fe32 --- /dev/null +++ b/src/test/modules/injection_points/expected/reindex_concurrently_deferred.out @@ -0,0 +1,41 @@ +Parsed test spec with 2 sessions + +starting permutation: reindex check_catalog begin2 write2 write_dup resolve_dup commit2 wakeup noop1 +injection_points_attach +----------------------- + +(1 row) + +step reindex: REINDEX TABLE CONCURRENTLY reind_deferred; +step check_catalog: + SELECT c.relname, i.indisunique, i.indimmediate, i.indisready, i.indisvalid + FROM pg_class c + JOIN pg_index i ON i.indexrelid = c.oid + WHERE c.relname = 'uq_val_ccnew'; + +relname |indisunique|indimmediate|indisready|indisvalid +------------+-----------+------------+----------+---------- +uq_val_ccnew|t |f |t |f +(1 row) + +step begin2: BEGIN; +step write2: INSERT INTO reind_deferred VALUES (3, 9); +step write_dup: INSERT INTO reind_deferred VALUES (4, 9); +step resolve_dup: UPDATE reind_deferred SET val = 10 WHERE id = 4; +step commit2: COMMIT; +step wakeup: + SELECT injection_points_detach('reindex-conc-index-built'); + SELECT injection_points_wakeup('reindex-conc-index-built'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_wakeup +----------------------- + +(1 row) + +step reindex: <... completed> +step noop1: diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build index 163b6374ebcdd..aaf0536ba7e9e 100644 --- a/src/test/modules/injection_points/meson.build +++ b/src/test/modules/injection_points/meson.build @@ -45,6 +45,7 @@ tests += { 'specs': [ 'basic', 'inplace', + 'reindex_concurrently_deferred', 'repack', 'repack_temporal', 'repack_temporal_multirange', diff --git a/src/test/modules/injection_points/specs/reindex_concurrently_deferred.spec b/src/test/modules/injection_points/specs/reindex_concurrently_deferred.spec new file mode 100644 index 0000000000000..4b95e1da2a712 --- /dev/null +++ b/src/test/modules/injection_points/specs/reindex_concurrently_deferred.spec @@ -0,0 +1,50 @@ +# REINDEX CONCURRENTLY with DEFERRED constraints +# +# Verify that concurrent writes that temporarily violate a deferred unique +# constraint do not fail while REINDEX CONCURRENTLY is running. +# +# The injection point "reindex-conc-index-built" fires after the phase 2 +# of REINDEX CONCURRENTLY, when the new index has indisready = true (inserts +# are checked against it) but indisvalid = false. + +setup +{ + CREATE EXTENSION injection_points; + CREATE TABLE reind_deferred (id int, val int, + CONSTRAINT uq_val UNIQUE(val) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO reind_deferred VALUES (1, 1), (2, 2); +} + +teardown +{ + DROP TABLE reind_deferred; + DROP EXTENSION injection_points; +} + +session s1 +setup +{ + SELECT injection_points_set_local(); + SELECT injection_points_attach('reindex-conc-index-built', 'wait'); +} +step reindex { REINDEX TABLE CONCURRENTLY reind_deferred; } +step noop1 { } + +session s2 +step check_catalog { + SELECT c.relname, i.indisunique, i.indimmediate, i.indisready, i.indisvalid + FROM pg_class c + JOIN pg_index i ON i.indexrelid = c.oid + WHERE c.relname = 'uq_val_ccnew'; +} +step begin2 { BEGIN; } +step write2 { INSERT INTO reind_deferred VALUES (3, 9); } +step write_dup { INSERT INTO reind_deferred VALUES (4, 9); } +step resolve_dup { UPDATE reind_deferred SET val = 10 WHERE id = 4; } +step commit2 { COMMIT; } +step wakeup { + SELECT injection_points_detach('reindex-conc-index-built'); + SELECT injection_points_wakeup('reindex-conc-index-built'); +} + +permutation reindex check_catalog begin2 write2 write_dup resolve_dup commit2 wakeup noop1 From 5713b437abed7085e7d59849c6e9e0f4f469633d Mon Sep 17 00:00:00 2001 From: Michael Paquier Date: Tue, 28 Jul 2026 10:49:26 +0900 Subject: [PATCH 25/43] Fix portability issue in authentication test 003_peer The mapped user name is built upon the OS user name of the environment where the test is run. Depending on the characters used in the OS user name, CREATE ROLE may not get parsed (the author has mentioned hyphens as one case), causing a failure of the test. Let's use double-quotes around the mapped user name, which should be a solution good enough for the environments where this test tends to run. The buildfarm issued no complaint over the years. Oversight in 3c4e26a62c31, so backpatch down to v19. Perhaps 3c4e26a62c31 and this commit should be backpatched further down, but let's leave that for another day, if it proves necessary. Author: Yugo Nagata Discussion: https://postgr.es/m/20260727133857.fbd23d43d422f10f376a8bee@sraoss.co.jp Backpatch-through: 19 --- src/test/authentication/t/003_peer.pl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/authentication/t/003_peer.pl b/src/test/authentication/t/003_peer.pl index 5c774babd3233..686e409ce6a3c 100644 --- a/src/test/authentication/t/003_peer.pl +++ b/src/test/authentication/t/003_peer.pl @@ -213,7 +213,7 @@ sub test_role # Create target role for \1 tests. my $mapped_name = "test${regex_test_string}map${regex_test_string}user"; -$node->safe_psql('postgres', "CREATE ROLE $mapped_name LOGIN"); +$node->safe_psql('postgres', "CREATE ROLE \"$mapped_name\" LOGIN"); # Success as the regular expression matches and \1 is replaced in the given # subexpression. From 62d1a5f8be836faa5547789fe19d34bac1b908f4 Mon Sep 17 00:00:00 2001 From: Dean Rasheed Date: Tue, 28 Jul 2026 09:44:23 +0100 Subject: [PATCH 26/43] Avoid RETURNING side effects for FOR PORTION OF leftovers. UPDATE/DELETE ... FOR PORTION OF inserts leftover rows for the untouched parts of the original row. These hidden inserts should not affect the command tag or ROW_COUNT, so they call ExecInsert() with canSetTag set to false. However, ExecInsert() still processed the RETURNING list whenever the target ResultRelInfo had ri_projectReturning set. That caused RETURNING expressions to be evaluated for leftover rows even though their results were discarded. As a result, expressions with side effects and information-leaking functions could be executed on the leftover rows, in addition to the visibly updated or deleted row. Fix by having ExecInsert() skip RETURNING processing when it is handling an internal FOR PORTION OF leftover insert. Use both the presence of a FOR PORTION OF clause and mtstate->operation == CMD_INSERT for this check, so that the auxiliary INSERT of a cross-partition UPDATE with a FOR PORTION OF clause still processes RETURNING normally. Back-patch to v19, where support for FOR PORTION OF was added. Author: Chao Li Reviewed-by: Dean Rasheed Reviewed-by: Paul A Jungwirth Discussion: https://postgr.es/m/07C125E5-F6ED-460C-A394-E6503DAE18FB@gmail.com Backpatch-through: 19 --- src/backend/executor/nodeModifyTable.c | 14 ++++++-- src/test/regress/expected/for_portion_of.out | 36 +++++++++++++++----- src/test/regress/sql/for_portion_of.sql | 16 +++++++-- 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c index 1dbf0ffff9ed6..9a1c0992bfea8 100644 --- a/src/backend/executor/nodeModifyTable.c +++ b/src/backend/executor/nodeModifyTable.c @@ -1335,8 +1335,18 @@ ExecInsert(ModifyTableContext *context, if (resultRelInfo->ri_WithCheckOptions != NIL) ExecWithCheckOptions(WCO_VIEW_CHECK, resultRelInfo, slot, estate); - /* Process RETURNING if present */ - if (resultRelInfo->ri_projectReturning) + /* + * Process RETURNING if present. + * + * If this is an UPDATE/DELETE ... FOR PORTION OF, we do not return the + * leftover rows inserted by ExecForPortionOfLeftovers(). Note that we + * must check mtstate->operation here, because we *do* want to process the + * newly inserted row of a cross-partition UPDATE with a FOR PORTION OF + * clause (ExecCrossPartitionUpdate() leaves mtstate->operation set to + * CMD_UPDATE, whereas ExecForPortionOfLeftovers() sets it to CMD_INSERT). + */ + if (resultRelInfo->ri_projectReturning && + !(node->forPortionOf && mtstate->operation == CMD_INSERT)) { TupleTableSlot *oldSlot = NULL; diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out index 3c592f90e70ce..a6cb1ba8380ce 100644 --- a/src/test/regress/expected/for_portion_of.out +++ b/src/test/regress/expected/for_portion_of.out @@ -920,14 +920,23 @@ SELECT * FROM for_portion_of_test ORDER BY id, valid_at; \set QUIET true -- UPDATE ... RETURNING returns only the updated values -- (not the inserted side values, which are added by a separate "statement"): +CREATE FUNCTION fpo_returning_row(text) +RETURNS text LANGUAGE plpgsql AS +$$ +BEGIN + RAISE NOTICE 'RETURNING %', $1; + RETURN $1; +END; +$$; UPDATE for_portion_of_test FOR PORTION OF valid_at FROM '2018-02-01' TO '2018-02-15' SET name = 'three^3' WHERE id = '[3,4)' - RETURNING *; - id | valid_at | name --------+-------------------------+--------- - [3,4) | [2018-02-01,2018-02-15) | three^3 + RETURNING *, fpo_returning_row(for_portion_of_test::text); +NOTICE: RETURNING ("[3,4)","[2018-02-01,2018-02-15)",three^3) + id | valid_at | name | fpo_returning_row +-------+-------------------------+---------+--------------------------------------------- + [3,4) | [2018-02-01,2018-02-15) | three^3 | ("[3,4)","[2018-02-01,2018-02-15)",three^3) (1 row) -- UPDATE ... RETURNING supports NEW and OLD valid_at @@ -975,10 +984,11 @@ DELETE FROM for_portion_of_test WHERE id = '[99,100)'; DELETE FROM for_portion_of_test FOR PORTION OF valid_at FROM '2018-02-02' TO '2018-02-03' WHERE id = '[3,4)' - RETURNING *; - id | valid_at | name --------+-------------------------+--------- - [3,4) | [2018-02-01,2018-02-10) | three^3 + RETURNING *, fpo_returning_row(for_portion_of_test::text); +NOTICE: RETURNING ("[3,4)","[2018-02-01,2018-02-10)",three^3) + id | valid_at | name | fpo_returning_row +-------+-------------------------+---------+--------------------------------------------- + [3,4) | [2018-02-01,2018-02-10) | three^3 | ("[3,4)","[2018-02-01,2018-02-10)",three^3) (1 row) -- DELETE FOR PORTION OF in a PL/pgSQL function @@ -2137,7 +2147,14 @@ UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-0 UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01' SET name = 'one^2', id = '[4,5)' - WHERE id = '[1,2)'; + WHERE id = '[1,2)' + RETURNING id, valid_at, name, fpo_returning_row(temporal_partitioned::text); +NOTICE: RETURNING ("[4,5)","[2000-06-01,2000-07-01)",one^2,30) + id | valid_at | name | fpo_returning_row +-------+-------------------------+-------+---------------------------------------------- + [4,5) | [2000-06-01,2000-07-01) | one^2 | ("[4,5)","[2000-06-01,2000-07-01)",one^2,30) +(1 row) + -- Move from partition 3 to partition 1 UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01' SET name = 'three^2', @@ -2199,6 +2216,7 @@ SELECT * FROM temporal_partitioned_5 ORDER BY id, valid_at; five | [2000-07-01,2010-01-01) | [5,6) | 3471 (4 rows) +DROP FUNCTION fpo_returning_row; DROP TABLE temporal_partitioned; -- UPDATE/DELETE FOR PORTION OF with RULEs CREATE TABLE fpo_rule (f1 bigint, f2 int4range); diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql index 5f7b04fdf6b40..955cf666d6613 100644 --- a/src/test/regress/sql/for_portion_of.sql +++ b/src/test/regress/sql/for_portion_of.sql @@ -590,11 +590,19 @@ SELECT * FROM for_portion_of_test ORDER BY id, valid_at; -- UPDATE ... RETURNING returns only the updated values -- (not the inserted side values, which are added by a separate "statement"): +CREATE FUNCTION fpo_returning_row(text) +RETURNS text LANGUAGE plpgsql AS +$$ +BEGIN + RAISE NOTICE 'RETURNING %', $1; + RETURN $1; +END; +$$; UPDATE for_portion_of_test FOR PORTION OF valid_at FROM '2018-02-01' TO '2018-02-15' SET name = 'three^3' WHERE id = '[3,4)' - RETURNING *; + RETURNING *, fpo_returning_row(for_portion_of_test::text); -- UPDATE ... RETURNING supports NEW and OLD valid_at UPDATE for_portion_of_test @@ -629,7 +637,7 @@ DELETE FROM for_portion_of_test WHERE id = '[99,100)'; DELETE FROM for_portion_of_test FOR PORTION OF valid_at FROM '2018-02-02' TO '2018-02-03' WHERE id = '[3,4)' - RETURNING *; + RETURNING *, fpo_returning_row(for_portion_of_test::text); -- DELETE FOR PORTION OF in a PL/pgSQL function INSERT INTO for_portion_of_test (id, valid_at, name) VALUES @@ -1439,7 +1447,8 @@ UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-0 UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01' SET name = 'one^2', id = '[4,5)' - WHERE id = '[1,2)'; + WHERE id = '[1,2)' + RETURNING id, valid_at, name, fpo_returning_row(temporal_partitioned::text); -- Move from partition 3 to partition 1 UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01' @@ -1460,6 +1469,7 @@ SELECT * FROM temporal_partitioned_1 ORDER BY id, valid_at; SELECT * FROM temporal_partitioned_3 ORDER BY id, valid_at; SELECT * FROM temporal_partitioned_5 ORDER BY id, valid_at; +DROP FUNCTION fpo_returning_row; DROP TABLE temporal_partitioned; -- UPDATE/DELETE FOR PORTION OF with RULEs From 8a045f760f67c8aa72881511add869d3d275a431 Mon Sep 17 00:00:00 2001 From: Alexander Korotkov Date: Tue, 28 Jul 2026 10:50:13 +0200 Subject: [PATCH 27/43] Restore vacuum_delay_point() in GIN posting-tree leaf vacuum Commit fd83c83d094 turned the recursive posting-tree cleanup in ginVacuumPostingTreeLeaves() into an iterative sweep that follows the tree's leaf pages via their rightlinks. The recursive version called vacuum_delay_point() while processing the tree, but that call was removed and never re-added to the new loop. As that commit only set out to fix a deadlock, the removal appears to have been unintentional. Consequently the leaf-page sweep of a single posting tree runs with no vacuum_delay_point(), and therefore no CHECK_FOR_INTERRUPTS(). A posting tree stores all the TIDs for one indexed key, so for a frequently occurring key it can span a large number of leaf pages. While such a tree is being vacuumed the operation ignores vacuum_cost_delay and does not respond to query cancellation or statement_timeout; an autovacuum worker likewise cannot be interrupted mid-sweep when another backend requests a conflicting lock. Restore the call, placed after the current page has been unlocked and released so that no buffer content lock is held across a potential delay (cf. 21c27af65fb). The sibling loops in ginbulkdelete() and ginvacuumcleanup() already call vacuum_delay_point() once per page. Author: Paul Kim Co-authored-by: Alexander Korotkov Reviewed-by: Michael Paquier Reviewed-by: Andrey Borodin Reviewed-by: solai v Discussion: https://postgr.es/m/178447127453.110.12276981925360691905%40mail.gmail.com Backpatch-through: 14 --- src/backend/access/gin/ginvacuum.c | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/backend/access/gin/ginvacuum.c b/src/backend/access/gin/ginvacuum.c index 840543eb6642b..040f21a92e317 100644 --- a/src/backend/access/gin/ginvacuum.c +++ b/src/backend/access/gin/ginvacuum.c @@ -429,6 +429,13 @@ ginVacuumPostingTreeLeaves(GinVacuumState *gvs, BlockNumber blkno) if (blkno == InvalidBlockNumber) break; + /* + * A safe point to delay/accept interrupts: the previous page has been + * unlocked and released, so we hold no buffer content lock (nor any + * other LWLock) here and CHECK_FOR_INTERRUPTS() can do its job. + */ + vacuum_delay_point(false); + buffer = ReadBufferExtended(gvs->index, MAIN_FORKNUM, blkno, RBM_NORMAL, gvs->strategy); LockBuffer(buffer, GIN_EXCLUSIVE); From 63e7a0d2c3c7f80e52ddf216707ccc1d466453a4 Mon Sep 17 00:00:00 2001 From: Masahiko Sawada Date: Tue, 28 Jul 2026 10:39:36 -0700 Subject: [PATCH 28/43] Fix pg_get_publication_tables() failure with concurrent DROP TABLE. pg_get_publication_tables() collects the OIDs of the published tables on its first call, without locking them, and then reopens each table later, once per result row, to compute its column list and fetch its row filter. The reopen used table_open(), which errors out with "could not open relation with OID" if the table has been dropped in the meantime. This could happen for any published table without an explicit column list, which is every table in FOR ALL TABLES and FOR TABLES IN SCHEMA publications, but also FOR TABLE entries without a column list. The failure is common in environments where many tables are created and dropped while publication tables are being queried, e.g. by table synchronization on a subscriber. Fix by opening every table with try_table_open(), which returns NULL if the relation no longer exists, and skipping the table in that case. Concurrently dropped tables are thus simply absent from the result set, which is the expected point-in-time behavior. As a side effect, tables with an explicit column list, which were previously returned without being opened, are now also locked with AccessShareLock, so the function can block behind concurrent DDL on such tables where it previously did not. Backpatch to v16, where we added the table_open() call in pg_get_publication_tables(). Author: Bharath Rupireddy Reviewed-by: Bertrand Drouvot Reviewed-by: shveta malik Reviewed-by: Ajin Cherian Reviewed-by: Masahiko Sawada Reviewed-by: Chao Li Discussion: https://www.postgresql.org/message-id/CALj2ACVYYooWH-5tJ6cPKkU%2BmutVxwb_z4S%2BqAi-zdrFqxXE2Q%40mail.gmail.com Backpatch-through: 16 --- src/backend/catalog/pg_publication.c | 52 +++++++++++++++---- .../expected/pub-concurrent-drop.out | 16 ++++++ src/test/isolation/isolation_schedule | 1 + .../isolation/specs/pub-concurrent-drop.spec | 36 +++++++++++++ src/tools/pgindent/typedefs.list | 1 + 5 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 src/test/isolation/expected/pub-concurrent-drop.out create mode 100644 src/test/isolation/specs/pub-concurrent-drop.spec diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c index 1ec94c851b2f3..ea28ec319c568 100644 --- a/src/backend/catalog/pg_publication.c +++ b/src/backend/catalog/pg_publication.c @@ -1424,14 +1424,27 @@ pg_get_publication_tables(FunctionCallInfo fcinfo, ArrayType *pubnames, bool pub_missing_ok) { #define NUM_PUBLICATION_TABLES_ELEM 4 + + /* + * State carried across SRF calls. We track the index ourselves instead of + * using funcctx->call_cntr, so that concurrently dropped tables can be + * skipped without emitting a row. + */ + typedef struct + { + List *table_infos; /* list of published_rel */ + int curr_idx; /* current index into table_infos */ + } publication_tables_state; + FuncCallContext *funcctx; - List *table_infos = NIL; + publication_tables_state *ptstate = NULL; /* stuff done only on the first call of the function */ if (SRF_IS_FIRSTCALL()) { TupleDesc tupdesc; MemoryContext oldcontext; + List *table_infos = NIL; Datum *elems; int nelems, i; @@ -1554,26 +1567,47 @@ pg_get_publication_tables(FunctionCallInfo fcinfo, ArrayType *pubnames, TupleDescFinalize(tupdesc); funcctx->tuple_desc = BlessTupleDesc(tupdesc); - funcctx->user_fctx = table_infos; + + /* Store the state to be used across SRF calls. */ + ptstate = palloc_object(publication_tables_state); + ptstate->table_infos = table_infos; + ptstate->curr_idx = 0; + funcctx->user_fctx = ptstate; MemoryContextSwitchTo(oldcontext); } /* stuff done on every call of the function */ funcctx = SRF_PERCALL_SETUP(); - table_infos = (List *) funcctx->user_fctx; + ptstate = (publication_tables_state *) funcctx->user_fctx; - if (funcctx->call_cntr < list_length(table_infos)) + while (ptstate->curr_idx < list_length(ptstate->table_infos)) { HeapTuple pubtuple = NULL; HeapTuple rettuple; Publication *pub; - published_rel *table_info = (published_rel *) list_nth(table_infos, funcctx->call_cntr); + published_rel *table_info = (published_rel *) list_nth(ptstate->table_infos, + ptstate->curr_idx); Oid relid = table_info->relid; - Oid schemaid = get_rel_namespace(relid); + Relation rel; + Oid schemaid; Datum values[NUM_PUBLICATION_TABLES_ELEM] = {0}; bool nulls[NUM_PUBLICATION_TABLES_ELEM] = {0}; + /* Advance the index for the next call. */ + ptstate->curr_idx++; + + /* + * The table OIDs were collected earlier, so a table may have been + * dropped before we get here. try_table_open() returns NULL if it is + * already gone, in which case we skip it; such tables are simply + * absent from the result set, which is the expected point-in-time + * behavior. + */ + rel = try_table_open(relid, AccessShareLock); + if (rel == NULL) + continue; + /* * Form tuple with appropriate data. */ @@ -1587,6 +1621,7 @@ pg_get_publication_tables(FunctionCallInfo fcinfo, ArrayType *pubnames, * We don't consider row filters or column lists for FOR ALL TABLES or * FOR TABLES IN SCHEMA publications. */ + schemaid = RelationGetNamespace(rel); if (!pub->alltables && !SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP, ObjectIdGetDatum(schemaid), @@ -1616,7 +1651,6 @@ pg_get_publication_tables(FunctionCallInfo fcinfo, ArrayType *pubnames, /* Show all columns when the column list is not specified. */ if (nulls[2]) { - Relation rel = table_open(relid, AccessShareLock); int nattnums = 0; int16 *attnums; TupleDesc desc = RelationGetDescr(rel); @@ -1653,10 +1687,10 @@ pg_get_publication_tables(FunctionCallInfo fcinfo, ArrayType *pubnames, values[2] = PointerGetDatum(buildint2vector(attnums, nattnums)); nulls[2] = false; } - - table_close(rel, AccessShareLock); } + table_close(rel, AccessShareLock); + rettuple = heap_form_tuple(funcctx->tuple_desc, values, nulls); SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(rettuple)); diff --git a/src/test/isolation/expected/pub-concurrent-drop.out b/src/test/isolation/expected/pub-concurrent-drop.out new file mode 100644 index 0000000000000..8360af0ec9ca6 --- /dev/null +++ b/src/test/isolation/expected/pub-concurrent-drop.out @@ -0,0 +1,16 @@ +Parsed test spec with 2 sessions + +starting permutation: lock list_pub_tables drop_and_commit +step lock: BEGIN; LOCK pubdrop.dropme IN ACCESS EXCLUSIVE MODE; +step list_pub_tables: + SELECT relid::regclass AS tablename + FROM pg_get_publication_tables('pub_schema') + ORDER BY tablename; + +step drop_and_commit: DROP TABLE pubdrop.dropme; COMMIT; +step list_pub_tables: <... completed> +tablename +-------------- +pubdrop.keepme +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c54..26abed9f9f072 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: pub-concurrent-drop diff --git a/src/test/isolation/specs/pub-concurrent-drop.spec b/src/test/isolation/specs/pub-concurrent-drop.spec new file mode 100644 index 0000000000000..4f7d701d60c8e --- /dev/null +++ b/src/test/isolation/specs/pub-concurrent-drop.spec @@ -0,0 +1,36 @@ +# Tests for concurrently dropping a relation while a publication's tables are +# being listed. + +setup +{ + CREATE SCHEMA pubdrop; + CREATE PUBLICATION pub_schema FOR TABLES IN SCHEMA pubdrop; + CREATE TABLE pubdrop.dropme (id int); + CREATE TABLE pubdrop.keepme (id int); +} + +teardown +{ + DROP SCHEMA pubdrop CASCADE; + DROP PUBLICATION pub_schema; +} + +session s1 +step lock { BEGIN; LOCK pubdrop.dropme IN ACCESS EXCLUSIVE MODE; } +step drop_and_commit { DROP TABLE pubdrop.dropme; COMMIT; } + +session s2 +step list_pub_tables +{ + SELECT relid::regclass AS tablename + FROM pg_get_publication_tables('pub_schema') + ORDER BY tablename; +} + +# Hold an ACCESS EXCLUSIVE lock on the table in one session, so that the query +# listing a publication's tables in another session blocks when it tries to +# open the locked table. Then drop the table in the same lock-holding session +# and commit, releasing the lock, so the query in another session resumes and +# skips the now-dropped table instead of erroring with "could not open relation +# with OID". +permutation lock list_pub_tables drop_and_commit diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 56c1f997f88b1..85d989f395d41 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -4209,6 +4209,7 @@ pthread_mutex_t pthread_once_t pthread_t ptrdiff_t +publication_tables_state published_rel pull_var_clause_context pull_varattnos_context From acaa100f9a0c8dbd20ef48b3ce8b94bb773d6acc Mon Sep 17 00:00:00 2001 From: Masahiko Sawada Date: Tue, 28 Jul 2026 12:33:28 -0700 Subject: [PATCH 29/43] Fix logical decoding of empty prepared transactions. A two-phase transaction that is assigned an XID but produces no change to be decoded -- for example, one that only acquires row locks via SELECT ... FOR SHARE -- has no base snapshot in the reorder buffer. ReorderBufferReplay() already skips such a transaction at PREPARE time and never invokes the begin_prepare/change/prepare callbacks for it, but ReorderBufferFinishPrepared() still called the commit_prepared (or rollback_prepared) callback. As a result a spurious COMMIT/ROLLBACK PREPARED was sent to the output plugin with no preceding PREPARE. For the built-in subscriber this breaks replication (the apply worker fails to find the prepared transaction), and test_decoding could even crash. Fix this by detecting an empty transaction (base_snapshot == NULL) in ReorderBufferFinishPrepared() and cleaning it up without invoking the commit/rollback prepared callbacks, mirroring the existing empty transaction handling in ReorderBufferReplay(). On v18 and newer versions, commit 072ee847ad4 changed ReorderBufferPrepare() to send the prepare whenever it had not already been sent, which also fires for empty transactions and emits a spurious PREPARE. On those branches ReorderBufferPrepare() is therefore additionally guarded with base_snapshot != NULL. This guard and the Assert(!rbtxn_sent_prepare()) added in ReorderBufferFinishPrepared(), are not necessary on v17 and older versions: there ReorderBufferPrepare() only sends a prepare for concurrently-aborted transactions (which never applies to an empty transaction) and the RBTXN_SENT_PREPARE flag does not exist. Back-patch to v14, where decoding of two-phase transactions was introduced. Bug: #19556 Reported-by: Alexander Kozhemyakin Reviewed-by: Amit Kapila Discussion: https://postgr.es/m/19556-daa6d7ea65054d48@postgresql.org Backpatch-through: 14 --- contrib/test_decoding/expected/twophase.out | 25 +++++++++++ contrib/test_decoding/sql/twophase.sql | 12 ++++++ .../replication/logical/reorderbuffer.c | 34 +++++++++++++-- src/test/subscription/t/021_twophase.pl | 41 +++++++++++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/contrib/test_decoding/expected/twophase.out b/contrib/test_decoding/expected/twophase.out index 08a7c56b5dfb5..ea3c51f8215c6 100644 --- a/contrib/test_decoding/expected/twophase.out +++ b/contrib/test_decoding/expected/twophase.out @@ -227,6 +227,31 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc COMMIT PREPARED 'test_toast_table_access' (1 row) +-- Test that an empty prepared transaction should not be decoded, whether it +-- is committed or rolled back. +BEGIN; +SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE; + id | data +----+------ + 1 | +(1 row) + +PREPARE TRANSACTION 'test_empty_transaction'; +COMMIT PREPARED 'test_empty_transaction'; +BEGIN; +SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE; + id | data +----+------ + 1 | +(1 row) + +PREPARE TRANSACTION 'test_empty_transaction'; +ROLLBACK PREPARED 'test_empty_transaction'; +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + data +------ +(0 rows) + -- Test 8: -- cleanup and make sure results are also empty DROP TABLE test_prepared1; diff --git a/contrib/test_decoding/sql/twophase.sql b/contrib/test_decoding/sql/twophase.sql index 4b9ef0c0c4499..834e5282c301e 100644 --- a/contrib/test_decoding/sql/twophase.sql +++ b/contrib/test_decoding/sql/twophase.sql @@ -125,6 +125,18 @@ COMMIT PREPARED 'test_toast_table_access'; -- consume commit prepared SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1'); +-- Test that an empty prepared transaction should not be decoded, whether it +-- is committed or rolled back. +BEGIN; +SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE; +PREPARE TRANSACTION 'test_empty_transaction'; +COMMIT PREPARED 'test_empty_transaction'; +BEGIN; +SELECT * FROM test_prepared1 WHERE id = 1 FOR SHARE; +PREPARE TRANSACTION 'test_empty_transaction'; +ROLLBACK PREPARED 'test_empty_transaction'; +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + -- Test 8: -- cleanup and make sure results are also empty DROP TABLE test_prepared1; diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c index 6df6166d8a741..6aed63463663d 100644 --- a/src/backend/replication/logical/reorderbuffer.c +++ b/src/backend/replication/logical/reorderbuffer.c @@ -2979,11 +2979,18 @@ ReorderBufferPrepare(ReorderBuffer *rb, TransactionId xid, txn->prepare_time, txn->origin_id, txn->origin_lsn); /* - * Send a prepare if not already done so. This might occur if we have - * detected a concurrent abort while replaying the non-streaming - * transaction. + * Send a prepare if not already done so. The "not already sent" case can + * occur if we have detected a concurrent abort while replaying the + * non-streaming transaction; we still send the prepare so that later when + * rollback prepared is decoded and sent, the downstream should be able to + * rollback such a xact. See comments atop DecodePrepare. + * + * Skip this for a transaction that made no changes to the database (i.e. + * has no base snapshot), as we haven't sent any changes for it. Such a + * transaction is cleaned up without invoking the commit/rollback prepared + * callbacks in ReorderBufferFinishPrepared(). */ - if (!rbtxn_sent_prepare(txn)) + if (!rbtxn_sent_prepare(txn) && txn->base_snapshot != NULL) { rb->prepare(rb, txn, txn->final_lsn); txn->txn_flags |= RBTXN_SENT_PREPARE; @@ -3050,6 +3057,25 @@ ReorderBufferFinishPrepared(ReorderBuffer *rb, TransactionId xid, txn->prepare_time, txn->origin_id, txn->origin_lsn); } + /* + * If this transaction has no snapshot, it didn't make any changes to the + * database, so there's nothing to decode. Note that + * ReorderBufferCommitChild will have transferred any snapshots from + * subtransactions if there were any. + */ + if (txn->base_snapshot == NULL) + { + Assert(txn->ninvalidations == 0); + Assert(!rbtxn_sent_prepare(txn)); + + /* + * Removing this txn before a commit might result in the computation + * of an incorrect restart_lsn. See SnapBuildProcessRunningXacts. + */ + ReorderBufferCleanupTXN(rb, txn); + return; + } + txn->final_lsn = commit_lsn; txn->end_lsn = end_lsn; txn->commit_time = commit_time; diff --git a/src/test/subscription/t/021_twophase.pl b/src/test/subscription/t/021_twophase.pl index 4404d7b5449e9..f755e748e9caa 100644 --- a/src/test/subscription/t/021_twophase.pl +++ b/src/test/subscription/t/021_twophase.pl @@ -309,6 +309,47 @@ "SELECT count(*) FROM pg_prepared_xacts;"); is($result, qq(0), 'transaction is aborted on subscriber'); +############################### +# Test that an empty prepared transaction is not replicated. +# +# A transaction that is assigned an XID but makes no change decoded by logical +# replication (here, via a row lock) must not be sent to the subscriber. +# Otherwise the subscriber would receive a PREPARE with no preceding BEGIN +# PREPARE and error out, breaking replication. +############################### + +# An empty prepared transaction that is committed. +$node_publisher->safe_psql( + 'postgres', " + BEGIN; + SELECT a FROM tab_full WHERE a = 1 FOR SHARE; + PREPARE TRANSACTION 'test_empty_prepared'; + COMMIT PREPARED 'test_empty_prepared';"); + +# An empty prepared transaction that is rolled back. +$node_publisher->safe_psql( + 'postgres', " + BEGIN; + SELECT a FROM tab_full WHERE a = 1 FOR SHARE; + PREPARE TRANSACTION 'test_empty_prepared'; + ROLLBACK PREPARED 'test_empty_prepared';"); + +# A subsequent normal change must still replicate. Reaching catchup confirms +# the apply worker was not stalled by the empty prepared transactions above. +$node_publisher->safe_psql('postgres', "INSERT INTO tab_full VALUES (31);"); +$node_publisher->wait_for_catchup($appname); + +# The empty transactions must not have been prepared on the subscriber. +$result = $node_subscriber->safe_psql('postgres', + "SELECT count(*) FROM pg_prepared_xacts;"); +is($result, qq(0), 'empty prepared transaction is not replicated'); + +# The subsequent change is visible, so replication is healthy. +$result = $node_subscriber->safe_psql('postgres', + "SELECT count(*) FROM tab_full WHERE a = 31;"); +is($result, qq(1), + 'replication continues after an empty prepared transaction'); + ############################### # copy_data=false and two_phase ############################### From 153ca22a3a71ccaa55f27a4686202bce8f8de759 Mon Sep 17 00:00:00 2001 From: Daniel Gustafsson Date: Tue, 28 Jul 2026 21:52:24 +0200 Subject: [PATCH 30/43] Recheck checksum state before file_copy during CREATE DATABASE The file_copy strategy check in createdb() runs during option validation, before the transaction has an XID and before the pg_database row exists, so the datachecksumsworker launcher can start in that window and see neither the new database nor the transaction creating it. It then raw-copies a template that was not processed yet, and those files stay unchecksummed, failing verification from then on. Recheck the state in CreateDatabaseUsingFileCopy(): the XID is assigned by then, so a launcher starting after this point waits for the transaction and finds the new database, and the copy errors out instead. Add an injection point before the catalog insert to test the window. Backpatch to v19 where online checksums were introduced. Author: Zsolt Parragi Reviewed-by: Daniel Gustafsson Discussion: https://postgr.es/m/CAN4CZFPEBsz8JeY4ixQ1V4ZL_xOY6pJaZS8ZLGH7R+wF--pEtg@mail.gmail.com Backpatch-through: 19 --- src/backend/commands/dbcommands.c | 28 ++++++++ .../modules/test_checksums/t/005_injection.pl | 68 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/backend/commands/dbcommands.c b/src/backend/commands/dbcommands.c index 51dcbd9cace4f..e9766bcd7efc1 100644 --- a/src/backend/commands/dbcommands.c +++ b/src/backend/commands/dbcommands.c @@ -65,6 +65,7 @@ #include "utils/acl.h" #include "utils/builtins.h" #include "utils/fmgroids.h" +#include "utils/injection_point.h" #include "utils/lsyscache.h" #include "utils/pg_locale.h" #include "utils/relmapper.h" @@ -558,6 +559,23 @@ CreateDatabaseUsingFileCopy(Oid src_dboid, Oid dst_dboid, Oid src_tsid, Relation rel; HeapTuple tuple; + /* + * The strategy check in createdb() runs before our transaction has an XID + * and before the pg_database row exists, so the datachecksumsworker + * launcher can start in that window and miss both the new database and + * our transaction, leaving the raw-copied files without checksums. + */ + if (DataChecksumsInProgressOn()) + ereport(ERROR, + errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("create database strategy \"%s\" not allowed when data checksums are being enabled", + "file_copy")); + + /* + * The XID is assigned by now, so a datachecksumsworker launcher starting + * after this point will wait for us and find the new database. + */ + /* * Force a checkpoint before starting the copy. This will force all dirty * buffers, including those of unlogged tables, out to disk, to ensure @@ -1045,6 +1063,14 @@ createdb(ParseState *pstate, const CreatedbStmt *stmt) dbstrategy = CREATEDB_WAL_LOG; else if (pg_strcasecmp(strategy, "file_copy") == 0) { + /* + * If data checksums are being enabled we must not use file_copy + * since it might copy source database which hasn't yet had data + * checksums enabled, and the destination database will be skipped + * as it's expected to have data checksums enabled. Once we have + * an XID assigned this needs to be rechecked, but if can error + * out already we can save a lot of work. + */ if (DataChecksumsInProgressOn()) ereport(ERROR, errcode(ERRCODE_INVALID_PARAMETER_VALUE), @@ -1510,6 +1536,8 @@ createdb(ParseState *pstate, const CreatedbStmt *stmt) tuple = heap_form_tuple(RelationGetDescr(pg_database_rel), new_record, new_record_nulls); + INJECTION_POINT("createdb-before-catalog-insert", NULL); + CatalogTupleInsert(pg_database_rel, tuple); /* diff --git a/src/test/modules/test_checksums/t/005_injection.pl b/src/test/modules/test_checksums/t/005_injection.pl index 7240b93bdd135..34cd47e6c81be 100644 --- a/src/test/modules/test_checksums/t/005_injection.pl +++ b/src/test/modules/test_checksums/t/005_injection.pl @@ -76,5 +76,73 @@ enable_data_checksums($node, wait => 'on'); } +# --------------------------------------------------------------------------- +# Test concurrent CREATE DATABASE which use the file_copy strategy +# + +disable_data_checksums($node, wait => 1); +my $node_loglocation = -s $node->logfile; + +$node->safe_psql('postgres', + "CREATE TABLE t AS SELECT generate_series(1,10000) AS a;"); + +$node->safe_psql('postgres', + "SELECT injection_points_attach('createdb-before-catalog-insert','wait');" +); +$node->safe_psql('postgres', + "SELECT injection_points_attach('datachecksumsworker-fake-temptable-wait','wait');" +); + +# Hold CREATE DATABASE after the strategy check, before its xact is visible. +my $bg = $node->background_psql('postgres'); +$bg->query_until( + qr/starting_create/, q( +\echo starting_create +CREATE DATABASE fcdb TEMPLATE template0 STRATEGY file_copy; +)); +$node->wait_for_event('client backend', 'createdb-before-catalog-insert'); + +# Enable checksums, worker holds before processing template0. +enable_data_checksums($node); +$node->wait_for_event('datachecksums worker', + 'datachecksumsworker-fake-temptable-wait'); + +# Release CREATE DATABASE, must fail on the recheck instead of raw-copying. +$node->safe_psql('postgres', + "SELECT injection_points_wakeup('createdb-before-catalog-insert');"); +$node->safe_psql('postgres', + "SELECT injection_points_detach('createdb-before-catalog-insert');"); + +# Wait for the CREATE DATABASE xact to finish before releasing the worker. +$node->poll_query_until('postgres', + "SELECT count(*) = 0 FROM pg_catalog.pg_stat_activity " + . "WHERE query LIKE 'CREATE DATABASE%' AND state != 'idle';"); + +$node->safe_psql('postgres', + "SELECT injection_points_wakeup('datachecksumsworker-fake-temptable-wait');" +); +$node->safe_psql('postgres', + "SELECT injection_points_detach('datachecksumsworker-fake-temptable-wait');" +); + +wait_for_checksum_state($node, 'on'); + +my $result = $node->safe_psql('postgres', + "SELECT count(*) FROM pg_catalog.pg_database WHERE datname = 'fcdb';"); +is($result, '0', 'file_copy database creation was refused'); + +my $log = + PostgreSQL::Test::Utils::slurp_file($node->logfile, $node_loglocation); +like( + $log, + qr/create database strategy "file_copy" not allowed/m, + 'file_copy error message in log'); + +# --------------------------------------------------------------------------- +# Test teardown +# + +$bg->{run}->finish; +$bg->quit; $node->stop; done_testing(); From 51f55b13a4d3621244b3645a506b006decca0ec2 Mon Sep 17 00:00:00 2001 From: Daniel Gustafsson Date: Tue, 28 Jul 2026 21:52:27 +0200 Subject: [PATCH 31/43] Handle invalid and dropped databases during checksum enable Enable errors out early with a hint when an invalid database exists, since the worker cannot connect to it and its files stay on disk. A worker that started but failed gets the same dropped-database heuristic as one that failed to start, so a concurrent drop during processing no longer aborts the whole run. The existence check locks the database first, otherwise a DROP DATABASE ... WITH (FORCE) which killed the worker is still only halfway done and the database looks like it is there to stay. Backpatch to v19 where online checksums were introduced. Author: Zsolt Parragi Reviewed-by: Daniel Gustafsson Discussion: https://postgr.es/m/CAN4CZFOGdqxtZ5-6gb4apqmvoH=Z+TNH8RKJ3mVtoR1HirKQWg@mail.gmail.com Backpatch-through: 19 --- src/backend/postmaster/datachecksum_state.c | 71 ++++++++++ .../modules/test_checksums/t/001_basic.pl | 121 ++++++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/src/backend/postmaster/datachecksum_state.c b/src/backend/postmaster/datachecksum_state.c index 73dc539836b01..fc082ac37b9ec 100644 --- a/src/backend/postmaster/datachecksum_state.c +++ b/src/backend/postmaster/datachecksum_state.c @@ -385,6 +385,7 @@ static void StartDataChecksumsWorkerLauncher(DataChecksumsWorkerOperation op, int cost_limit); static void DataChecksumsShmemRequest(void *arg); static bool DatabaseExists(Oid dboid); +static void ErrorOnInvalidDatabases(void); static List *BuildDatabaseList(void); static List *BuildRelationList(bool temp_relations, bool include_shared); static void FreeDatabaseList(List *dblist); @@ -583,6 +584,15 @@ enable_data_checksums(PG_FUNCTION_ARGS) errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("cost limit must be greater than zero")); + /* + * An invalid database cannot be connected to, so the worker would fail to + * process it, and unlike a dropped database its files stay around. Error + * out early with a hint rather than failing halfway through processing. A + * database which turns invalid after this check is handled by the + * launcher treating it as concurrently dropped. + */ + ErrorOnInvalidDatabases(); + StartDataChecksumsWorkerLauncher(ENABLE_DATACHECKSUMS, cost_delay, cost_limit); PG_RETURN_VOID(); @@ -984,6 +994,17 @@ ProcessDatabase(DataChecksumsWorkerDatabase *db) DataChecksumState->worker_pid = InvalidPid; LWLockRelease(DataChecksumsWorkerLock); + /* + * A worker which started but failed before reporting a result has most + * likely FATALed in InitPostgres. If the database was dropped, or was + * invalidated by a DROP DATABASE which is bound to remove its files, + * after we built the database list then that is the expected outcome and + * not an error, so apply the same heuristic as when the worker failed to + * start. + */ + if (result == DATACHECKSUMSWORKER_FAILED && !DatabaseExists(db->dboid)) + result = DATACHECKSUMSWORKER_DROPDB; + if (result == DATACHECKSUMSWORKER_ABORTED) ereport(LOG, errmsg("data checksums processing was aborted in database \"%s\"", @@ -1410,6 +1431,15 @@ DatabaseExists(Oid dboid) StartTransactionCommand(); + /* + * DROP DATABASE holds an exclusive lock on the database from before it + * terminates the connections to it until it commits, so take a lock which + * conflicts with it to wait out a drop which is in flight. Without this + * we can see a database whose worker was just killed by DROP DATABASE ... + * WITH (FORCE) as still existing, and report a spurious failure. + */ + LockSharedObject(DatabaseRelationId, dboid, 0, AccessShareLock); + rel = table_open(DatabaseRelationId, AccessShareLock); ScanKeyInit(&skey, Anum_pg_database_oid, @@ -1436,6 +1466,47 @@ DatabaseExists(Oid dboid) return found; } +/* + * ErrorOnInvalidDatabases + * Error out if the cluster contains an invalid database + * + * A database left invalid by an interrupted DROP DATABASE cannot be connected + * to, so data checksums can never be enabled in it, while its files remain on + * disk where checksum verification will find them. Report it to the caller + * so the user can drop it before retrying. Called from a normal backend, so + * unlike DatabaseExists we are already in a transaction. + * + * A cluster can contain more than one invalid database, but only the first one + * found is reported; collecting them all is not worth the complexity here. A + * user with several of them gets the error again for the next one after + * dropping the reported database, which the hint accounts for. + */ +static void +ErrorOnInvalidDatabases(void) +{ + Relation rel; + TableScanDesc scan; + HeapTuple tup; + + rel = table_open(DatabaseRelationId, AccessShareLock); + scan = table_beginscan_catalog(rel, 0, NULL); + + while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection))) + { + Form_pg_database pgdb = (Form_pg_database) GETSTRUCT(tup); + + if (database_is_invalid_form(pgdb)) + ereport(ERROR, + errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("cannot enable data checksums in a cluster with invalid database \"%s\"", + NameStr(pgdb->datname)), + errhint("Use DROP DATABASE to drop invalid databases.")); + } + + table_endscan(scan); + table_close(rel, AccessShareLock); +} + /* * BuildDatabaseList * Compile a list of all currently available databases in the cluster diff --git a/src/test/modules/test_checksums/t/001_basic.pl b/src/test/modules/test_checksums/t/001_basic.pl index a78118320d551..72e0d0df46f72 100644 --- a/src/test/modules/test_checksums/t/001_basic.pl +++ b/src/test/modules/test_checksums/t/001_basic.pl @@ -59,5 +59,126 @@ $result = $node->safe_psql('postgres', "SELECT count(*) FROM t WHERE a > 1"); is($result, '10000', 'ensure checksummed pages can be read back'); +# Enabling checksums in a cluster which contains an invalid database left +# behind by an interrupted DROP DATABASE must be refused. +disable_data_checksums($node, wait => 1); + +$node->safe_psql('postgres', "CREATE DATABASE baddb;"); +$node->safe_psql('baddb', + "CREATE TABLE bad_t AS SELECT generate_series(1,100) AS a;"); + +# Mark the database invalid, as an interrupted DROP DATABASE would. +$node->safe_psql('postgres', + "UPDATE pg_database SET datconnlimit = -2 WHERE datname = 'baddb';"); + +# The request must fail up front with an actionable error, rather than fail +# halfway through processing. +my ($ret, $stdout, $stderr) = + $node->psql('postgres', "SELECT pg_enable_data_checksums();"); +isnt($ret, 0, 'pg_enable_data_checksums fails with an invalid database'); +like( + $stderr, + qr/invalid database "baddb"/, + 'error message names the invalid database'); +like( + $stderr, + qr/DROP DATABASE/, + 'error message hints at dropping the database'); +test_checksum_state($node, 'off'); + +# Dropping the invalid database clears the way. +$node->safe_psql('postgres', "DROP DATABASE baddb;"); +enable_data_checksums($node, wait => 'on'); + +# A database dropped while processing is in progress is not an error, the +# remaining databases are still processed. +disable_data_checksums($node, wait => 1); + +$node->safe_psql('postgres', "CREATE DATABASE dropme;"); +$node->safe_psql('dropme', + "CREATE TABLE dropme_t AS SELECT generate_series(1,10000) AS a;"); + +# Hold the worker in the "postgres" database by keeping a temporary table +# around, the worker waits for pre-existing temp tables to disappear before +# it reports the database as processed. "dropme" was created last, so it is +# processed after "postgres" and is still untouched while we wait. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('CREATE TEMP TABLE holdme (a int);'); + +enable_data_checksums($node); + +$node->poll_query_until( + 'postgres', qq[ + SELECT count(*) > 0 FROM pg_stat_activity + WHERE backend_type = 'datachecksums worker' AND datname = 'postgres' + AND query LIKE 'Waiting for % temp tables to be removed'] +) or die "timed out waiting for worker to wait for temporary tables"; + +# Verify the assumption that processing has not reached "dropme" yet, without +# it the test would silently stop covering the concurrent drop. +my $log = slurp_file($node->logfile); +unlike( + $log, + qr/initiating data checksum processing in database "dropme"/, + 'processing has not reached the database to drop'); + +# Not processed yet and nobody is connected to it, so this must succeed. +$node->safe_psql('postgres', "DROP DATABASE dropme;"); + +# Let the worker in "postgres" finish, the launcher then moves on to the +# database which no longer exists. +$bg->query_safe('DROP TABLE holdme;'); +$bg->quit; + +wait_for_checksum_state($node, 'on'); +$node->poll_query_until('postgres', + "SELECT count(*) = 0 " + . "FROM pg_catalog.pg_stat_activity " + . "WHERE backend_type = 'datachecksums launcher';"); + +# Same thing with DROP DATABASE ... WITH (FORCE), which terminates the +# checksums worker connected to the database being dropped. +disable_data_checksums($node, wait => 1); + +$node->safe_psql('postgres', "CREATE DATABASE dropmeforce;"); +$node->safe_psql('dropmeforce', + "CREATE TABLE dropme_t AS SELECT generate_series(1,10000) AS a;"); + +# Hold the worker inside "dropmeforce" by keeping a temporary table around +# there. +$bg = $node->background_psql('dropmeforce'); +$bg->query_safe('CREATE TEMP TABLE holdme (a int);'); + +enable_data_checksums($node); + +$node->poll_query_until( + 'postgres', qq[ + SELECT count(*) > 0 FROM pg_stat_activity + WHERE backend_type = 'datachecksums worker' AND datname = 'dropmeforce' + AND query LIKE 'Waiting for % temp tables to be removed'] +) or die "timed out waiting for worker to wait for temporary tables"; + +# Terminates both the session holding the temp table and the checksums +# worker connected to the database. +$node->safe_psql('postgres', "DROP DATABASE dropmeforce WITH (FORCE);"); +$bg->{run}->finish; +$bg->quit; + +wait_for_checksum_state($node, 'on'); +$node->poll_query_until('postgres', + "SELECT count(*) = 0 " + . "FROM pg_catalog.pg_stat_activity " + . "WHERE backend_type = 'datachecksums launcher';"); + +$result = $node->safe_psql('postgres', "SELECT count(*) FROM t WHERE a > 1"); +is($result, '10000', 'ensure checksummed pages can be read back'); + $node->stop; + +# The resulting cluster must also pass offline verification, proving no +# unchecksummed files were left behind. +command_ok( + [ 'pg_checksums', '--check', '-D', $node->data_dir ], + 'offline checksum verification passes after enable'); + done_testing(); From 239eabda41e39de73c376000ba74bbeb8fe32a5c Mon Sep 17 00:00:00 2001 From: Tom Lane Date: Tue, 28 Jul 2026 16:08:46 -0400 Subject: [PATCH 32/43] Fix planner's nullability/strictness logic for ScalarArrayOpExpr. find_nonnullable_rels and find_nonnullable_vars mistakenly treated a ScalarArrayOpExpr that could return FALSE as strict, but that's okay only at top level of a qual expression; further down, we've got to insist on a guaranteed-NULL result. The result was that we could draw mistaken conclusions about whether outer joins can be simplified, if the decision hinged on a non-top-level ScalarArrayOpExpr with a potentially-empty array argument. I believe this error dates to commit 72a070a36, which taught find_nonnullable_rels to descend into non-top-level parts of qual expressions. is_strict_saop (added earlier by 72153c058) already had enough intelligence to do the case correctly, but it wasn't passed the proper flag, ie "top_level" needs to be passed for "falseOK". e006a24ad copied that mistake into find_nonnullable_vars. Later, over-eager refactoring in commit 2f153ddfd broke contain_nonstrict_functions' handling of ScalarArrayOpExpr by treating it as though it were no different from an OpExpr. It is, because we must also prove the array is non-empty before concluding that the expression is strict. This could result in misclassifying an expression as strict when it is not, leading to assorted planning mistakes such as inlining a SQL function that shouldn't be inlined. We can almost fix this by just re-adding the previous handling of ScalarArrayOpExpr in that function, but doing only that would lead to also calling check_functions_in_node() and thus redundantly checking the operator's strictness. Avoid that by turning the if-series into an else-if chain, as it arguably should have been all along. The reason these errors have escaped detection for decades is that they are exposed only in arcane corner cases. ScalarArrayOpExpr with an empty array isn't typical usage, and even when that's possible several other conditions apply before the planner can reach a mistaken conclusion. While it's possible to build test cases demonstrating these mistakes, I (tgl) judged them too indirect and special-purpose to justify consuming regression test cycles forevermore. Author: Ayush Tiwari Reviewed-by: Tom Lane Discussion: https://postgr.es/m/CAJTYsWV3vqRJmST-gv1NsXEef-zOnjVJpYS910aBaiuMij4nFg@mail.gmail.com Discussion: https://postgr.es/m/CAJTYsWWcLGmz0f8_QPP_Liq-fc7-geiFSCdqoq3XGeRHPPsWeA@mail.gmail.com Backpatch-through: 14 --- src/backend/optimizer/util/clauses.c | 74 +++++++++++++++------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c index aa8886ec210e9..7d7f2f9664bb1 100644 --- a/src/backend/optimizer/util/clauses.c +++ b/src/backend/optimizer/util/clauses.c @@ -1040,7 +1040,7 @@ contain_nonstrict_functions_walker(Node *node, void *context) /* an aggregate could return non-null with null input */ return true; } - if (IsA(node, GroupingFunc)) + else if (IsA(node, GroupingFunc)) { /* * A GroupingFunc doesn't evaluate its arguments, and therefore must @@ -1048,12 +1048,12 @@ contain_nonstrict_functions_walker(Node *node, void *context) */ return true; } - if (IsA(node, WindowFunc)) + else if (IsA(node, WindowFunc)) { /* a window function could return non-null with null input */ return true; } - if (IsA(node, SubscriptingRef)) + else if (IsA(node, SubscriptingRef)) { SubscriptingRef *sbsref = (SubscriptingRef *) node; const SubscriptRoutines *sbsroutines; @@ -1067,17 +1067,25 @@ contain_nonstrict_functions_walker(Node *node, void *context) return true; /* else fall through to check args */ } - if (IsA(node, DistinctExpr)) + else if (IsA(node, DistinctExpr)) { /* IS DISTINCT FROM is inherently non-strict */ return true; } - if (IsA(node, NullIfExpr)) + else if (IsA(node, NullIfExpr)) { /* NULLIF is inherently non-strict */ return true; } - if (IsA(node, BoolExpr)) + else if (IsA(node, ScalarArrayOpExpr)) + { + ScalarArrayOpExpr *expr = (ScalarArrayOpExpr *) node; + + if (!is_strict_saop(expr, false)) + return true; + /* else fall through to check args */ + } + else if (IsA(node, BoolExpr)) { BoolExpr *expr = (BoolExpr *) node; @@ -1091,28 +1099,26 @@ contain_nonstrict_functions_walker(Node *node, void *context) break; } } - if (IsA(node, SubLink)) + else if (IsA(node, SubLink)) { /* In some cases a sublink might be strict, but in general not */ return true; } - if (IsA(node, SubPlan)) + else if (IsA(node, SubPlan)) return true; - if (IsA(node, AlternativeSubPlan)) + else if (IsA(node, AlternativeSubPlan)) return true; - if (IsA(node, FieldStore)) + else if (IsA(node, FieldStore)) return true; - if (IsA(node, CoerceViaIO)) + else if (IsA(node, CoerceViaIO)) { /* * CoerceViaIO is strict regardless of whether the I/O functions are, - * so just go look at its argument; asking check_functions_in_node is - * useless expense and could deliver the wrong answer. + * so we should skip check_functions_in_node() and just fall through + * to check the arguments. */ - return contain_nonstrict_functions_walker((Node *) ((CoerceViaIO *) node)->arg, - context); } - if (IsA(node, ArrayCoerceExpr)) + else if (IsA(node, ArrayCoerceExpr)) { /* * ArrayCoerceExpr is strict at the array level, regardless of what @@ -1122,31 +1128,33 @@ contain_nonstrict_functions_walker(Node *node, void *context) return contain_nonstrict_functions_walker((Node *) ((ArrayCoerceExpr *) node)->arg, context); } - if (IsA(node, CaseExpr)) + else if (IsA(node, CaseExpr)) return true; - if (IsA(node, ArrayExpr)) + else if (IsA(node, ArrayExpr)) return true; - if (IsA(node, RowExpr)) + else if (IsA(node, RowExpr)) return true; - if (IsA(node, RowCompareExpr)) + else if (IsA(node, RowCompareExpr)) return true; - if (IsA(node, CoalesceExpr)) + else if (IsA(node, CoalesceExpr)) return true; - if (IsA(node, MinMaxExpr)) + else if (IsA(node, MinMaxExpr)) return true; - if (IsA(node, XmlExpr)) + else if (IsA(node, XmlExpr)) return true; - if (IsA(node, NullTest)) - return true; - if (IsA(node, BooleanTest)) + else if (IsA(node, NullTest)) return true; - if (IsA(node, JsonConstructorExpr)) + else if (IsA(node, BooleanTest)) return true; - - /* Check other function-containing nodes */ - if (check_functions_in_node(node, contain_nonstrict_functions_checker, - context)) + else if (IsA(node, JsonConstructorExpr)) return true; + else + { + /* Check other function-containing nodes */ + if (check_functions_in_node(node, contain_nonstrict_functions_checker, + context)) + return true; + } return expression_tree_walker(node, contain_nonstrict_functions_walker, context); @@ -1546,7 +1554,7 @@ find_nonnullable_rels_walker(Node *node, bool top_level) { ScalarArrayOpExpr *expr = (ScalarArrayOpExpr *) node; - if (is_strict_saop(expr, true)) + if (is_strict_saop(expr, top_level)) result = find_nonnullable_rels_walker((Node *) expr->args, false); } else if (IsA(node, BoolExpr)) @@ -1799,7 +1807,7 @@ find_nonnullable_vars_walker(Node *node, bool top_level) { ScalarArrayOpExpr *expr = (ScalarArrayOpExpr *) node; - if (is_strict_saop(expr, true)) + if (is_strict_saop(expr, top_level)) result = find_nonnullable_vars_walker((Node *) expr->args, false); } else if (IsA(node, BoolExpr)) From dd50eb9145eead17ebd62db2e43a6de7c53102c0 Mon Sep 17 00:00:00 2001 From: Michael Paquier Date: Wed, 29 Jul 2026 08:54:11 +0900 Subject: [PATCH 33/43] Use more strlcpy() in two-phase transaction code This commit replaces two calls of strcpy() and one call of strncpy() to use strlcpy(), which are patterns that static analyzers (mostly LLMs, it seems) have been complaining regarding buffer overflow risks. The existing calls are safe, here are more details for each one of them: - MarkAsPreparingGuts()'s strcpy() was guarded by MarkAsPreparing(). - PrepareRedoAdd()'s strcpy() is safe because the record-level CRC check prevents corrupted data from reaching it unless intentionally crafted. The replay code also assumes that the GID is within the allowed bounds, as WAL records are trusted. - Similarly, ParsePrepareRecord() stores its GID in a buffer bounded by GIDSIZE while trusting the length provided by the record. As a result, these changes are purely cosmetic. They adopt a more defensive coding style and should also silence some of the static analysis reports received recently. Author: Matt Suiche Discussion: https://postgr.es/m/CAGf6Lfx2kbQfcEnCi99V2i65JSWD6ij_E29F+UkY=TyMUyeG6A@mail.gmail.com --- src/backend/access/rmgrdesc/xactdesc.c | 2 +- src/backend/access/transam/twophase.c | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/access/rmgrdesc/xactdesc.c b/src/backend/access/rmgrdesc/xactdesc.c index 4f53d3035cc26..96aa6e17bd50c 100644 --- a/src/backend/access/rmgrdesc/xactdesc.c +++ b/src/backend/access/rmgrdesc/xactdesc.c @@ -256,7 +256,7 @@ ParsePrepareRecord(uint8 info, xl_xact_prepare *xlrec, xl_xact_parsed_prepare *p parsed->nabortstats = xlrec->nabortstats; parsed->nmsgs = xlrec->ninvalmsgs; - strncpy(parsed->twophase_gid, bufptr, xlrec->gidlen); + strlcpy(parsed->twophase_gid, bufptr, GIDSIZE); bufptr += MAXALIGN(xlrec->gidlen); parsed->subxacts = (TransactionId *) bufptr; diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c index 439e28c9987f9..fa3bc50ec483a 100644 --- a/src/backend/access/transam/twophase.c +++ b/src/backend/access/transam/twophase.c @@ -492,7 +492,7 @@ MarkAsPreparingGuts(GlobalTransaction gxact, FullTransactionId fxid, gxact->locking_backend = MyProcNumber; gxact->valid = false; gxact->inredo = false; - strcpy(gxact->gid, gid); + strlcpy(gxact->gid, gid, GIDSIZE); /* * Remember that we have this GlobalTransaction entry locked for us. If we @@ -2597,7 +2597,7 @@ PrepareRedoAdd(FullTransactionId fxid, char *buf, gxact->valid = false; gxact->ondisk = !XLogRecPtrIsValid(start_lsn); gxact->inredo = true; /* yes, added in redo */ - strcpy(gxact->gid, gid); + strlcpy(gxact->gid, gid, GIDSIZE); /* And insert it into the active array */ Assert(TwoPhaseState->numPrepXacts < max_prepared_xacts); From c12c101b0846b1e6488f2dc986a852fbc6bf2e3b Mon Sep 17 00:00:00 2001 From: Amit Kapila Date: Wed, 29 Jul 2026 09:46:53 +0530 Subject: [PATCH 34/43] Avoid accumulating relation locks during sequence synchronization. While collecting the sequences to synchronize, the sequence sync worker opened each INIT sequence with RowExclusiveLock and held it until the transaction committed. With many such sequences, this could exhaust the shared lock table and fail with "out of shared memory". The worker only reads each sequence's identity (namespace and name) here and needs it to stay stable while read, for which AccessShareLock is enough, as it conflicts with the AccessExclusiveLock taken by DROP, RENAME, and SET SCHEMA. Take that lock instead and release it as soon as the identity is read. The later synchronization re-opens each sequence, so it does not rely on the lock being retained. Reported-by: Noah Misch Author: vignesh C Reviewed-by: Hayato Kuroda Reviewed-by: Amit Kapila Backpatch-through: 19, where it was introduced Discussion: https://postgr.es/m/20260710045217.f0.noahmisch@microsoft.com --- src/backend/replication/logical/sequencesync.c | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/backend/replication/logical/sequencesync.c b/src/backend/replication/logical/sequencesync.c index fe506a98c2052..0423745a428a6 100644 --- a/src/backend/replication/logical/sequencesync.c +++ b/src/backend/replication/logical/sequencesync.c @@ -752,7 +752,16 @@ LogicalRepSyncSequences(void) subrel = (Form_pg_subscription_rel) GETSTRUCT(tup); - sequence_rel = try_table_open(subrel->srrelid, RowExclusiveLock); + /* + * Lock the sequence so its identity (namespace and name) cannot + * change under us via a concurrent DROP, RENAME or SET SCHEMA. The + * lock is released immediately rathen than at the transaction end. + * The later synchronization does not depend on this captured identity + * remaining valid, as it re-opens the sequence and tolerates + * concurrent changes. Releasing early also avoids holding one lock + * per sequence, which could exhaust the lock table. + */ + sequence_rel = try_table_open(subrel->srrelid, AccessShareLock); /* Skip if sequence was dropped concurrently */ if (!sequence_rel) @@ -761,7 +770,7 @@ LogicalRepSyncSequences(void) /* Skip if the relation is not a sequence */ if (sequence_rel->rd_rel->relkind != RELKIND_SEQUENCE) { - table_close(sequence_rel, NoLock); + table_close(sequence_rel, AccessShareLock); continue; } @@ -779,7 +788,7 @@ LogicalRepSyncSequences(void) MemoryContextSwitchTo(oldctx); - table_close(sequence_rel, NoLock); + table_close(sequence_rel, AccessShareLock); } /* Cleanup */ From 33b392eaabdd1c563d40388784df051821e03c6b Mon Sep 17 00:00:00 2001 From: Michael Paquier Date: Wed, 29 Jul 2026 17:39:37 +0900 Subject: [PATCH 35/43] Protect PGPROC lookup when terminating background workers TerminateBackgroundWorkersForDatabase() uses BackendPidGetProc() and, until now, accessed fields of the returned PGPROC after releasing ProcArrayLock, including its database OID. If the PGPROC slot is recycled during this window, the database OID being checked may belong to a different backend, causing an unrelated background worker to be terminated. Triggering this bug requires a very narrow race: the background worker identified by BackendPidGetProc() must exit, its PGPROC slot must be released and reused, and only then must TerminateBackgroundWorkersForDatabase() examine the database OID. TerminateBackgroundWorkersForDatabase() holds BackgroundWorkerLock, preventing parallel workers and dynamically registered workers (such as those created by worker_spi) from reusing the slot. As far as I know, the only plausible scenario is a static background worker that exits and is restarted quickly enough to reuse the same PGPROC slot within the race window. In practice, this race is extremely unlikely, still reachable in theory. Oversight in f1e251be80a0. Author: Chao Li Reviewed-by: Aya Iwata Reviewed-by: Haibo Yan Discussion: https://postgr.es/m/78E81763-EA1D-4788-9741-4092BCB997A5@gmail.com Backpatch-through: 19 --- src/backend/postmaster/bgworker.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/backend/postmaster/bgworker.c b/src/backend/postmaster/bgworker.c index f2cffce3ff6d2..3da2a90417fc6 100644 --- a/src/backend/postmaster/bgworker.c +++ b/src/backend/postmaster/bgworker.c @@ -1442,16 +1442,20 @@ TerminateBackgroundWorkersForDatabase(Oid databaseId) if (slot->in_use && (slot->worker.bgw_flags & BGWORKER_INTERRUPTIBLE)) { - PGPROC *proc = BackendPidGetProc(slot->pid); + PGPROC *proc; + pid_t pid = slot->pid; + LWLockAcquire(ProcArrayLock, LW_SHARED); + proc = BackendPidGetProcWithLock(pid); if (proc && proc->databaseId == databaseId) { slot->terminate = true; signal_postmaster = true; elog(DEBUG1, "termination requested for worker (PID %d) on database %u", - (int) slot->pid, databaseId); + (int) pid, databaseId); } + LWLockRelease(ProcArrayLock); } } From 9fa2c1ebd1757db6032cf81b53f973941be77747 Mon Sep 17 00:00:00 2001 From: Daniel Gustafsson Date: Wed, 29 Jul 2026 12:37:02 +0200 Subject: [PATCH 36/43] doc: Add getdatabaseencoding to function docs The getdatabaseencoding function was added in bf00bbb0c494 in 1998 but was never documented. While mostly used in tests, there is no reason not to document it as this function isn't going anywhere and is already used in extensions. Author: Ian Barwick Reviewed-by: Thom Brown Reviewed-by: surya poondla Reviewed-by: Daniel Gustafsson Discussion: https://postgr.es/m/CAB8KJ=ij+pznQGub=DkyJuKL=tC=Q=07qSahTyw7TLb0DdNJsg@mail.gmail.com --- doc/src/sgml/func/func-string.sgml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/doc/src/sgml/func/func-string.sgml b/doc/src/sgml/func/func-string.sgml index 0786573d7be15..fb7408ee01470 100644 --- a/doc/src/sgml/func/func-string.sgml +++ b/doc/src/sgml/func/func-string.sgml @@ -753,6 +753,23 @@ + + + + getdatabaseencoding + + getdatabaseencoding ( ) + name + + + Returns current database encoding name. + + + getdatabaseencoding() + UTF8 + + + From cdbd46f980a8143b6048e59e37169dd3c9e501d8 Mon Sep 17 00:00:00 2001 From: Dmitry Fomin Date: Sun, 7 Jun 2026 20:31:49 +0000 Subject: [PATCH 37/43] wait_event_timing: add --enable-wait-event-timing flag and wait_event_capture GUC Introduce the compile-time option --enable-wait-event-timing (meson: -Dwait_event_timing=true) defining USE_WAIT_EVENT_TIMING, and the runtime GUC wait_event_capture (PGC_SUSET, enum off|stats, default off). This commit is scaffolding only: it wires the flag through both build systems and registers the GUC with check/assign hooks, but adds no instrumentation yet -- later commits in this series add the recording hot path, the SQL surface, and the trace level. In builds compiled without --enable-wait-event-timing, the GUC's check hook rejects any value other than off (downgrading to off with a warning for non-interactive sources), so the variable exists uniformly for tooling but cannot be enabled. --- configure | 32 +++++ configure.ac | 8 ++ meson.build | 1 + meson_options.txt | 3 + src/backend/utils/activity/Makefile | 3 +- src/backend/utils/activity/meson.build | 1 + .../utils/activity/wait_event_timing.c | 109 ++++++++++++++++++ src/backend/utils/misc/guc_parameters.dat | 9 ++ src/backend/utils/misc/guc_tables.c | 1 + src/backend/utils/misc/postgresql.conf.sample | 2 + src/include/pg_config.h.in | 3 + src/include/utils/guc.h | 1 + src/include/utils/guc_hooks.h | 2 + src/include/utils/wait_event_timing.h | 53 +++++++++ src/tools/pgindent/typedefs.list | 1 + 15 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/backend/utils/activity/wait_event_timing.c create mode 100644 src/include/utils/wait_event_timing.h diff --git a/configure b/configure index d42a7a794ff0c..81c0721e39e66 100755 --- a/configure +++ b/configure @@ -850,6 +850,7 @@ enable_debug enable_profiling enable_coverage enable_dtrace +enable_wait_event_timing enable_tap_tests enable_injection_points with_blocksize @@ -1551,6 +1552,8 @@ Optional Features: --enable-profiling build with profiling enabled --enable-coverage build with coverage testing instrumentation --enable-dtrace build with DTrace support + --enable-wait-event-timing + build with wait event timing instrumentation --enable-tap-tests enable TAP tests (requires Perl and IPC::Run) --enable-injection-points enable injection points (for testing) @@ -3633,6 +3636,35 @@ fi +# +# --enable-wait-event-timing adds wait event timing instrumentation +# + + +# Check whether --enable-wait-event-timing was given. +if test "${enable_wait_event_timing+set}" = set; then : + enableval=$enable_wait_event_timing; + case $enableval in + yes) + +$as_echo "#define USE_WAIT_EVENT_TIMING 1" >>confdefs.h + + ;; + no) + : + ;; + *) + as_fn_error $? "no argument expected for --enable-wait-event-timing option" "$LINENO" 5 + ;; + esac + +else + enable_wait_event_timing=no + +fi + + + # # TAP tests # diff --git a/configure.ac b/configure.ac index a331749fcb5f8..b2397d57ea3e0 100644 --- a/configure.ac +++ b/configure.ac @@ -225,6 +225,14 @@ fi AC_SUBST(DTRACEFLAGS)]) AC_SUBST(enable_dtrace) +# +# --enable-wait-event-timing adds wait event timing instrumentation +# +PGAC_ARG_BOOL(enable, wait-event-timing, no, + [build with wait event timing instrumentation], + [AC_DEFINE([USE_WAIT_EVENT_TIMING], 1, + [Define to 1 to build with wait event timing. (--enable-wait-event-timing)])]) + # # TAP tests # diff --git a/meson.build b/meson.build index f4cde2492423b..9abfa4144cb8c 100644 --- a/meson.build +++ b/meson.build @@ -505,6 +505,7 @@ meson_bin = find_program(meson_binpath, native: true) cdata.set('USE_ASSERT_CHECKING', get_option('cassert') ? 1 : false) cdata.set('USE_INJECTION_POINTS', get_option('injection_points') ? 1 : false) +cdata.set('USE_WAIT_EVENT_TIMING', get_option('wait_event_timing') ? 1 : false) blocksize = get_option('blocksize').to_int() * 1024 diff --git a/meson_options.txt b/meson_options.txt index 6a793f3e47943..1f191d3a9d621 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -40,6 +40,9 @@ option('pgport', type: 'integer', value: 5432, option('cassert', type: 'boolean', value: false, description: 'Enable assertion checks (for debugging)') +option('wait_event_timing', type: 'boolean', value: false, + description: 'Enable wait event timing instrumentation') + option('tap_tests', type: 'feature', value: 'auto', description: 'Enable TAP tests') diff --git a/src/backend/utils/activity/Makefile b/src/backend/utils/activity/Makefile index 5fed953c28a7f..1c824e9b78832 100644 --- a/src/backend/utils/activity/Makefile +++ b/src/backend/utils/activity/Makefile @@ -36,7 +36,8 @@ OBJS = \ pgstat_wal.o \ pgstat_xact.o \ wait_event.o \ - wait_event_funcs.o + wait_event_funcs.o \ + wait_event_timing.o # Force these dependencies to be known even without dependency info built: wait_event.o: wait_event.c $(top_builddir)/src/backend/utils/pgstat_wait_event.c diff --git a/src/backend/utils/activity/meson.build b/src/backend/utils/activity/meson.build index 470b5dac402bd..13a85bb0d6a6e 100644 --- a/src/backend/utils/activity/meson.build +++ b/src/backend/utils/activity/meson.build @@ -20,6 +20,7 @@ backend_sources += files( 'pgstat_subscription.c', 'pgstat_wal.c', 'pgstat_xact.c', + 'wait_event_timing.c', ) # this includes a .c file with contents generated in ../../../include/activity, diff --git a/src/backend/utils/activity/wait_event_timing.c b/src/backend/utils/activity/wait_event_timing.c new file mode 100644 index 0000000000000..d11823adc57fe --- /dev/null +++ b/src/backend/utils/activity/wait_event_timing.c @@ -0,0 +1,109 @@ +/*------------------------------------------------------------------------- + * + * wait_event_timing.c + * Per-backend wait event timing instrumentation. + * + * Controlled by the wait_event_capture GUC (off | stats, default off) + * and the compile-time option --enable-wait-event-timing. + * + * This commit provides only the GUC scaffolding: the backing variable, + * the enum-value table consumed by guc.c, and the check/assign hooks. + * No instrumentation is performed yet -- later commits in the series add + * the recording hot path and the SQL surface. The file compiles in both + * build configurations; in builds without --enable-wait-event-timing the + * check hook rejects any value other than off. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/backend/utils/activity/wait_event_timing.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "utils/guc.h" +#include "utils/guc_hooks.h" +#include "utils/wait_event_timing.h" + +/* + * GUC variable -- always defined so the GUC system has a backing variable + * even when compiled without --enable-wait-event-timing. In stub builds + * the check hook below rejects any value other than OFF. + */ +int wait_event_capture = WAIT_EVENT_CAPTURE_OFF; + +/* + * Enum value table consumed by guc.c. Order matches the + * WaitEventCaptureLevel enum and the documented "off < stats" ordering. + */ +const struct config_enum_entry wait_event_capture_options[] = { + {"off", WAIT_EVENT_CAPTURE_OFF, false}, + {"stats", WAIT_EVENT_CAPTURE_STATS, false}, + {NULL, 0, false} +}; + +#ifdef USE_WAIT_EVENT_TIMING + +/* + * GUC check hook for wait_event_capture (timing build). + * + * All enum values are accepted at this level. Side effects (attaching + * storage, etc.) are introduced by later commits; for now there is + * nothing to validate beyond the enum mapping that guc.c already did. + */ +bool +check_wait_event_capture(int *newval, void **extra, GucSource source) +{ + return true; +} + +/* + * GUC assign hook for wait_event_capture (timing build). + * + * No-op for now. Later commits use this hook to drop in-flight wait + * state and manage per-session resources when the capture level changes. + */ +void +assign_wait_event_capture(int newval, void *extra) +{ +} + +#else /* !USE_WAIT_EVENT_TIMING */ + +/* + * GUC check hook for the stub build. Any value other than 'off' is + * meaningless without --enable-wait-event-timing, so reject it -- or + * downgrade to 'off' with a warning when the value comes from a + * non-interactive source (config file at startup), so a leftover setting + * does not prevent the server from starting. + */ +bool +check_wait_event_capture(int *newval, void **extra, GucSource source) +{ + if (*newval != WAIT_EVENT_CAPTURE_OFF) + { + if (source < PGC_S_INTERACTIVE) + { + ereport(WARNING, + (errmsg("wait_event_capture is not supported by this build, " + "forcing to \"off\""), + errhint("Compile PostgreSQL with " + "--enable-wait-event-timing."))); + *newval = WAIT_EVENT_CAPTURE_OFF; + return true; + } + GUC_check_errdetail("This build does not support wait event capture."); + GUC_check_errhint("Compile PostgreSQL with --enable-wait-event-timing."); + return false; + } + return true; +} + +/* Stub assign hook -- nothing to do without compile-time support. */ +void +assign_wait_event_capture(int newval, void *extra) +{ +} + +#endif /* USE_WAIT_EVENT_TIMING */ diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index adb72361ce0bb..7e511aaedf94d 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -3437,6 +3437,15 @@ boot_val => 'true', }, +{ name => 'wait_event_capture', type => 'enum', context => 'PGC_SUSET', group => 'STATS_CUMULATIVE', + short_desc => 'Controls collection of per-wait-event timing statistics.', + variable => 'wait_event_capture', + boot_val => 'WAIT_EVENT_CAPTURE_OFF', + options => 'wait_event_capture_options', + check_hook => 'check_wait_event_capture', + assign_hook => 'assign_wait_event_capture', +}, + { name => 'wal_block_size', type => 'int', context => 'PGC_INTERNAL', group => 'PRESET_OPTIONS', short_desc => 'Shows the block size in the write ahead log.', flags => 'GUC_NOT_IN_SAMPLE | GUC_DISALLOW_IN_FILE', diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c index 1ec460b6a8236..cf36670c127dd 100644 --- a/src/backend/utils/misc/guc_tables.c +++ b/src/backend/utils/misc/guc_tables.c @@ -103,6 +103,7 @@ #include "utils/plancache.h" #include "utils/ps_status.h" #include "utils/rls.h" +#include "utils/wait_event_timing.h" #include "utils/xml.h" #ifdef TRACE_SYNCSCAN diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index 7958653077b16..16043d74b4470 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -704,6 +704,8 @@ #track_cost_delay_timing = off #track_io_timing = off #track_wal_io_timing = off +#wait_event_capture = off # off, stats + # (requires --enable-wait-event-timing) #track_functions = none # none, pl, all #stats_fetch_consistency = cache # cache, none, snapshot diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in index 661c4a9b1684f..5be94b7c28b9a 100644 --- a/src/include/pg_config.h.in +++ b/src/include/pg_config.h.in @@ -759,6 +759,9 @@ /* Define to select unnamed POSIX semaphores. */ #undef USE_UNNAMED_POSIX_SEMAPHORES +/* Define to 1 to build with wait event timing. (--enable-wait-event-timing) */ +#undef USE_WAIT_EVENT_TIMING + /* Define to select Win32-style semaphores. */ #undef USE_WIN32_SEMAPHORES diff --git a/src/include/utils/guc.h b/src/include/utils/guc.h index 8057d7870adb4..285be02ef58dc 100644 --- a/src/include/utils/guc.h +++ b/src/include/utils/guc.h @@ -347,6 +347,7 @@ extern PGDLLIMPORT const struct config_enum_entry dynamic_shared_memory_options[ extern PGDLLIMPORT const struct config_enum_entry io_method_options[]; extern PGDLLIMPORT const struct config_enum_entry recovery_target_action_options[]; extern PGDLLIMPORT const struct config_enum_entry server_message_level_options[]; +extern PGDLLIMPORT const struct config_enum_entry wait_event_capture_options[]; extern PGDLLIMPORT const struct config_enum_entry wal_level_options[]; extern PGDLLIMPORT const struct config_enum_entry wal_sync_method_options[]; diff --git a/src/include/utils/guc_hooks.h b/src/include/utils/guc_hooks.h index 6a76f8d5ed6cc..92bbea1fa3bd9 100644 --- a/src/include/utils/guc_hooks.h +++ b/src/include/utils/guc_hooks.h @@ -169,6 +169,8 @@ extern bool check_transaction_isolation(int *newval, void **extra, GucSource sou extern bool check_transaction_read_only(bool *newval, void **extra, GucSource source); extern void assign_transaction_timeout(int newval, void *extra); extern const char *show_unix_socket_permissions(void); +extern bool check_wait_event_capture(int *newval, void **extra, GucSource source); +extern void assign_wait_event_capture(int newval, void *extra); extern bool check_wal_buffers(int *newval, void **extra, GucSource source); extern bool check_wal_consistency_checking(char **newval, void **extra, GucSource source); diff --git a/src/include/utils/wait_event_timing.h b/src/include/utils/wait_event_timing.h new file mode 100644 index 0000000000000..b44d19ecf355e --- /dev/null +++ b/src/include/utils/wait_event_timing.h @@ -0,0 +1,53 @@ +/*------------------------------------------------------------------------- + * + * wait_event_timing.h + * Per-backend wait event timing instrumentation. + * + * This header declares the public surface of the wait-event-timing + * feature, gated by the compile-time option --enable-wait-event-timing + * (USE_WAIT_EVENT_TIMING) and the runtime GUC wait_event_capture. + * + * This commit introduces only the scaffolding: the capture-level enum + * and the GUC backing variable. Later commits in the series add the + * recording hot path, the SQL-visible statistics, and the per-session + * trace ring. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * src/include/utils/wait_event_timing.h + *------------------------------------------------------------------------- + */ +#ifndef WAIT_EVENT_TIMING_H +#define WAIT_EVENT_TIMING_H + +#include "c.h" + +/* + * Capture levels for the wait_event_capture GUC. Order is significant: + * higher values are strict supersets of lower ones, so code paths can + * test for activation with "level >= WAIT_EVENT_CAPTURE_STATS". + * + * OFF - No instrumentation, no hot-path cost. + * STATS - Aggregated per-event statistics (added by a later commit). + * + * A further TRACE level is added later in the series. + */ +typedef enum WaitEventCaptureLevel +{ + WAIT_EVENT_CAPTURE_OFF = 0, + WAIT_EVENT_CAPTURE_STATS, +} WaitEventCaptureLevel; + +/* + * Pin the enum ordering at compile time so future code that compares with + * >= against WAIT_EVENT_CAPTURE_STATS keeps working, and so reordering is + * caught at build time rather than via mysterious runtime mode switches. + */ +StaticAssertDecl(WAIT_EVENT_CAPTURE_OFF == 0 && + WAIT_EVENT_CAPTURE_STATS == 1, + "WaitEventCaptureLevel values must be 0=OFF < 1=STATS"); + +/* GUC variable (defined in wait_event_timing.c, even in stub builds). */ +extern PGDLLIMPORT int wait_event_capture; + +#endif /* WAIT_EVENT_TIMING_H */ diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 85d989f395d41..95c3be5dc57a0 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -3421,6 +3421,7 @@ WaitEvent WaitEventActivity WaitEventBuffer WaitEventClient +WaitEventCaptureLevel WaitEventCustomEntryByInfo WaitEventCustomEntryByName WaitEventIO From 1f194d66ebe08bc8d667a5c66e393445baed8e89 Mon Sep 17 00:00:00 2001 From: Dmitry Fomin Date: Mon, 8 Jun 2026 20:29:18 +0000 Subject: [PATCH 38/43] wait_event_timing: record per-backend wait event statistics (stats level) Implement wait_event_capture = stats. When enabled, every transition through pgstat_report_wait_start()/pgstat_report_wait_end() records the wait duration and accumulates per-(backend, event) statistics -- count, total and maximum duration, and a 32-bucket log2 duration histogram -- in shared memory. Storage is a per-backend slot array in the main shared memory segment, sized at postmaster start from wait_event_timing_max_tranches. Because each slot lives for the entire life of its backend, the hot path needs no lazy attach and no teardown gating: pgstat_report_wait_start_timing() records a timestamp and the current event, and pgstat_report_wait_end_timing() computes the duration and accumulates. Each backend writes only to its own slot, so no locking is required and the SRF reader is lock-free. The inline gate in pgstat_report_wait_start()/_end() is a single load of wait_event_capture plus a branch, with the bodies kept out-of-line so the many inlined call sites stay compact; while capture is off the hot path adds only that branch. Non-LWLock wait events map to a flat array via a class table generated from wait_event_names.txt by generate-wait_event_types.pl. LWLock events, whose tranche ids are unbounded, use a per-backend open-addressing hash capped by wait_event_timing_max_tranches (PGC_POSTMASTER, default 192). SQL surface: - pg_stat_get_wait_event_timing(pid) and the pg_stat_wait_event_timing view, one row per backend per event with a non-zero count; - pg_wait_event_timing_histogram_buckets, naming the 32 histogram bins. Builds compiled without --enable-wait-event-timing keep the GUC (its check hook rejects any non-off value) and empty-result SQL stubs, so tooling sees a uniform surface. A later commit in the series exposes the per-backend overflow counters (maintained here) and adds the reset functions. --- doc/src/sgml/config.sgml | 62 ++ doc/src/sgml/monitoring.sgml | 268 +++++++ src/backend/catalog/system_views.sql | 62 ++ src/backend/storage/lmgr/proc.c | 5 + src/backend/utils/.gitignore | 1 + src/backend/utils/Makefile | 9 +- src/backend/utils/activity/Makefile | 1 + .../activity/generate-wait_event_types.pl | 179 +++++ src/backend/utils/activity/wait_event.c | 3 +- .../utils/activity/wait_event_timing.c | 724 +++++++++++++++++- src/backend/utils/misc/guc_parameters.dat | 9 + src/backend/utils/misc/postgresql.conf.sample | 1 + src/include/catalog/pg_proc.dat | 10 + src/include/storage/subsystemlist.h | 1 + src/include/utils/.gitignore | 1 + src/include/utils/meson.build | 4 +- src/include/utils/wait_classes.h | 9 + src/include/utils/wait_event.h | 49 ++ src/include/utils/wait_event_timing.h | 145 +++- src/test/regress/expected/rules.out | 16 + .../regress/expected/wait_event_timing.out | 84 ++ .../regress/expected/wait_event_timing_1.out | 85 ++ src/test/regress/parallel_schedule | 4 + src/test/regress/sql/wait_event_timing.sql | 54 ++ src/tools/pginclude/headerscheck | 2 + src/tools/pgindent/typedefs.list | 4 + 26 files changed, 1737 insertions(+), 55 deletions(-) create mode 100644 src/test/regress/expected/wait_event_timing.out create mode 100644 src/test/regress/expected/wait_event_timing_1.out create mode 100644 src/test/regress/sql/wait_event_timing.sql diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index aa7b1bd75d20e..ae95737a6c7ae 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -9154,6 +9154,68 @@ COPY postgres_log FROM '/full/path/to/logfile.csv' WITH csv; + + wait_event_capture (enum) + + wait_event_capture configuration parameter + + + + + Controls collection of wait event timing instrumentation. Requires + the server to be compiled with + . Possible values are + off (the default) and stats. + + + At stats, the server records per-backend wait + event statistics — counts, total and average durations, and a + log2 duration histogram — visible in the + + pg_stat_wait_event_timing view. + Two clock_gettime()-grade timestamps are taken + around every wait event transition, costing roughly + 40–100 ns each on modern hardware; while + wait_event_capture is off the + hot path adds only a single predictable branch. + + + Only superusers and users with the appropriate SET + privilege can change this setting. Read access to the resulting + statistics is controlled separately by membership in the + pg_read_all_stats + role (which the pg_monitor role inherits). To + delegate the ability to change the setting to a monitoring role, + use GRANT SET ON PARAMETER: + +GRANT SET ON PARAMETER wait_event_capture TO pg_monitor; + + + + + + + wait_event_timing_max_tranches (integer) + + wait_event_timing_max_tranches configuration parameter + + + + + Sets the maximum number of distinct LWLock tranches whose timing is + recorded individually per backend. PostgreSQL maintains a + per-backend hash table mapping each tranche the backend encounters + to its histogram; once the table fills, further tranches are not + individually timed. Sized at server start; this parameter has no + effect on builds compiled without + . The default is + 192; raise it if your installation loads many + extensions that register their own LWLock tranches. This parameter + can only be set at server start. + + + + track_functions (enum) diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml index a209e891b181a..40b52b710b5a7 100644 --- a/doc/src/sgml/monitoring.sgml +++ b/doc/src/sgml/monitoring.sgml @@ -571,6 +571,15 @@ postgres 27093 0.0 0.0 30096 2752 ? Ss 11:34 0:00 postgres: ser + + pg_stat_wait_event_timingpg_stat_wait_event_timing + One row per backend per wait event, showing accumulated timing + statistics. See + + pg_stat_wait_event_timing for details. + + + @@ -3993,6 +4002,265 @@ description | Waiting for a newly initialized WAL file to reach durable storage + + <structname>pg_stat_wait_event_timing</structname> + + + pg_stat_wait_event_timing + + + + The pg_stat_wait_event_timing view contains one + row for each combination of backend and wait event that has a non-zero + call count. It shows accumulated timing statistics collected when + is set to stats. Requires the server to be compiled with + . + + + + Statistics are accumulated in each backend's own shared memory and are + reported only for backends that are currently connected, much like + + pg_stat_activity; a backend's rows + disappear when it disconnects, and they are reset to zero if its process + number is later reused by a new backend. To study a short-lived + workload, query this view while that workload is still running rather + than after it finishes. + + +
+ <structname>pg_stat_wait_event_timing</structname> View + + + + + Column Type + + + Description + + + + + + + + pid integer + + + Process ID of the backend + + + + + + backend_type text + + + Type of the backend (e.g. client backend, + checkpointer, walwriter) + + + + + + procnumber integer + + + Internal slot number (0-based process number) of the backend. + + + + + + wait_event_type text + + + Wait event type (e.g. IO, LWLock, + Timeout) + + + + + + wait_event text + + + Wait event name (e.g. DataFileRead, + WALWrite, PgSleep) + + + + + + calls bigint + + + Number of times this wait event occurred + + + + + + total_time_ms double precision + + + Total time spent in this wait event, in milliseconds + + + + + + avg_time_us double precision + + + Average wait duration, in microseconds + + + + + + max_time_us double precision + + + Maximum single wait duration, in microseconds + + + + + + histogram bigint[] + + + Log2 histogram of wait durations with 32 buckets. Bin edges are + powers of two on the nanosecond axis: bucket 0 covers + [0, 1024) ns, bucket k covers + [2^(k+9), + 2^(k+10)) ns, and the last bucket covers + [2^40, ∞) ns. The boundaries approximate the + decimal-microsecond grid (1024 ns ≈ 1 μs, 2048 ns ≈ + 2 μs, ...); the exact edges are chosen to let the hot path skip + a division by 1000. The + + pg_wait_event_timing_histogram_buckets + view provides the numeric bin edges and human-readable labels for + each index; the canonical join pattern is: + +SELECT w.wait_event, b.label, h.count +FROM pg_stat_wait_event_timing w, + LATERAL unnest(w.histogram) WITH ORDINALITY AS h(count, idx) +JOIN pg_wait_event_timing_histogram_buckets b ON b.bucket_idx = h.idx - 1 +WHERE w.wait_event = 'PgSleep' +ORDER BY b.bucket_idx; + + + + + + +
+ + + + <structname>pg_wait_event_timing_histogram_buckets</structname> + + + pg_wait_event_timing_histogram_buckets + + + + The pg_wait_event_timing_histogram_buckets + view describes the 32 bins used by the + histogram column of + + pg_stat_wait_event_timing. It always + contains 32 rows in ascending order of + bucket_idx, and is independent of runtime + state; a join against it attaches numeric bin edges and human + labels to any histogram array. Bins are powers of two on the + nanosecond axis: bin 0 covers [0, 1us), each + subsequent bin doubles its lower edge, and the final bin + (bucket_idx = 31) is open-ended at + approximately 1024 seconds. + + + + The 32-bin layout (rather than the more common 16-bin choice for + log-scale histograms) is deliberate: real-world wait-event + distributions have long tails routinely extending past 16 ms + into multi-second territory (slow-disk + DataFileRead, lock contention waits, replication + apply waits, vacuum waits). A 16-bin histogram would collapse all + of those into a single overflow bin, hiding the very signal that + wait-event timing exists to surface. The 32-bin layout keeps the + long tail individually addressable up to about 17 minutes + before the open-ended bin; single waits beyond that belong in + auto_explain + or pg_stat_activity, not a histogram. + + + + <structname>pg_wait_event_timing_histogram_buckets</structname> View + + + + + Column Type + + + Description + + + + + + + + bucket_idx integer + + + Zero-based bin index (0–31). Matches the offset into the + histogram array of + pg_stat_wait_event_timing. + + + + + + lower_ns bigint + + + Inclusive lower edge of this bin in nanoseconds. + + + + + + upper_ns bigint + + + Exclusive upper edge of this bin in nanoseconds, or + NULL for the final bin which extends to + infinity. + + + + + + label text + + + Short human-readable label for the bin (e.g. + <1us, 1-2us, + >=1024s), expressed on the approximate + decimal-microsecond grid the bin edges are aligned to. + + + + +
+
+ <structname>pg_stat_database</structname> diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql index 090281a03ddff..640b4dc61da3e 100644 --- a/src/backend/catalog/system_views.sql +++ b/src/backend/catalog/system_views.sql @@ -1568,3 +1568,65 @@ CREATE VIEW pg_aios AS SELECT * FROM pg_get_aios(); REVOKE ALL ON pg_aios FROM PUBLIC; GRANT SELECT ON pg_aios TO pg_read_all_stats; + +-- Taxonomy for the histogram column on pg_stat_wait_event_timing. The +-- histogram array has one entry per bucket, in ascending order. This +-- view names them so callers do not have to memorise the layout; join +-- against it via unnest(histogram) WITH ORDINALITY. +-- +-- WARNING: keep this list in lock-step with WAIT_EVENT_TIMING_HISTOGRAM_BUCKETS +-- and wait_event_timing_bucket() in src/backend/utils/activity/wait_event_timing.c. +-- Bin edges are powers of two in nanoseconds; labels are the approximate +-- decimal-microsecond grid documented in src/include/utils/wait_event_timing.h. +CREATE VIEW pg_wait_event_timing_histogram_buckets AS + SELECT bucket_idx, lower_ns, upper_ns, label + FROM (VALUES + ( 0, 0::bigint, 1024::bigint, '<1us'::text), + ( 1, 1024::bigint, 2048::bigint, '1-2us'), + ( 2, 2048::bigint, 4096::bigint, '2-4us'), + ( 3, 4096::bigint, 8192::bigint, '4-8us'), + ( 4, 8192::bigint, 16384::bigint, '8-16us'), + ( 5, 16384::bigint, 32768::bigint, '16-32us'), + ( 6, 32768::bigint, 65536::bigint, '32-64us'), + ( 7, 65536::bigint, 131072::bigint, '64-128us'), + ( 8, 131072::bigint, 262144::bigint, '128-256us'), + ( 9, 262144::bigint, 524288::bigint, '256-512us'), + (10, 524288::bigint, 1048576::bigint, '512us-1ms'), + (11, 1048576::bigint, 2097152::bigint, '1-2ms'), + (12, 2097152::bigint, 4194304::bigint, '2-4ms'), + (13, 4194304::bigint, 8388608::bigint, '4-8ms'), + (14, 8388608::bigint, 16777216::bigint, '8-16ms'), + (15, 16777216::bigint, 33554432::bigint, '16-32ms'), + (16, 33554432::bigint, 67108864::bigint, '32-64ms'), + (17, 67108864::bigint, 134217728::bigint, '64-128ms'), + (18, 134217728::bigint, 268435456::bigint, '128-256ms'), + (19, 268435456::bigint, 536870912::bigint, '256-512ms'), + (20, 536870912::bigint, 1073741824::bigint, '512ms-1s'), + (21, 1073741824::bigint, 2147483648::bigint, '1-2s'), + (22, 2147483648::bigint, 4294967296::bigint, '2-4s'), + (23, 4294967296::bigint, 8589934592::bigint, '4-8s'), + (24, 8589934592::bigint, 17179869184::bigint, '8-16s'), + (25, 17179869184::bigint, 34359738368::bigint, '16-32s'), + (26, 34359738368::bigint, 68719476736::bigint, '32-64s'), + (27, 68719476736::bigint, 137438953472::bigint, '64-128s'), + (28, 137438953472::bigint, 274877906944::bigint, '128-256s'), + (29, 274877906944::bigint, 549755813888::bigint, '256-512s'), + (30, 549755813888::bigint, 1099511627776::bigint, '512s-1024s'), + (31, 1099511627776::bigint, NULL::bigint, '>=1024s') + ) AS t(bucket_idx, lower_ns, upper_ns, label); + +CREATE VIEW pg_stat_wait_event_timing AS + SELECT + t.pid, + t.backend_type, + t.procnumber, + t.wait_event_type, + t.wait_event, + t.calls, + t.total_time_ms, + t.avg_time_us, + t.max_time_us, + t.histogram + FROM pg_stat_get_wait_event_timing(NULL) t; +REVOKE ALL ON pg_stat_wait_event_timing FROM PUBLIC; +GRANT SELECT ON pg_stat_wait_event_timing TO pg_read_all_stats; diff --git a/src/backend/storage/lmgr/proc.c b/src/backend/storage/lmgr/proc.c index 9d6e69175a58a..763493053e2f0 100644 --- a/src/backend/storage/lmgr/proc.c +++ b/src/backend/storage/lmgr/proc.c @@ -57,6 +57,7 @@ #include "utils/timeout.h" #include "utils/timestamp.h" #include "utils/wait_event.h" +#include "utils/wait_event_timing.h" /* GUC variables */ int DeadlockTimeout = 1000; @@ -542,6 +543,7 @@ InitProcess(void) /* now that we have a proc, report wait events to shared memory */ pgstat_set_wait_event_storage(&MyProc->wait_event_info); + pgstat_set_wait_event_timing_storage(MyProcNumber); /* * We might be reusing a semaphore that belonged to a failed process. So @@ -717,6 +719,7 @@ InitAuxiliaryProcess(void) /* now that we have a proc, report wait events to shared memory */ pgstat_set_wait_event_storage(&MyProc->wait_event_info); + pgstat_set_wait_event_timing_storage(MyProcNumber); /* Check that group locking fields are in a proper initial state. */ Assert(MyProc->lockGroupLeader == NULL); @@ -1055,6 +1058,7 @@ ProcKill(int code, Datum arg) /* See comment above, close to DisownLatch() */ pgstat_reset_wait_event_storage(); + pgstat_reset_wait_event_timing_storage(); MyProc = NULL; MyProcNumber = INVALID_PROC_NUMBER; @@ -1117,6 +1121,7 @@ AuxiliaryProcKill(int code, Datum arg) /* look at the equivalent ProcKill() code for comments */ SwitchBackToLocalLatch(); pgstat_reset_wait_event_storage(); + pgstat_reset_wait_event_timing_storage(); /* * If this was one of the aux processes advertised in ProcGlobal, clear it diff --git a/src/backend/utils/.gitignore b/src/backend/utils/.gitignore index fa9cfb39693db..5051e36d1f01f 100644 --- a/src/backend/utils/.gitignore +++ b/src/backend/utils/.gitignore @@ -7,4 +7,5 @@ /errcodes.h /pgstat_wait_event.c /wait_event_funcs_data.c +/wait_event_timing_data.h /wait_event_types.h diff --git a/src/backend/utils/Makefile b/src/backend/utils/Makefile index 81b4a956bda3f..5c11d8294f01a 100644 --- a/src/backend/utils/Makefile +++ b/src/backend/utils/Makefile @@ -43,7 +43,7 @@ generated-header-symlinks: $(top_builddir)/src/include/utils/header-stamp submak submake-adt-headers: $(MAKE) -C adt jsonpath_gram.h -$(SUBDIRS:%=%-recursive): fmgr-stamp errcodes.h guc_tables.inc.c pgstat_wait_event.c wait_event_funcs_data.c wait_event_types.h +$(SUBDIRS:%=%-recursive): fmgr-stamp errcodes.h guc_tables.inc.c pgstat_wait_event.c wait_event_funcs_data.c wait_event_timing_data.h wait_event_types.h # fmgr-stamp records the last time we ran Gen_fmgrtab.pl. We don't rely on # the timestamps of the individual output files, because the Perl script @@ -60,6 +60,7 @@ guc_tables.inc.c: $(top_srcdir)/src/backend/utils/misc/guc_parameters.dat $(top_ pgstat_wait_event.c: wait_event_types.h wait_event_funcs_data.c: wait_event_types.h +wait_event_timing_data.h: wait_event_types.h wait_event_types.h: $(top_srcdir)/src/backend/utils/activity/wait_event_names.txt $(top_srcdir)/src/backend/utils/activity/generate-wait_event_types.pl $(PERL) $(top_srcdir)/src/backend/utils/activity/generate-wait_event_types.pl --code $< @@ -79,8 +80,8 @@ endif # These generated headers must be symlinked into src/include/. # We use header-stamp to record that we've done this because the symlinks # themselves may appear older than fmgr-stamp. -$(top_builddir)/src/include/utils/header-stamp: fmgr-stamp errcodes.h probes.h guc_tables.inc.c pgstat_wait_event.c wait_event_funcs_data.c wait_event_types.h - cd '$(dir $@)' && for file in fmgroids.h fmgrprotos.h errcodes.h probes.h guc_tables.inc.c pgstat_wait_event.c wait_event_funcs_data.c wait_event_types.h; do \ +$(top_builddir)/src/include/utils/header-stamp: fmgr-stamp errcodes.h probes.h guc_tables.inc.c pgstat_wait_event.c wait_event_funcs_data.c wait_event_timing_data.h wait_event_types.h + cd '$(dir $@)' && for file in fmgroids.h fmgrprotos.h errcodes.h probes.h guc_tables.inc.c pgstat_wait_event.c wait_event_funcs_data.c wait_event_timing_data.h wait_event_types.h; do \ rm -f $$file && $(LN_S) "../../../$(subdir)/$$file" . ; \ done touch $@ @@ -99,4 +100,4 @@ uninstall-data: clean: rm -f probes.h probes.h.tmp rm -f fmgroids.h fmgrprotos.h fmgrtab.c fmgr-stamp errcodes.h guc_tables.inc.c - rm -f wait_event_types.h pgstat_wait_event.c wait_event_funcs_data.c + rm -f wait_event_types.h pgstat_wait_event.c wait_event_funcs_data.c wait_event_timing_data.h diff --git a/src/backend/utils/activity/Makefile b/src/backend/utils/activity/Makefile index 1c824e9b78832..cc6e63855f9ba 100644 --- a/src/backend/utils/activity/Makefile +++ b/src/backend/utils/activity/Makefile @@ -42,5 +42,6 @@ OBJS = \ # Force these dependencies to be known even without dependency info built: wait_event.o: wait_event.c $(top_builddir)/src/backend/utils/pgstat_wait_event.c wait_event_funcs.o: wait_event_funcs.c $(top_builddir)/src/backend/utils/wait_event_funcs_data.c +wait_event_timing.o: wait_event_timing.c $(top_builddir)/src/backend/utils/wait_event_timing_data.h include $(top_srcdir)/src/backend/common.mk diff --git a/src/backend/utils/activity/generate-wait_event_types.pl b/src/backend/utils/activity/generate-wait_event_types.pl index d39a30d04783d..f3f1f107a4c04 100644 --- a/src/backend/utils/activity/generate-wait_event_types.pl +++ b/src/backend/utils/activity/generate-wait_event_types.pl @@ -5,6 +5,7 @@ # - wait_event_types.h (if --code is passed) # - pgstat_wait_event.c (if --code is passed) # - wait_event_funcs_data.c (if --code is passed) +# - wait_event_timing_data.h (if --code is passed) # - wait_event_types.sgml (if --docs is passed) # # Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group @@ -269,17 +270,195 @@ } } + # ----------------------------------------------------------- + # Compute wait_event_timing class mapping data. + # + # The dense class table maps raw classId (0x00..max) to a + # dense index, with per-class slot counts rounded up to the + # next power of 2 (minimum 16). Extension and InjectionPoint + # are fixed at 128 because extensions register custom events. + # LWLock uses a hash table (dense = -1). + # ----------------------------------------------------------- + + # Map section name -> raw classId (from wait_classes.h constants) + my %class_to_raw = ( + 'Lock' => 0x03, + 'Buffer' => 0x04, + 'Activity' => 0x05, + 'Client' => 0x06, + 'Extension' => 0x07, + 'IPC' => 0x08, + 'Timeout' => 0x09, + 'IO' => 0x0A, + 'InjectionPoint' => 0x0B, + ); + + # Classes that need fixed large slot counts (dynamically extensible) + my %fixed_slot_classes = ( + 'Extension' => 128, + 'InjectionPoint' => 128, + ); + + # Count events per class from the parsed data. + # Build a list of (className, rawId, actualCount) sorted by rawId. + my @timing_classes; + foreach my $waitclass (keys %hashwe) + { + my $short = $waitclass; + $short =~ s/^WaitEvent//; + + # Skip LWLock -- uses hash table, not flat array + next unless exists $class_to_raw{$short}; + + my $raw_id = $class_to_raw{$short}; + my $count = scalar @{ $hashwe{$waitclass} }; + + push @timing_classes, { + name => $short, + raw_id => $raw_id, + actual => $count, + }; + } + + # InjectionPoint (0x0B) has no section in wait_event_names.txt + # because its events are dynamically registered at runtime. + # Add it explicitly with actual=0 and a fixed slot count. + if (!grep { $_->{name} eq 'InjectionPoint' } @timing_classes) + { + push @timing_classes, { + name => 'InjectionPoint', + raw_id => $class_to_raw{'InjectionPoint'}, + actual => 0, + }; + } + + # Sort by raw classId + @timing_classes = sort { $a->{raw_id} <=> $b->{raw_id} } @timing_classes; + + # Compute slot counts: next power of 2, minimum 16, or fixed + foreach my $cls (@timing_classes) + { + if (exists $fixed_slot_classes{$cls->{name}}) + { + $cls->{slots} = $fixed_slot_classes{$cls->{name}}; + } + else + { + my $slots = 16; # minimum + $slots *= 2 while $slots < $cls->{actual}; + $cls->{slots} = $slots; + } + } + + # Compute cumulative offsets + my $offset = 0; + foreach my $cls (@timing_classes) + { + $cls->{offset} = $offset; + $offset += $cls->{slots}; + } + my $total_events = $offset; + + # Determine max raw classId for array sizing + my $max_raw = 0; + foreach my $cls (@timing_classes) + { + $max_raw = $cls->{raw_id} if $cls->{raw_id} > $max_raw; + } + my $raw_classes = $max_raw + 1; + my $dense_classes = scalar @timing_classes; + + # Emit timing defines into wait_event_types.h + printf $h "\n/* Wait event timing flat array sizing (generated) */\n"; + printf $h "#define WAIT_EVENT_TIMING_RAW_CLASSES\t%d\n", $raw_classes; + printf $h "#define WAIT_EVENT_TIMING_DENSE_CLASSES\t%d\n", $dense_classes; + printf $h "#define WAIT_EVENT_TIMING_NUM_EVENTS\t%d\n\n", $total_events; + printf $h "#endif /* WAIT_EVENT_TYPES_H */\n"; close $h; close $c; close $wc; + # Generate wait_event_timing_data.h with the mapping arrays. + # A header (rather than a .c file) keeps the file-extension category + # straight: it is included into a single TU (wait_event_timing.c) and + # defines static const tables there. The include guard makes the + # single-owner intent explicit and prevents accidental double inclusion. + my $ttmp = "$output_path/wait_event_timing_data.h.tmp$$"; + open my $t, '>', $ttmp or die "Could not open $ttmp: $!"; + printf $t $header_comment, 'wait_event_timing_data.h'; + + printf $t "#ifndef WAIT_EVENT_TIMING_DATA_H\n"; + printf $t "#define WAIT_EVENT_TIMING_DATA_H\n\n"; + + # Emit wait_event_class_dense[] + printf $t "static const int8 wait_event_class_dense[WAIT_EVENT_TIMING_RAW_CLASSES] = {\n"; + for (my $i = 0; $i < $raw_classes; $i++) + { + my $dense = -1; + my $comment = "unused"; + for (my $d = 0; $d < $dense_classes; $d++) + { + if ($timing_classes[$d]->{raw_id} == $i) + { + $dense = $d; + $comment = $timing_classes[$d]->{name}; + last; + } + } + # classId 0x01 is LWLock + if ($i == 0x01) + { + $comment = "LWLock (uses hash)"; + } + my $comma = ($i < $raw_classes - 1) ? "," : ""; + printf $t "\t%2d$comma\t\t/* 0x%02x: %s */\n", $dense, $i, $comment; + } + printf $t "};\n\n"; + + # Emit wait_event_class_nevents[] + printf $t "static const int wait_event_class_nevents[WAIT_EVENT_TIMING_DENSE_CLASSES] = {\n"; + for (my $d = 0; $d < $dense_classes; $d++) + { + my $cls = $timing_classes[$d]; + my $comma = ($d < $dense_classes - 1) ? "," : ""; + printf $t "\t%d$comma\t\t/* %s (actual: %d) */\n", + $cls->{slots}, $cls->{name}, $cls->{actual}; + } + printf $t "};\n\n"; + + # Emit wait_event_class_offset[] + printf $t "static const int wait_event_class_offset[WAIT_EVENT_TIMING_DENSE_CLASSES] = {\n"; + for (my $d = 0; $d < $dense_classes; $d++) + { + my $cls = $timing_classes[$d]; + my $comma = ($d < $dense_classes - 1) ? "," : ""; + printf $t "\t%d$comma\t\t/* %s */\n", $cls->{offset}, $cls->{name}; + } + printf $t "};\n\n"; + + # Emit wait_event_dense_to_classid[] + printf $t "static const uint8 wait_event_dense_to_classid[WAIT_EVENT_TIMING_DENSE_CLASSES] = {\n\t"; + for (my $d = 0; $d < $dense_classes; $d++) + { + my $cls = $timing_classes[$d]; + my $comma = ($d < $dense_classes - 1) ? ", " : ""; + printf $t "0x%02x$comma", $cls->{raw_id}; + } + printf $t "\n};\n\n"; + + printf $t "#endif /* WAIT_EVENT_TIMING_DATA_H */\n"; + + close $t; + rename($htmp, "$output_path/wait_event_types.h") || die "rename: $htmp to $output_path/wait_event_types.h: $!"; rename($ctmp, "$output_path/pgstat_wait_event.c") || die "rename: $ctmp to $output_path/pgstat_wait_event.c: $!"; rename($wctmp, "$output_path/wait_event_funcs_data.c") || die "rename: $wctmp to $output_path/wait_event_funcs_data.c: $!"; + rename($ttmp, "$output_path/wait_event_timing_data.h") + || die "rename: $ttmp to $output_path/wait_event_timing_data.h: $!"; } # Generate the .sgml file. elsif ($gen_docs) diff --git a/src/backend/utils/activity/wait_event.c b/src/backend/utils/activity/wait_event.c index e36a740a888d9..6eb5ec78dffe4 100644 --- a/src/backend/utils/activity/wait_event.c +++ b/src/backend/utils/activity/wait_event.c @@ -40,8 +40,7 @@ static const char *pgstat_get_wait_io(WaitEventIO w); static uint32 local_my_wait_event_info; uint32 *my_wait_event_info = &local_my_wait_event_info; -#define WAIT_EVENT_CLASS_MASK 0xFF000000 -#define WAIT_EVENT_ID_MASK 0x0000FFFF +/* WAIT_EVENT_CLASS_MASK / WAIT_EVENT_ID_MASK are defined in utils/wait_classes.h */ /* * Hash tables for storing custom wait event ids and their names in diff --git a/src/backend/utils/activity/wait_event_timing.c b/src/backend/utils/activity/wait_event_timing.c index d11823adc57fe..97f01993bb7b7 100644 --- a/src/backend/utils/activity/wait_event_timing.c +++ b/src/backend/utils/activity/wait_event_timing.c @@ -1,17 +1,24 @@ /*------------------------------------------------------------------------- * * wait_event_timing.c - * Per-backend wait event timing instrumentation. + * Per-backend wait event timing and histogram accumulation. * - * Controlled by the wait_event_capture GUC (off | stats, default off) - * and the compile-time option --enable-wait-event-timing. + * Every transition through pgstat_report_wait_start()/_end() records the + * wait duration with INSTR_TIME (a clock_gettime()-grade timestamp) and + * accumulates per-(backend, event) statistics -- count, total/max + * nanoseconds, and a log2 duration histogram -- in shared memory. Each + * backend writes only to its own slot, so the hot path needs no locking. * - * This commit provides only the GUC scaffolding: the backing variable, - * the enum-value table consumed by guc.c, and the check/assign hooks. - * No instrumentation is performed yet -- later commits in the series add - * the recording hot path and the SQL surface. The file compiles in both - * build configurations; in builds without --enable-wait-event-timing the - * check hook rejects any value other than off. + * The per-backend slot array lives in the main shared memory segment, + * sized at postmaster start from wait_event_timing_max_tranches, so it is + * valid for the entire life of every backend -- no lazy attach and no + * teardown gating are required. + * + * Controlled by the wait_event_capture GUC (off | stats, default off) and + * the compile-time option --enable-wait-event-timing. In builds without + * that option the file still compiles (the GUC backing variable, the enum + * table, the rejecting check hook, and empty-result SQL stubs), so the GUC + * and the catalog functions exist uniformly. * * Copyright (c) 2026, PostgreSQL Global Development Group * @@ -22,16 +29,17 @@ */ #include "postgres.h" +#include "storage/subsystems.h" #include "utils/guc.h" #include "utils/guc_hooks.h" #include "utils/wait_event_timing.h" /* - * GUC variable -- always defined so the GUC system has a backing variable - * even when compiled without --enable-wait-event-timing. In stub builds - * the check hook below rejects any value other than OFF. + * GUC variables -- always defined so the GUC system has backing variables + * even when compiled without --enable-wait-event-timing. */ int wait_event_capture = WAIT_EVENT_CAPTURE_OFF; +int wait_event_timing_max_tranches = 192; /* * Enum value table consumed by guc.c. Order matches the @@ -43,40 +51,29 @@ const struct config_enum_entry wait_event_capture_options[] = { {NULL, 0, false} }; -#ifdef USE_WAIT_EVENT_TIMING +#ifndef USE_WAIT_EVENT_TIMING /* - * GUC check hook for wait_event_capture (timing build). - * - * All enum values are accepted at this level. Side effects (attaching - * storage, etc.) are introduced by later commits; for now there is - * nothing to validate beyond the enum mapping that guc.c already did. + * Stub build: no instrumentation. Provide the symbols referenced by + * pg_proc.dat, the GUC machinery, and the shmem subsystem registry. */ -bool -check_wait_event_capture(int *newval, void **extra, GucSource source) -{ - return true; -} +#include "fmgr.h" +#include "funcapi.h" -/* - * GUC assign hook for wait_event_capture (timing build). - * - * No-op for now. Later commits use this hook to drop in-flight wait - * state and manage per-session resources when the capture level changes. - */ -void -assign_wait_event_capture(int newval, void *extra) +Datum pg_stat_get_wait_event_timing(PG_FUNCTION_ARGS); + +Datum +pg_stat_get_wait_event_timing(PG_FUNCTION_ARGS) { + InitMaterializedSRF(fcinfo, 0); + PG_RETURN_VOID(); } -#else /* !USE_WAIT_EVENT_TIMING */ - /* * GUC check hook for the stub build. Any value other than 'off' is * meaningless without --enable-wait-event-timing, so reject it -- or * downgrade to 'off' with a warning when the value comes from a - * non-interactive source (config file at startup), so a leftover setting - * does not prevent the server from starting. + * non-interactive source, so a leftover setting does not block startup. */ bool check_wait_event_capture(int *newval, void **extra, GucSource source) @@ -100,10 +97,665 @@ check_wait_event_capture(int *newval, void **extra, GucSource source) return true; } -/* Stub assign hook -- nothing to do without compile-time support. */ void assign_wait_event_capture(int newval, void *extra) { } +/* No shared memory is reserved in the stub build. */ +const ShmemCallbacks WaitEventTimingShmemCallbacks = {0}; + +/* Defined so every extern in wait_event_timing.h resolves in stub builds. */ +WaitEventTimingState *my_wait_event_timing = NULL; + +void +pgstat_set_wait_event_timing_storage(int procNumber) +{ +} + +void +pgstat_reset_wait_event_timing_storage(void) +{ +} + +#else /* USE_WAIT_EVENT_TIMING */ + +#include "catalog/pg_authid.h" +#include "catalog/pg_type_d.h" +#include "funcapi.h" +#include "miscadmin.h" +#include "port/pg_bitutils.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "storage/procnumber.h" +#include "utils/acl.h" +#include "utils/array.h" +#include "utils/backend_status.h" +#include "utils/builtins.h" +#include "utils/tuplestore.h" +#include "utils/wait_event.h" + +#define NUM_WAIT_EVENT_TIMING_SLOTS (MaxBackends + NUM_AUXILIARY_PROCS) + +#define HAS_PGSTAT_PERMISSIONS(role) \ + (has_privs_of_role(GetUserId(), ROLE_PG_READ_ALL_STATS) || \ + has_privs_of_role(GetUserId(), role)) + +/* Pointer to this backend's timing state in shared memory. */ +WaitEventTimingState *my_wait_event_timing = NULL; + +/* + * Backend-local cached pointer to the start of the shared slot array, set + * at shmem init (postmaster) and, in EXEC_BACKEND mode, at attach. Slots + * are NOT a simple C array: each has a runtime-determined stride (header + + * variable-size hash arrays); use wet_slot() to index. + */ +static char *WaitEventTimingArray = NULL; + +/* + * Per-backend slot stride and the hash dimensions, all derived from the + * GUC wait_event_timing_max_tranches. Because the GUC is PGC_POSTMASTER, + * every backend in the cluster derives identical values, so the shared + * layout is consistent across fork() and EXEC_BACKEND. + */ +static Size wait_event_timing_per_backend_stride = 0; +static int wait_event_timing_hash_size = 0; +static int wait_event_timing_max_entries = 0; + +/* + * Mapping arrays for the flat events[] array, generated from + * wait_event_names.txt by generate-wait_event_types.pl. Defines + * wait_event_class_dense / _nevents / _offset / _dense_to_classid. + */ +#include "utils/wait_event_timing_data.h" + +/* + * Round up to the next power of two, with a minimum of 32. The hash slot + * count must be a power of two for the mask-based modulo in the lookup hot + * path; we target >= 2x the entry cap so the load factor stays <= 50%. + */ +static int +wait_event_timing_hash_size_for(int max_entries) +{ + int size = 32; + + while (size < max_entries * 2) + size <<= 1; + return size; +} + +/* + * Compute the per-backend slot size for the given max_entries. Layout: + * + * [ WaitEventTimingState header ] + * [ LWLockTimingHashEntry[hash_size] ] + * [ WaitEventTimingEntry[max_entries] <- lwlock_events[] ] + */ +static Size +wait_event_timing_slot_size(int max_entries) +{ + int hash_size = wait_event_timing_hash_size_for(max_entries); + + return add_size(sizeof(WaitEventTimingState), + add_size(mul_size(hash_size, sizeof(LWLockTimingHashEntry)), + mul_size(max_entries, sizeof(WaitEventTimingEntry)))); +} + +/* Cache the backend-local layout dimensions from the GUC (idempotent). */ +static void +wait_event_timing_init_local_dims(void) +{ + if (wait_event_timing_per_backend_stride != 0) + return; + wait_event_timing_max_entries = wait_event_timing_max_tranches; + wait_event_timing_hash_size = + wait_event_timing_hash_size_for(wait_event_timing_max_entries); + wait_event_timing_per_backend_stride = + wait_event_timing_slot_size(wait_event_timing_max_entries); +} + +/* Resolve the address of slot `idx` within WaitEventTimingArray. */ +static inline WaitEventTimingState * +wet_slot(int idx) +{ + return (WaitEventTimingState *) + (WaitEventTimingArray + (Size) idx * wait_event_timing_per_backend_stride); +} + +/* + * Address of the LWLock hash slot table for a slot (immediately follows + * the WaitEventTimingState header). + */ +static inline LWLockTimingHashEntry * +wet_lwlock_hash_entries(WaitEventTimingState *state) +{ + return (LWLockTimingHashEntry *) ((char *) state + sizeof(WaitEventTimingState)); +} + +/* + * Address of the dense LWLock events array for a slot (immediately follows + * the slot table). + */ +static inline WaitEventTimingEntry * +wet_lwlock_hash_events(WaitEventTimingState *state) +{ + return (WaitEventTimingEntry *) + ((char *) state + sizeof(WaitEventTimingState) + + (Size) state->lwlock_hash.hash_size * sizeof(LWLockTimingHashEntry)); +} + +/* + * Convert wait_event_info to a flat index for the events[] array. Returns + * WAIT_EVENT_TIMING_IDX_LWLOCK for LWLock events (which use the hash) and + * -1 for events outside the mapped classes. + */ +static int +wait_event_timing_index(uint32 wait_event_info) +{ + uint32 classId = wait_event_info & WAIT_EVENT_CLASS_MASK; + int eventId = wait_event_info & WAIT_EVENT_ID_MASK; + int class_byte; + int dense; + + if (classId == PG_WAIT_LWLOCK) + return WAIT_EVENT_TIMING_IDX_LWLOCK; + + class_byte = classId >> 24; + if (class_byte >= WAIT_EVENT_TIMING_RAW_CLASSES) + return -1; + + dense = wait_event_class_dense[class_byte]; + if (dense < 0) + return -1; + + if (eventId >= wait_event_class_nevents[dense]) + return -1; + + return wait_event_class_offset[dense] + eventId; +} + +/* + * Reset a slot's LWLockTimingHash to its empty initial state. The hash + * header's hash_size and max_entries are immutable and not reset here. + */ +static void +lwlock_timing_hash_clear(WaitEventTimingState *state) +{ + LWLockTimingHash *ht = &state->lwlock_hash; + LWLockTimingHashEntry *entries = wet_lwlock_hash_entries(state); + WaitEventTimingEntry *events = wet_lwlock_hash_events(state); + int i; + + ht->num_used = 0; + memset(events, 0, (Size) ht->max_entries * sizeof(WaitEventTimingEntry)); + for (i = 0; i < ht->hash_size; i++) + { + entries[i].tranche_id = LWLOCK_TIMING_EMPTY_SLOT; + entries[i].dense_idx = 0; + } +} + +/* + * Maximum probes attempted on the lookup hot path once the table is at + * capacity. At cap an unknown tranche cannot be inserted, so bounding the + * scan caps the per-event cost instead of walking clustered occupied slots + * on every unknown-tranche wait_end. 8 is well above the expected probe + * distance at the target load factor. + */ +#define LWLOCK_TIMING_LOOKUP_AT_CAP_PROBE_LIMIT 8 + +/* + * Look up (or insert) the timing entry for an LWLock tranche id. Returns + * NULL when the table is at capacity and the tranche is not already + * present. + */ +static WaitEventTimingEntry * +lwlock_timing_lookup(WaitEventTimingState *state, uint16 tranche_id) +{ + LWLockTimingHash *ht = &state->lwlock_hash; + LWLockTimingHashEntry *entries = wet_lwlock_hash_entries(state); + WaitEventTimingEntry *events = wet_lwlock_hash_events(state); + uint32 hash = (uint32) tranche_id * 2654435761U; + int slot = hash & (ht->hash_size - 1); + int limit; + int i; + + limit = (ht->num_used >= ht->max_entries) + ? LWLOCK_TIMING_LOOKUP_AT_CAP_PROBE_LIMIT + : ht->hash_size; + + for (i = 0; i < limit; i++) + { + LWLockTimingHashEntry *e = &entries[slot]; + + if (e->tranche_id == tranche_id) + return &events[e->dense_idx]; + + if (e->tranche_id == LWLOCK_TIMING_EMPTY_SLOT) + { + if (ht->num_used >= ht->max_entries) + return NULL; + + e->tranche_id = tranche_id; + e->dense_idx = ht->num_used++; + return &events[e->dense_idx]; + } + + slot = (slot + 1) & (ht->hash_size - 1); + } + + return NULL; +} + +/* + * Compute the histogram bucket index for a duration in nanoseconds. See + * the rationale on WAIT_EVENT_TIMING_HISTOGRAM_BUCKETS in the header. + */ +static int +wait_event_timing_bucket(int64 duration_ns) +{ + int bucket; + + /* + * Everything under ~1us (and 0, undefined for pg_leftmost_one_pos64) + * lands in bucket 0. + */ + if (duration_ns < 1024) + return 0; + + bucket = pg_leftmost_one_pos64((uint64) duration_ns) - 9; + + if (bucket >= WAIT_EVENT_TIMING_HISTOGRAM_BUCKETS) + bucket = WAIT_EVENT_TIMING_HISTOGRAM_BUCKETS - 1; + + return bucket; +} + +/* + * ShmemRequest: reserve the per-backend slot array. Sized from + * wait_event_timing_max_tranches; the framework stores the allocated + * address in WaitEventTimingArray before WaitEventTimingShmemInit runs. + */ +static void +WaitEventTimingShmemRequest(void *arg) +{ + Size stride; + + wait_event_timing_init_local_dims(); + stride = wait_event_timing_per_backend_stride; + + ShmemRequestStruct(.name = "WaitEventTimingArray", + .size = mul_size(NUM_WAIT_EVENT_TIMING_SLOTS, stride), + .ptr = (void **) &WaitEventTimingArray); +} + +/* ShmemInit: zero the array and initialise each slot's hash header. */ +static void +WaitEventTimingShmemInit(void *arg) +{ + int i; + + wait_event_timing_init_local_dims(); + + memset(WaitEventTimingArray, 0, + mul_size(NUM_WAIT_EVENT_TIMING_SLOTS, + wait_event_timing_per_backend_stride)); + + for (i = 0; i < NUM_WAIT_EVENT_TIMING_SLOTS; i++) + { + WaitEventTimingState *slot = wet_slot(i); + LWLockTimingHashEntry *entries; + int j; + + slot->lwlock_hash.num_used = 0; + slot->lwlock_hash.hash_size = wait_event_timing_hash_size; + slot->lwlock_hash.max_entries = wait_event_timing_max_entries; + + /* The array was zeroed above, but the empty sentinel is 0xFFFF. */ + entries = wet_lwlock_hash_entries(slot); + for (j = 0; j < wait_event_timing_hash_size; j++) + entries[j].tranche_id = LWLOCK_TIMING_EMPTY_SLOT; + } +} + +const ShmemCallbacks WaitEventTimingShmemCallbacks = { + .request_fn = WaitEventTimingShmemRequest, + .init_fn = WaitEventTimingShmemInit, +}; + +/* + * Point my_wait_event_timing at this backend's slot. Called from + * InitProcess()/InitAuxiliaryProcess() once the backend has a procNumber. + * The slot is cleared here so stats do not leak across slot reuse when a + * new backend inherits a procNumber previously held by an exited one. + */ +void +pgstat_set_wait_event_timing_storage(int procNumber) +{ + WaitEventTimingState *slot; + + if (procNumber < 0 || procNumber >= NUM_WAIT_EVENT_TIMING_SLOTS || + WaitEventTimingArray == NULL) + { + my_wait_event_timing = NULL; + return; + } + + wait_event_timing_init_local_dims(); + + slot = wet_slot(procNumber); + + memset(slot->events, 0, sizeof(slot->events)); + lwlock_timing_hash_clear(slot); + slot->lwlock_overflow_count = 0; + slot->flat_overflow_count = 0; + slot->current_event = 0; + INSTR_TIME_SET_ZERO(slot->wait_start); + + /* Publish only after the slot is fully initialised. */ + my_wait_event_timing = slot; +} + +/* + * Detach from the timing slot on backend exit. The slot itself stays in + * shared memory; clearing the pointer keeps the late-shutdown wait-event + * hot path from touching it. + */ +void +pgstat_reset_wait_event_timing_storage(void) +{ + my_wait_event_timing = NULL; +} + +/* + * GUC check hook for wait_event_capture (timing build). All enum values + * are accepted; there is nothing to validate beyond the enum mapping. + */ +bool +check_wait_event_capture(int *newval, void **extra, GucSource source) +{ + return true; +} + +/* + * GUC assign hook for wait_event_capture (timing build). + * + * Drop any in-flight wait state: after the capture level changes, the + * existing wait_start / current_event can no longer be trusted (a wait + * that started under one level and ends under another would be miscredited + * or use a stale start time). Forfeiting at most one in-flight sample per + * GUC change is negligible and eliminates all such miscredits. + */ +void +assign_wait_event_capture(int newval, void *extra) +{ + if (my_wait_event_timing != NULL) + { + INSTR_TIME_SET_ZERO(my_wait_event_timing->wait_start); + my_wait_event_timing->current_event = 0; + } +} + +/* + * Out-of-line body for pgstat_report_wait_start()'s timing path. Records + * the start timestamp and the event being waited on. Reached only when + * wait_event_capture != OFF. + */ +void +pgstat_report_wait_start_timing(uint32 wait_event_info) +{ + if (my_wait_event_timing == NULL) + return; + + INSTR_TIME_SET_CURRENT(my_wait_event_timing->wait_start); + my_wait_event_timing->current_event = wait_event_info; +} + +/* + * Out-of-line body for pgstat_report_wait_end()'s timing path. Computes + * the wait duration and accumulates per-event statistics. + * + * capture_level is the value of wait_event_capture observed at the inline + * gate; in this commit only STATS exists, so it is not branched on, but it + * is threaded through to keep the gate ABI stable for the trace level + * added later in the series. + */ +void +pgstat_report_wait_end_timing(int capture_level) +{ + uint32 event; + + (void) capture_level; + + if (my_wait_event_timing == NULL) + return; + + event = my_wait_event_timing->current_event; + + if (event != 0 && !INSTR_TIME_IS_ZERO(my_wait_event_timing->wait_start)) + { + instr_time now; + int64 duration_ns; + int idx; + WaitEventTimingEntry *entry = NULL; + + INSTR_TIME_SET_CURRENT(now); + duration_ns = INSTR_TIME_GET_NANOSEC(now) - + INSTR_TIME_GET_NANOSEC(my_wait_event_timing->wait_start); + + if (duration_ns < 0) + duration_ns = 0; + + idx = wait_event_timing_index(event); + + /* + * Single-writer hot path: each slot has exactly one writer (the + * owning backend), and the SRF reader is lock-free, so no locking is + * needed here. Events that do not map to a slot -- an LWLock tranche + * beyond the per-backend cap, or a class unknown to the timing tables + * -- bump a per-backend overflow counter. We deliberately do not log + * here: this runs inline in every wait_end, potentially deep in the + * backend stack, so the overflow counters are surfaced through a + * statistics view rather than through ereport(). + */ + if (idx == WAIT_EVENT_TIMING_IDX_LWLOCK) + entry = lwlock_timing_lookup(my_wait_event_timing, event & 0xFFFF); + else if (idx >= 0) + entry = &my_wait_event_timing->events[idx]; + + if (entry != NULL) + { + entry->count++; + entry->total_ns += duration_ns; + if (duration_ns > entry->max_ns) + entry->max_ns = duration_ns; + entry->histogram[wait_event_timing_bucket(duration_ns)]++; + } + else if (idx == WAIT_EVENT_TIMING_IDX_LWLOCK) + my_wait_event_timing->lwlock_overflow_count++; + else if (idx == -1) + my_wait_event_timing->flat_overflow_count++; + + INSTR_TIME_SET_ZERO(my_wait_event_timing->wait_start); + } +} + +/* + * Resolve the optional pid SRF argument to a procNumber range + * [out_start, out_end). Returns false if the SRF should emit zero rows + * (unknown pid -- silent no-op). + */ +static bool +wait_event_timing_pid_range(FunctionCallInfo fcinfo, + int *out_start, int *out_end) +{ + if (PG_ARGISNULL(0)) + { + *out_start = 0; + *out_end = NUM_WAIT_EVENT_TIMING_SLOTS; + return true; + } + else + { + int target_pid = PG_GETARG_INT32(0); + PGPROC *proc; + int procNumber; + + proc = BackendPidGetProc(target_pid); + if (proc == NULL) + proc = AuxiliaryPidGetProc(target_pid); + if (proc == NULL) + return false; + + procNumber = GetNumberFromPGProc(proc); + if (procNumber < 0 || procNumber >= NUM_WAIT_EVENT_TIMING_SLOTS) + return false; + + *out_start = procNumber; + *out_end = procNumber + 1; + return true; + } +} + +/* Emit one SRF row for a populated timing entry. */ +static void +wait_event_timing_emit_row(ReturnSetInfo *rsinfo, PgBackendStatus *beentry, + int procnumber, uint32 wait_event_info, + WaitEventTimingEntry *entry, + ArrayType *hist_array, int64 *hist_payload) +{ + Datum values[10]; + bool nulls[10]; + const char *event_type; + const char *event_name; + int bucket; + + event_type = pgstat_get_wait_event_type(wait_event_info); + event_name = pgstat_get_wait_event(wait_event_info); + if (event_type == NULL || event_name == NULL) + return; + + memset(nulls, 0, sizeof(nulls)); + + values[0] = Int32GetDatum(beentry->st_procpid); + values[1] = CStringGetTextDatum(GetBackendTypeDesc(beentry->st_backendType)); + values[2] = Int32GetDatum(procnumber); + values[3] = CStringGetTextDatum(event_type); + values[4] = CStringGetTextDatum(event_name); + values[5] = Int64GetDatum(entry->count); + values[6] = Float8GetDatum((double) entry->total_ns / 1000000.0); + values[7] = Float8GetDatum(entry->count > 0 + ? (double) entry->total_ns / entry->count / 1000.0 + : 0.0); + values[8] = Float8GetDatum((double) entry->max_ns / 1000.0); + + for (bucket = 0; bucket < WAIT_EVENT_TIMING_HISTOGRAM_BUCKETS; bucket++) + hist_payload[bucket] = entry->histogram[bucket]; + values[9] = PointerGetDatum(hist_array); + + tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls); +} + +/* + * SQL function: pg_stat_get_wait_event_timing(pid int4, OUT ...) + * + * Returns one row per (backend, wait_event) with a non-zero count. pid is + * optional: NULL means all backends; a non-NULL value restricts the sweep + * to that backend (silently empty for unknown pids). + */ +Datum +pg_stat_get_wait_event_timing(PG_FUNCTION_ARGS) +{ + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + int start_idx; + int end_idx; + int backend_idx; + ArrayType *hist_array; + int64 *hist_payload; + + InitMaterializedSRF(fcinfo, 0); + + if (WaitEventTimingArray == NULL) + PG_RETURN_VOID(); + + if (!wait_event_timing_pid_range(fcinfo, &start_idx, &end_idx)) + PG_RETURN_VOID(); + + /* + * Allocate the histogram ArrayType once and reuse it across every row; + * tuplestore_putvalues flattens the varlena into its stored tuple, so + * rewriting the payload cannot corrupt previously emitted rows. + */ + { + Datum zero_elems[WAIT_EVENT_TIMING_HISTOGRAM_BUCKETS]; + + memset(zero_elems, 0, sizeof(zero_elems)); + hist_array = construct_array_builtin(zero_elems, + WAIT_EVENT_TIMING_HISTOGRAM_BUCKETS, + INT8OID); + hist_payload = (int64 *) ARR_DATA_PTR(hist_array); + } + + for (backend_idx = start_idx; backend_idx < end_idx; backend_idx++) + { + WaitEventTimingState *state = wet_slot(backend_idx); + PgBackendStatus *beentry; + int i; + + /* Skip dead backend slots and enforce stats permissions. */ + beentry = pgstat_get_beentry_by_proc_number(backend_idx); + if (beentry == NULL) + continue; + if (!HAS_PGSTAT_PERMISSIONS(beentry->st_userid)) + continue; + + /* Flat array rows (all classes except LWLock). */ + for (i = 0; i < WAIT_EVENT_TIMING_DENSE_CLASSES; i++) + { + int base = wait_event_class_offset[i]; + int nevents = wait_event_class_nevents[i]; + uint32 classId = wait_event_dense_to_classid[i]; + int j; + + for (j = 0; j < nevents; j++) + { + WaitEventTimingEntry *entry = &state->events[base + j]; + + if (entry->count == 0) + continue; + + wait_event_timing_emit_row(rsinfo, beentry, backend_idx, + ((uint32) classId << 24) | j, + entry, hist_array, hist_payload); + } + } + + /* LWLock hash rows. */ + { + LWLockTimingHashEntry *entries = wet_lwlock_hash_entries(state); + WaitEventTimingEntry *events = wet_lwlock_hash_events(state); + int hash_size = state->lwlock_hash.hash_size; + + for (i = 0; i < hash_size; i++) + { + LWLockTimingHashEntry *he = &entries[i]; + WaitEventTimingEntry *entry; + + if (he->tranche_id == LWLOCK_TIMING_EMPTY_SLOT) + continue; + + entry = &events[he->dense_idx]; + if (entry->count == 0) + continue; + + wait_event_timing_emit_row(rsinfo, beentry, backend_idx, + PG_WAIT_LWLOCK | he->tranche_id, + entry, hist_array, hist_payload); + } + } + } + + PG_RETURN_VOID(); +} + #endif /* USE_WAIT_EVENT_TIMING */ diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index 7e511aaedf94d..92b134ccc8f3c 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -3446,6 +3446,15 @@ assign_hook => 'assign_wait_event_capture', }, +{ name => 'wait_event_timing_max_tranches', type => 'int', context => 'PGC_POSTMASTER', group => 'STATS_CUMULATIVE', + short_desc => 'Sets the maximum number of distinct LWLock tranches whose timing is recorded per backend.', + long_desc => 'Each backend\'s wait-event-timing hash table can hold this many distinct LWLock tranches; subsequent tranches are not individually timed. Sized at server start; raise this if your installation loads many extensions that register their own LWLock tranches.', + variable => 'wait_event_timing_max_tranches', + boot_val => '192', + min => '16', + max => '65534', +}, + { name => 'wal_block_size', type => 'int', context => 'PGC_INTERNAL', group => 'PRESET_OPTIONS', short_desc => 'Shows the block size in the write ahead log.', flags => 'GUC_NOT_IN_SAMPLE | GUC_DISALLOW_IN_FILE', diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index 16043d74b4470..bab7de3eee804 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -706,6 +706,7 @@ #track_wal_io_timing = off #wait_event_capture = off # off, stats # (requires --enable-wait-event-timing) +#wait_event_timing_max_tranches = 192 # (change requires restart) #track_functions = none # none, pl, all #stats_fetch_consistency = cache # cache, none, snapshot diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index f8a021987b5e5..0f6c2e16cb7fa 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -12715,4 +12715,14 @@ proname => 'hashoid8extended', prorettype => 'int8', proargtypes => 'oid8 int8', prosrc => 'hashoid8extended' }, +{ oid => '9956', + descr => 'statistics: per-backend wait event timing (count, duration, histogram)', + proname => 'pg_stat_get_wait_event_timing', prorows => '1000', + proisstrict => 'f', proretset => 't', provolatile => 's', proparallel => 'r', + prorettype => 'record', proargtypes => 'int4', + proallargtypes => '{int4,int4,text,int4,text,text,int8,float8,float8,float8,_int8}', + proargmodes => '{i,o,o,o,o,o,o,o,o,o,o}', + proargnames => '{pid,pid,backend_type,procnumber,wait_event_type,wait_event,calls,total_time_ms,avg_time_us,max_time_us,histogram}', + prosrc => 'pg_stat_get_wait_event_timing' }, + ] diff --git a/src/include/storage/subsystemlist.h b/src/include/storage/subsystemlist.h index 9ad619080be22..331d60cf703ee 100644 --- a/src/include/storage/subsystemlist.h +++ b/src/include/storage/subsystemlist.h @@ -79,6 +79,7 @@ PG_SHMEM_SUBSYSTEM(SyncScanShmemCallbacks) PG_SHMEM_SUBSYSTEM(AsyncShmemCallbacks) PG_SHMEM_SUBSYSTEM(StatsShmemCallbacks) PG_SHMEM_SUBSYSTEM(WaitEventCustomShmemCallbacks) +PG_SHMEM_SUBSYSTEM(WaitEventTimingShmemCallbacks) #ifdef USE_INJECTION_POINTS PG_SHMEM_SUBSYSTEM(InjectionPointShmemCallbacks) #endif diff --git a/src/include/utils/.gitignore b/src/include/utils/.gitignore index ff6f61cd7ee7b..8a489b7769b16 100644 --- a/src/include/utils/.gitignore +++ b/src/include/utils/.gitignore @@ -6,4 +6,5 @@ /header-stamp /pgstat_wait_event.c /wait_event_funcs_data.c +/wait_event_timing_data.h /wait_event_types.h diff --git a/src/include/utils/meson.build b/src/include/utils/meson.build index fd3a2352df5d4..ef8b2dc261811 100644 --- a/src/include/utils/meson.build +++ b/src/include/utils/meson.build @@ -1,6 +1,6 @@ # Copyright (c) 2022-2026, PostgreSQL Global Development Group -wait_event_output = ['wait_event_types.h', 'pgstat_wait_event.c', 'wait_event_funcs_data.c'] +wait_event_output = ['wait_event_types.h', 'pgstat_wait_event.c', 'wait_event_funcs_data.c', 'wait_event_timing_data.h'] wait_event_target = custom_target('wait_event_names', input: files('../../backend/utils/activity/wait_event_names.txt'), output: wait_event_output, @@ -11,7 +11,7 @@ wait_event_target = custom_target('wait_event_names', ], build_by_default: true, install: true, - install_dir: [dir_include_server / 'utils', false, false], + install_dir: [dir_include_server / 'utils', false, false, false], ) wait_event_types_h = wait_event_target[0] diff --git a/src/include/utils/wait_classes.h b/src/include/utils/wait_classes.h index b91690a22c63b..c6c692a1e9391 100644 --- a/src/include/utils/wait_classes.h +++ b/src/include/utils/wait_classes.h @@ -26,4 +26,13 @@ #define PG_WAIT_IO 0x0A000000U #define PG_WAIT_INJECTIONPOINT 0x0B000000U +/* + * Bit-layout masks for wait_event_info. The high byte encodes the + * class (one of the PG_WAIT_* constants above); the low 16 bits + * encode the per-class event id; the middle byte is currently + * reserved (see pgstat_report_wait_start in wait_event.h). + */ +#define WAIT_EVENT_CLASS_MASK 0xFF000000U +#define WAIT_EVENT_ID_MASK 0x0000FFFFU + #endif /* WAIT_CLASSES_H */ diff --git a/src/include/utils/wait_event.h b/src/include/utils/wait_event.h index 86ee348220d7f..3f94cd090993a 100644 --- a/src/include/utils/wait_event.h +++ b/src/include/utils/wait_event.h @@ -13,6 +13,10 @@ /* enums for wait events */ #include "utils/wait_event_types.h" +#ifdef USE_WAIT_EVENT_TIMING +#include "utils/wait_event_timing.h" +#endif + extern const char *pgstat_get_wait_event(uint32 wait_event_info); extern const char *pgstat_get_wait_event_type(uint32 wait_event_info); static inline void pgstat_report_wait_start(uint32 wait_event_info); @@ -22,6 +26,11 @@ extern void pgstat_reset_wait_event_storage(void); extern PGDLLIMPORT uint32 *my_wait_event_info; +#ifdef USE_WAIT_EVENT_TIMING +extern void pgstat_report_wait_start_timing(uint32 wait_event_info); +extern void pgstat_report_wait_end_timing(int capture_level); +#endif + /* * Wait Events - Extension, InjectionPoint @@ -61,6 +70,9 @@ extern char **GetWaitEventCustomNames(uint32 classId, int *nwaitevents); * * my_wait_event_info initially points to local memory, making it safe to * call this before MyProc has been initialized. + * + * When compiled with --enable-wait-event-timing, also records the start + * timestamp for later duration computation in pgstat_report_wait_end(). * ---------- */ static inline void @@ -71,17 +83,54 @@ pgstat_report_wait_start(uint32 wait_event_info) * four-bytes, updates are atomic. */ *(volatile uint32 *) my_wait_event_info = wait_event_info; + +#ifdef USE_WAIT_EVENT_TIMING + + /* + * Minimal inline gate: one global load and a branch. The body -- + * lazy/eager slot resolution, INSTR_TIME read, current-event write -- + * lives out-of-line in pgstat_report_wait_start_timing() so the many + * inlined call sites (LWLockAcquire, XLogInsert, ...) stay compact and + * the off-mode codegen impact is a load + test per site. + * + * No unlikely(): wait_event_capture is monomorphic for long stretches, so + * the dynamic branch predictor handles it perfectly with or without the + * hint, and a hint would point the wrong way once capture is on. + */ + if (wait_event_capture != WAIT_EVENT_CAPTURE_OFF) + pgstat_report_wait_start_timing(wait_event_info); +#endif } /* ---------- * pgstat_report_wait_end() - * * Called to report end of a wait. + * + * When compiled with --enable-wait-event-timing and the GUC is enabled, + * calls the out-of-line pgstat_report_wait_end_timing() to compute the + * wait duration and accumulate statistics. * ---------- */ static inline void pgstat_report_wait_end(void) { +#ifdef USE_WAIT_EVENT_TIMING + /* + * The load of wait_event_capture is reused as the argument to + * pgstat_report_wait_end_timing(), so the out-of-line body does not have + * to re-load it across the call boundary (CSE doesn't cross function + * calls). See pgstat_report_wait_start() for the no-unlikely() + * rationale. + */ + { + int capture_level = wait_event_capture; + + if (capture_level != WAIT_EVENT_CAPTURE_OFF) + pgstat_report_wait_end_timing(capture_level); + } +#endif + /* see pgstat_report_wait_start() */ *(volatile uint32 *) my_wait_event_info = 0; } diff --git a/src/include/utils/wait_event_timing.h b/src/include/utils/wait_event_timing.h index b44d19ecf355e..87b444d4763e8 100644 --- a/src/include/utils/wait_event_timing.h +++ b/src/include/utils/wait_event_timing.h @@ -3,14 +3,19 @@ * wait_event_timing.h * Per-backend wait event timing instrumentation. * - * This header declares the public surface of the wait-event-timing - * feature, gated by the compile-time option --enable-wait-event-timing - * (USE_WAIT_EVENT_TIMING) and the runtime GUC wait_event_capture. + * When enabled via the wait_event_capture GUC, every transition through + * pgstat_report_wait_start()/pgstat_report_wait_end() records the wait + * duration and accumulates per-(backend, event) statistics -- count, + * total/maximum duration, and a log2 duration histogram -- in shared + * memory. Each backend writes only to its own slot, so the hot path + * needs no locking; cross-backend readers (the pg_stat_wait_event_timing + * SRF) read lock-free and tolerate torn reads of 64-bit fields on 32-bit + * platforms, which is acceptable for statistics. * - * This commit introduces only the scaffolding: the capture-level enum - * and the GUC backing variable. Later commits in the series add the - * recording hot path, the SQL-visible statistics, and the per-session - * trace ring. + * The per-backend slot array lives in the main shared memory segment, + * sized at postmaster start (see WaitEventTimingShmemCallbacks). It is + * therefore valid for the entire life of every backend, including the + * proc_exit cascade -- no lazy attach and no teardown gating are needed. * * Copyright (c) 2026, PostgreSQL Global Development Group * @@ -20,7 +25,8 @@ #ifndef WAIT_EVENT_TIMING_H #define WAIT_EVENT_TIMING_H -#include "c.h" +#include "portability/instr_time.h" +#include "utils/wait_event_types.h" /* * Capture levels for the wait_event_capture GUC. Order is significant: @@ -28,7 +34,8 @@ * test for activation with "level >= WAIT_EVENT_CAPTURE_STATS". * * OFF - No instrumentation, no hot-path cost. - * STATS - Aggregated per-event statistics (added by a later commit). + * STATS - Aggregated per-event statistics (counts, durations, histogram) + * exposed via pg_stat_wait_event_timing. * * A further TRACE level is added later in the series. */ @@ -36,7 +43,7 @@ typedef enum WaitEventCaptureLevel { WAIT_EVENT_CAPTURE_OFF = 0, WAIT_EVENT_CAPTURE_STATS, -} WaitEventCaptureLevel; +} WaitEventCaptureLevel; /* * Pin the enum ordering at compile time so future code that compares with @@ -47,7 +54,123 @@ StaticAssertDecl(WAIT_EVENT_CAPTURE_OFF == 0 && WAIT_EVENT_CAPTURE_STATS == 1, "WaitEventCaptureLevel values must be 0=OFF < 1=STATS"); -/* GUC variable (defined in wait_event_timing.c, even in stub builds). */ +/* + * Number of log2 histogram buckets. Bin edges are powers of two on the + * nanosecond axis: bucket 0 covers [0, 1024) ns, bucket i covers + * [2^(i+9), 2^(i+10)) ns, and the last bucket is open-ended at + * [2^(NBUCKETS+8), inf) ns. These boundaries approximate the + * decimal-microsecond grid (1024 ~ 1 us, 2048 ~ 2 us, ...), which lets + * wait_event_timing_bucket() avoid a divide on the hot path. + * + * 32 buckets cover from <1us through the open-ended overflow at 2^40 ns + * (~18 minutes), so the long tail (lock contention, vacuum, replication + * apply, noisy-neighbour I/O spikes) lands in a real bucket rather than a + * single overflow bin -- which is exactly where tail/P99 analysis pays + * off. + */ +#define WAIT_EVENT_TIMING_HISTOGRAM_BUCKETS 32 + +/* Sentinel returned by wait_event_timing_index() for LWLock events. */ +#define WAIT_EVENT_TIMING_IDX_LWLOCK (-2) + +/* + * Per-event accumulated statistics. One entry per distinct wait event per + * backend, written only by the owning backend. + */ +typedef struct WaitEventTimingEntry +{ + int64 count; /* number of occurrences */ + int64 total_ns; /* total wait duration in nanoseconds */ + int64 max_ns; /* longest single wait in nanoseconds */ + int64 histogram[WAIT_EVENT_TIMING_HISTOGRAM_BUCKETS]; +} WaitEventTimingEntry; + +/* + * Sentinel marking an empty LWLock-hash slot. We reserve the top of the + * uint16 range (0xFFFF) rather than 0 so that any legal tranche id -- + * including the currently-unused tranche 0 -- can be stored and matched. + */ +#define LWLOCK_TIMING_EMPTY_SLOT ((uint16) 0xFFFF) + +/* + * Open-addressing hash slot mapping an LWLock tranche id to a dense index + * into the per-backend lwlock_events[] array. Per-backend, single-writer. + */ +typedef struct LWLockTimingHashEntry +{ + uint16 tranche_id; /* LWLOCK_TIMING_EMPTY_SLOT marks empty */ + uint16 dense_idx; /* index into lwlock_events[] */ +} LWLockTimingHashEntry; + +/* + * Header for the per-backend LWLock-timing hash. The slot table and the + * dense events array follow the WaitEventTimingState in memory (their + * lengths are runtime-determined by wait_event_timing_max_tranches), so + * they are not struct members; resolve them via the helpers in + * wait_event_timing.c. + */ +typedef struct LWLockTimingHash +{ + int num_used; /* count of occupied entries */ + int hash_size; /* slot-table size (power of two); immutable */ + int max_entries; /* cap on distinct tranches; immutable */ +} LWLockTimingHash; + +/* Declaration of the GUC (see guc_parameters.dat). */ +extern PGDLLIMPORT int wait_event_timing_max_tranches; + +/* + * Per-backend wait event timing state. One slot per + * MaxBackends + NUM_AUXILIARY_PROCS, written exclusively by the owning + * backend. Shared-memory layout of one slot: + * + * [ WaitEventTimingState header ] + * [ LWLockTimingHashEntry[hash_size] ] + * [ WaitEventTimingEntry[max_entries] <- lwlock_events[] ] + * + * where hash_size and max_entries are runtime-derived from the GUC + * wait_event_timing_max_tranches and recorded in lwlock_hash. Slots are + * laid out contiguously in the main shared memory segment using a runtime + * stride rather than C array indexing, since the per-backend size is + * determined at server start. + */ +typedef struct WaitEventTimingState +{ + /* Current wait start timestamp (set by pgstat_report_wait_start). */ + instr_time wait_start; + + /* Current wait_event_info (cached for use in wait_end). */ + uint32 current_event; + + /* Per-event statistics: flat array for bounded classes. */ + WaitEventTimingEntry events[WAIT_EVENT_TIMING_NUM_EVENTS]; + + /* Per-event statistics: hash for the LWLock class (unbounded ids). */ + LWLockTimingHash lwlock_hash; + + /* + * Count of LWLock events dropped because the LWLock-timing hash reached + * its cap (wait_event_timing_max_tranches). Written here; a later commit + * in the series exposes it via SQL. + */ + int64 lwlock_overflow_count; + + /* Count of events dropped because the class index was out of range. */ + int64 flat_overflow_count; +} WaitEventTimingState; + +/* GUC variables (see guc_parameters.dat). */ extern PGDLLIMPORT int wait_event_capture; +/* Pointer to this backend's timing state in shared memory. */ +extern PGDLLIMPORT WaitEventTimingState *my_wait_event_timing; + +/* + * Called from InitProcess()/InitAuxiliaryProcess() to point + * my_wait_event_timing at this backend's slot, and from ProcKill() to + * clear it. + */ +extern void pgstat_set_wait_event_timing_storage(int procNumber); +extern void pgstat_reset_wait_event_timing_storage(void); + #endif /* WAIT_EVENT_TIMING_H */ diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out index 6a3341356da1f..54fee3f420655 100644 --- a/src/test/regress/expected/rules.out +++ b/src/test/regress/expected/rules.out @@ -2426,6 +2426,17 @@ pg_stat_user_tables| SELECT relid, stats_reset FROM pg_stat_all_tables WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text)); +pg_stat_wait_event_timing| SELECT pid, + backend_type, + procnumber, + wait_event_type, + wait_event, + calls, + total_time_ms, + avg_time_us, + max_time_us, + histogram + FROM pg_stat_get_wait_event_timing(NULL::integer) t(pid, backend_type, procnumber, wait_event_type, wait_event, calls, total_time_ms, avg_time_us, max_time_us, histogram); pg_stat_wal| SELECT wal_records, wal_fpi, wal_bytes, @@ -2902,6 +2913,11 @@ pg_views| SELECT n.nspname AS schemaname, FROM (pg_class c LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))) WHERE (c.relkind = 'v'::"char"); +pg_wait_event_timing_histogram_buckets| SELECT bucket_idx, + lower_ns, + upper_ns, + label + FROM ( VALUES (0,(0)::bigint,(1024)::bigint,'<1us'::text), (1,(1024)::bigint,(2048)::bigint,'1-2us'::text), (2,(2048)::bigint,(4096)::bigint,'2-4us'::text), (3,(4096)::bigint,(8192)::bigint,'4-8us'::text), (4,(8192)::bigint,(16384)::bigint,'8-16us'::text), (5,(16384)::bigint,(32768)::bigint,'16-32us'::text), (6,(32768)::bigint,(65536)::bigint,'32-64us'::text), (7,(65536)::bigint,(131072)::bigint,'64-128us'::text), (8,(131072)::bigint,(262144)::bigint,'128-256us'::text), (9,(262144)::bigint,(524288)::bigint,'256-512us'::text), (10,(524288)::bigint,(1048576)::bigint,'512us-1ms'::text), (11,(1048576)::bigint,(2097152)::bigint,'1-2ms'::text), (12,(2097152)::bigint,(4194304)::bigint,'2-4ms'::text), (13,(4194304)::bigint,(8388608)::bigint,'4-8ms'::text), (14,(8388608)::bigint,(16777216)::bigint,'8-16ms'::text), (15,(16777216)::bigint,(33554432)::bigint,'16-32ms'::text), (16,(33554432)::bigint,(67108864)::bigint,'32-64ms'::text), (17,(67108864)::bigint,(134217728)::bigint,'64-128ms'::text), (18,(134217728)::bigint,(268435456)::bigint,'128-256ms'::text), (19,(268435456)::bigint,(536870912)::bigint,'256-512ms'::text), (20,(536870912)::bigint,(1073741824)::bigint,'512ms-1s'::text), (21,(1073741824)::bigint,'2147483648'::bigint,'1-2s'::text), (22,'2147483648'::bigint,'4294967296'::bigint,'2-4s'::text), (23,'4294967296'::bigint,'8589934592'::bigint,'4-8s'::text), (24,'8589934592'::bigint,'17179869184'::bigint,'8-16s'::text), (25,'17179869184'::bigint,'34359738368'::bigint,'16-32s'::text), (26,'34359738368'::bigint,'68719476736'::bigint,'32-64s'::text), (27,'68719476736'::bigint,'137438953472'::bigint,'64-128s'::text), (28,'137438953472'::bigint,'274877906944'::bigint,'128-256s'::text), (29,'274877906944'::bigint,'549755813888'::bigint,'256-512s'::text), (30,'549755813888'::bigint,'1099511627776'::bigint,'512s-1024s'::text), (31,'1099511627776'::bigint,NULL::bigint,'>=1024s'::text)) t(bucket_idx, lower_ns, upper_ns, label); pg_wait_events| SELECT type, name, description diff --git a/src/test/regress/expected/wait_event_timing.out b/src/test/regress/expected/wait_event_timing.out new file mode 100644 index 0000000000000..925b315c4e856 --- /dev/null +++ b/src/test/regress/expected/wait_event_timing.out @@ -0,0 +1,84 @@ +-- +-- WAIT_EVENT_TIMING +-- +-- Exercises the wait_event_capture = stats instrumentation: the GUC, the +-- pg_stat_get_wait_event_timing() SRF, the pg_stat_wait_event_timing view, +-- and the pg_wait_event_timing_histogram_buckets taxonomy view. +-- +-- Two expected outputs are maintained: +-- wait_event_timing.out -- --enable-wait-event-timing builds +-- wait_event_timing_1.out -- builds without the option (stub) +-- The difference is whether SET wait_event_capture = stats succeeds and +-- whether the SRF records anything; durations are never printed, so the +-- timing-build output is deterministic. +-- +-- Default is off. +SHOW wait_event_capture; + wait_event_capture +-------------------- + off +(1 row) + +-- The taxonomy view is pure SQL and identical in both build configs. +SELECT count(*) AS buckets FROM pg_wait_event_timing_histogram_buckets; + buckets +--------- + 32 +(1 row) + +SELECT bucket_idx, lower_ns, upper_ns, label +FROM pg_wait_event_timing_histogram_buckets +WHERE bucket_idx IN (0, 1, 31) +ORDER BY bucket_idx; + bucket_idx | lower_ns | upper_ns | label +------------+---------------+----------+--------- + 0 | 0 | 1024 | <1us + 1 | 1024 | 2048 | 1-2us + 31 | 1099511627776 | | >=1024s +(3 rows) + +-- Enable stats capture and generate a deterministic wait: pg_sleep emits a +-- Timeout / PgSleep wait of ~0.1s. (In a stub build the SET errors and the +-- SRF stays empty; that is the documented difference between the two +-- expected files.) +SET wait_event_capture = stats; +SELECT pg_sleep(0.1); + pg_sleep +---------- + +(1 row) + +-- PgSleep must now be recorded for this backend, with the per-event +-- invariants holding. We print only booleans so the output is stable. +SELECT calls >= 1 AS calls_ok, + calls = (SELECT sum(h) FROM unnest(histogram) AS h) AS hist_sum_eq_calls, + total_time_ms > 0 AS total_positive, + max_time_us > 0 AS max_positive, + array_length(histogram, 1) + = (SELECT count(*)::int FROM pg_wait_event_timing_histogram_buckets) + AS histogram_len_ok +FROM pg_stat_get_wait_event_timing(pg_backend_pid()) +WHERE wait_event = 'PgSleep'; + calls_ok | hist_sum_eq_calls | total_positive | max_positive | histogram_len_ok +----------+-------------------+----------------+--------------+------------------ + t | t | t | t | t +(1 row) + +-- The view surfaces the same row (type/name only; durations omitted). +SELECT wait_event_type, wait_event +FROM pg_stat_wait_event_timing +WHERE pid = pg_backend_pid() AND wait_event = 'PgSleep'; + wait_event_type | wait_event +-----------------+------------ + Timeout | PgSleep +(1 row) + +-- A non-NULL pid that does not exist yields no rows (silent, not an error). +SELECT count(*) AS rows_for_bogus_pid +FROM pg_stat_get_wait_event_timing(-1); + rows_for_bogus_pid +-------------------- + 0 +(1 row) + +RESET wait_event_capture; diff --git a/src/test/regress/expected/wait_event_timing_1.out b/src/test/regress/expected/wait_event_timing_1.out new file mode 100644 index 0000000000000..038a85656578b --- /dev/null +++ b/src/test/regress/expected/wait_event_timing_1.out @@ -0,0 +1,85 @@ +-- +-- WAIT_EVENT_TIMING +-- +-- Exercises the wait_event_capture = stats instrumentation: the GUC, the +-- pg_stat_get_wait_event_timing() SRF, the pg_stat_wait_event_timing view, +-- and the pg_wait_event_timing_histogram_buckets taxonomy view. +-- +-- Two expected outputs are maintained: +-- wait_event_timing.out -- --enable-wait-event-timing builds +-- wait_event_timing_1.out -- builds without the option (stub) +-- The difference is whether SET wait_event_capture = stats succeeds and +-- whether the SRF records anything; durations are never printed, so the +-- timing-build output is deterministic. +-- +-- Default is off. +SHOW wait_event_capture; + wait_event_capture +-------------------- + off +(1 row) + +-- The taxonomy view is pure SQL and identical in both build configs. +SELECT count(*) AS buckets FROM pg_wait_event_timing_histogram_buckets; + buckets +--------- + 32 +(1 row) + +SELECT bucket_idx, lower_ns, upper_ns, label +FROM pg_wait_event_timing_histogram_buckets +WHERE bucket_idx IN (0, 1, 31) +ORDER BY bucket_idx; + bucket_idx | lower_ns | upper_ns | label +------------+---------------+----------+--------- + 0 | 0 | 1024 | <1us + 1 | 1024 | 2048 | 1-2us + 31 | 1099511627776 | | >=1024s +(3 rows) + +-- Enable stats capture and generate a deterministic wait: pg_sleep emits a +-- Timeout / PgSleep wait of ~0.1s. (In a stub build the SET errors and the +-- SRF stays empty; that is the documented difference between the two +-- expected files.) +SET wait_event_capture = stats; +ERROR: invalid value for parameter "wait_event_capture": "stats" +DETAIL: This build does not support wait event capture. +HINT: Compile PostgreSQL with --enable-wait-event-timing. +SELECT pg_sleep(0.1); + pg_sleep +---------- + +(1 row) + +-- PgSleep must now be recorded for this backend, with the per-event +-- invariants holding. We print only booleans so the output is stable. +SELECT calls >= 1 AS calls_ok, + calls = (SELECT sum(h) FROM unnest(histogram) AS h) AS hist_sum_eq_calls, + total_time_ms > 0 AS total_positive, + max_time_us > 0 AS max_positive, + array_length(histogram, 1) + = (SELECT count(*)::int FROM pg_wait_event_timing_histogram_buckets) + AS histogram_len_ok +FROM pg_stat_get_wait_event_timing(pg_backend_pid()) +WHERE wait_event = 'PgSleep'; + calls_ok | hist_sum_eq_calls | total_positive | max_positive | histogram_len_ok +----------+-------------------+----------------+--------------+------------------ +(0 rows) + +-- The view surfaces the same row (type/name only; durations omitted). +SELECT wait_event_type, wait_event +FROM pg_stat_wait_event_timing +WHERE pid = pg_backend_pid() AND wait_event = 'PgSleep'; + wait_event_type | wait_event +-----------------+------------ +(0 rows) + +-- A non-NULL pid that does not exist yields no rows (silent, not an error). +SELECT count(*) AS rows_for_bogus_pid +FROM pg_stat_get_wait_event_timing(-1); + rows_for_bogus_pid +-------------------- + 0 +(1 row) + +RESET wait_event_capture; diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule index 8fa0a6c47fb30..b5e6712a1e83d 100644 --- a/src/test/regress/parallel_schedule +++ b/src/test/regress/parallel_schedule @@ -130,6 +130,10 @@ test: partition_merge partition_split partition_join partition_prune reloptions # ---------- test: compression compression_lz4 compression_pglz cluster +# wait_event_timing reads only the calling backend's own stats, so it is +# safe on its own line; pg_sleep keeps its runtime small. +test: wait_event_timing + # event_trigger depends on create_am and cannot run concurrently with # any test that runs DDL # oidjoins is read-only, though, and should run late for best coverage diff --git a/src/test/regress/sql/wait_event_timing.sql b/src/test/regress/sql/wait_event_timing.sql new file mode 100644 index 0000000000000..da15dbbe4950b --- /dev/null +++ b/src/test/regress/sql/wait_event_timing.sql @@ -0,0 +1,54 @@ +-- +-- WAIT_EVENT_TIMING +-- +-- Exercises the wait_event_capture = stats instrumentation: the GUC, the +-- pg_stat_get_wait_event_timing() SRF, the pg_stat_wait_event_timing view, +-- and the pg_wait_event_timing_histogram_buckets taxonomy view. +-- +-- Two expected outputs are maintained: +-- wait_event_timing.out -- --enable-wait-event-timing builds +-- wait_event_timing_1.out -- builds without the option (stub) +-- The difference is whether SET wait_event_capture = stats succeeds and +-- whether the SRF records anything; durations are never printed, so the +-- timing-build output is deterministic. +-- + +-- Default is off. +SHOW wait_event_capture; + +-- The taxonomy view is pure SQL and identical in both build configs. +SELECT count(*) AS buckets FROM pg_wait_event_timing_histogram_buckets; +SELECT bucket_idx, lower_ns, upper_ns, label +FROM pg_wait_event_timing_histogram_buckets +WHERE bucket_idx IN (0, 1, 31) +ORDER BY bucket_idx; + +-- Enable stats capture and generate a deterministic wait: pg_sleep emits a +-- Timeout / PgSleep wait of ~0.1s. (In a stub build the SET errors and the +-- SRF stays empty; that is the documented difference between the two +-- expected files.) +SET wait_event_capture = stats; +SELECT pg_sleep(0.1); + +-- PgSleep must now be recorded for this backend, with the per-event +-- invariants holding. We print only booleans so the output is stable. +SELECT calls >= 1 AS calls_ok, + calls = (SELECT sum(h) FROM unnest(histogram) AS h) AS hist_sum_eq_calls, + total_time_ms > 0 AS total_positive, + max_time_us > 0 AS max_positive, + array_length(histogram, 1) + = (SELECT count(*)::int FROM pg_wait_event_timing_histogram_buckets) + AS histogram_len_ok +FROM pg_stat_get_wait_event_timing(pg_backend_pid()) +WHERE wait_event = 'PgSleep'; + +-- The view surfaces the same row (type/name only; durations omitted). +SELECT wait_event_type, wait_event +FROM pg_stat_wait_event_timing +WHERE pid = pg_backend_pid() AND wait_event = 'PgSleep'; + +-- A non-NULL pid that does not exist yields no rows (silent, not an error). +SELECT count(*) AS rows_for_bogus_pid +FROM pg_stat_get_wait_event_timing(-1); + +RESET wait_event_capture; diff --git a/src/tools/pginclude/headerscheck b/src/tools/pginclude/headerscheck index 785d6f867ad09..eda4599de7096 100755 --- a/src/tools/pginclude/headerscheck +++ b/src/tools/pginclude/headerscheck @@ -125,6 +125,8 @@ do test "$f" = src/include/nodes/nodetags.h && continue test "$f" = src/backend/nodes/nodetags.h && continue test "$f" = src/include/storage/checksum_block_internal.h && continue + test "$f" = src/include/utils/wait_event_timing_data.h && continue + test "$f" = src/backend/utils/wait_event_timing_data.h && continue # These files are not meant to be included standalone, because # they contain lists that might have multiple use-cases. diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 95c3be5dc57a0..7443ecadd78e5 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -1615,6 +1615,8 @@ LWLock LWLockHandle LWLockMode LWLockPadded +LWLockTimingHash +LWLockTimingHashEntry LWLockTrancheShmemData LZ4F_compressionContext_t LZ4F_decompressOptions_t @@ -3428,6 +3430,8 @@ WaitEventIO WaitEventIPC WaitEventSet WaitEventTimeout +WaitEventTimingEntry +WaitEventTimingState WaitLSNProcInfo WaitLSNResult WaitLSNState From 6f7067c1b00f2e3445470f1d39825232adb38442 Mon Sep 17 00:00:00 2001 From: Dmitry Fomin Date: Tue, 9 Jun 2026 16:35:24 +0000 Subject: [PATCH 39/43] wait_event_timing: expose overflow counters and add reset functions Surface the per-backend truncation counters maintained by the recording path, and add the ability to reset wait-event-timing statistics. pg_stat_get_wait_event_timing_overflow(pid) and the pg_stat_wait_event_timing_overflow view report, per backend, lwlock_overflow_count (LWLock waits dropped because the per-backend tranche hash was full), flat_overflow_count (events whose class index was out of range), and reset_count. Resets use a lock-free request/response so the hot path stays single writer: each slot carries an atomic reset_generation, bumped by the resetter; the owning backend compares it against a backend-local last-seen value at its next wait_end and clears its own counters, incrementing reset_count. pg_stat_reset_wait_event_timing(pid) resets one backend -- synchronously when it targets the caller's own session (any user; pid defaults to NULL), or asynchronously for another backend (requiring pg_signal_backend, matching pg_stat_reset_backend_stats). pg_stat_reset_wait_event_timing_all() resets every backend and is superuser-only. Builds without --enable-wait-event-timing keep empty-result/feature-not- supported stubs for the new functions. --- doc/src/sgml/config.sgml | 14 +- doc/src/sgml/monitoring.sgml | 178 +++++++++++++ src/backend/catalog/system_views.sql | 17 ++ .../utils/activity/wait_event_timing.c | 242 ++++++++++++++++++ src/include/catalog/pg_proc.dat | 23 ++ src/include/utils/wait_event_timing.h | 21 +- src/test/regress/expected/rules.out | 7 + .../regress/expected/wait_event_timing.out | 51 ++++ .../regress/expected/wait_event_timing_1.out | 40 +++ src/test/regress/sql/wait_event_timing.sql | 25 ++ 10 files changed, 611 insertions(+), 7 deletions(-) diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index ae95737a6c7ae..dcee5390389cb 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -9205,13 +9205,17 @@ GRANT SET ON PARAMETER wait_event_capture TO pg_monitor; Sets the maximum number of distinct LWLock tranches whose timing is recorded individually per backend. PostgreSQL maintains a per-backend hash table mapping each tranche the backend encounters - to its histogram; once the table fills, further tranches are not - individually timed. Sized at server start; this parameter has no - effect on builds compiled without + to its histogram; once the table fills, further tranches are + counted against lwlock_overflow_count in + + pg_stat_wait_event_timing_overflow + and not individually timed. Sized at server start; this parameter + has no effect on builds compiled without . The default is 192; raise it if your installation loads many - extensions that register their own LWLock tranches. This parameter - can only be set at server start. + extensions that register their own LWLock tranches and you observe + a non-zero lwlock_overflow_count. This + parameter can only be set at server start. diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml index 40b52b710b5a7..bf22ec4175a2d 100644 --- a/doc/src/sgml/monitoring.sgml +++ b/doc/src/sgml/monitoring.sgml @@ -580,6 +580,15 @@ postgres 27093 0.0 0.0 30096 2752 ? Ss 11:34 0:00 postgres: ser
+ + pg_stat_wait_event_timing_overflowpg_stat_wait_event_timing_overflow + Per-backend truncation and reset counters for the wait-event + timing subsystem. See + + pg_stat_wait_event_timing_overflow for details. + + + @@ -4261,6 +4270,124 @@ ORDER BY b.bucket_idx; + + <structname>pg_stat_wait_event_timing_overflow</structname> + + + pg_stat_wait_event_timing_overflow + + + + The pg_stat_wait_event_timing_overflow view + exposes per-backend truncation counters for the wait-event timing + subsystem. Each backend owns a bounded LWLock timing hash + ( tranches) and a bounded flat event array; events that cannot + be mapped to a slot are counted here. A non-zero value means the + corresponding row(s) in + + pg_stat_wait_event_timing + are incomplete for that backend. Requires the server to be + compiled with . + + + + <structname>pg_stat_wait_event_timing_overflow</structname> View + + + + + Column Type + + + Description + + + + + + + + pid integer + + + Process ID of the backend + + + + + + backend_type text + + + Type of the backend (e.g. client backend, + checkpointer, walwriter) + + + + + + procnumber integer + + + Internal slot number (0-based process number) of the backend. + + + + + + lwlock_overflow_count bigint + + + Number of LWLock wait events dropped because the per-backend + LWLock timing hash was already full (more distinct tranches + observed in this session than + allows). + Zero means no LWLock truncation. A one-time + WARNING is also emitted to the server log on + first overflow. If you see this counter rising, raise + wait_event_timing_max_tranches at server + start (the per-backend memory cost is proportional and + described under that GUC). + + + + + + flat_overflow_count bigint + + + Number of non-LWLock wait events dropped because the event + could not be mapped to a known class / index. This almost + always indicates a code path emitting a wait event of a class + the timing infrastructure was not compiled for; it should be + zero in supported builds. + + + + + + reset_count bigint + + + Number of resets this backend has observed and acted + on; not a request counter. Own-backend resets via + pg_stat_reset_wait_event_timing(NULL) (or + passing the caller's own PID) are synchronous and bump this + column once per call. Cross-backend reset requests + coalesce: if several + pg_stat_reset_wait_event_timing(pid) + calls land between two of the target's wait events, the target + observes them as a single reset and increments + reset_count only once. Callers + polling for asynchronous-reset acknowledgment should watch for + any N → N+1 transition. + + + + +
+
+ <structname>pg_stat_database</structname> @@ -6317,6 +6444,57 @@ ORDER BY b.bucket_idx;
+ + + + pg_stat_reset_wait_event_timing + + pg_stat_reset_wait_event_timing ( pid integer DEFAULT NULL ) + void + + + Resets wait event timing counters for a single backend, identified + by its process ID (see pid in + + pg_stat_activity). + Passing NULL (or the caller's own + pg_backend_pid()) resets the current session; + any user may do this. Passing any other PID resets that backend + and requires membership in the + pg_signal_backend + role — the same role required by + pg_stat_reset_backend_stats, + pg_terminate_backend, and + pg_cancel_backend. Unknown or + already-exited PIDs are silent no-ops, matching the behavior of + pg_stat_reset_backend_stats. + + + + + + + pg_stat_reset_wait_event_timing_all + + pg_stat_reset_wait_event_timing_all () + void + + + Resets wait event timing counters for every backend in the + cluster. Requires superuser. This is intentionally stricter + than the per-backend variant + pg_stat_reset_wait_event_timing(pid), + which only requires pg_signal_backend: the + cluster-wide form has unbounded blast radius (it affects every + backend in a single call) and would erase forensic patterns + that span multiple backends, so it is gated to the cluster + owner. Returns before the resets have been observed by their + target backends; callers that need strict read-after-reset + semantics should poll each target's + reset_count column. + + + diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql index 640b4dc61da3e..2ef679f7fc66f 100644 --- a/src/backend/catalog/system_views.sql +++ b/src/backend/catalog/system_views.sql @@ -1630,3 +1630,20 @@ CREATE VIEW pg_stat_wait_event_timing AS FROM pg_stat_get_wait_event_timing(NULL) t; REVOKE ALL ON pg_stat_wait_event_timing FROM PUBLIC; GRANT SELECT ON pg_stat_wait_event_timing TO pg_read_all_stats; + +CREATE VIEW pg_stat_wait_event_timing_overflow AS + SELECT + t.pid, + t.backend_type, + t.procnumber, + t.lwlock_overflow_count, + t.flat_overflow_count, + t.reset_count + FROM pg_stat_get_wait_event_timing_overflow(NULL) t; +REVOKE ALL ON pg_stat_wait_event_timing_overflow FROM PUBLIC; +GRANT SELECT ON pg_stat_wait_event_timing_overflow TO pg_read_all_stats; + +-- Cluster-scope operations: revoked from PUBLIC (administrators can +-- delegate with GRANT EXECUTE); not granted to pg_read_all_stats because +-- they mutate state rather than read it. +REVOKE EXECUTE ON FUNCTION pg_stat_reset_wait_event_timing_all() FROM PUBLIC; diff --git a/src/backend/utils/activity/wait_event_timing.c b/src/backend/utils/activity/wait_event_timing.c index 97f01993bb7b7..6e50f3db16b5f 100644 --- a/src/backend/utils/activity/wait_event_timing.c +++ b/src/backend/utils/activity/wait_event_timing.c @@ -61,6 +61,9 @@ const struct config_enum_entry wait_event_capture_options[] = { #include "funcapi.h" Datum pg_stat_get_wait_event_timing(PG_FUNCTION_ARGS); +Datum pg_stat_get_wait_event_timing_overflow(PG_FUNCTION_ARGS); +Datum pg_stat_reset_wait_event_timing(PG_FUNCTION_ARGS); +Datum pg_stat_reset_wait_event_timing_all(PG_FUNCTION_ARGS); Datum pg_stat_get_wait_event_timing(PG_FUNCTION_ARGS) @@ -69,6 +72,33 @@ pg_stat_get_wait_event_timing(PG_FUNCTION_ARGS) PG_RETURN_VOID(); } +Datum +pg_stat_get_wait_event_timing_overflow(PG_FUNCTION_ARGS) +{ + InitMaterializedSRF(fcinfo, 0); + PG_RETURN_VOID(); +} + +Datum +pg_stat_reset_wait_event_timing(PG_FUNCTION_ARGS) +{ + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("wait event capture is not supported by this build"), + errhint("Compile PostgreSQL with --enable-wait-event-timing."))); + PG_RETURN_VOID(); +} + +Datum +pg_stat_reset_wait_event_timing_all(PG_FUNCTION_ARGS) +{ + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("wait event capture is not supported by this build"), + errhint("Compile PostgreSQL with --enable-wait-event-timing."))); + PG_RETURN_VOID(); +} + /* * GUC check hook for the stub build. Any value other than 'off' is * meaningless without --enable-wait-event-timing, so reject it -- or @@ -125,6 +155,7 @@ pgstat_reset_wait_event_timing_storage(void) #include "funcapi.h" #include "miscadmin.h" #include "port/pg_bitutils.h" +#include "storage/latch.h" #include "storage/proc.h" #include "storage/procarray.h" #include "storage/procnumber.h" @@ -144,6 +175,14 @@ pgstat_reset_wait_event_timing_storage(void) /* Pointer to this backend's timing state in shared memory. */ WaitEventTimingState *my_wait_event_timing = NULL; +/* + * Backend-local copy of the last reset generation this backend acted on. + * Compared against the shared reset_generation at every wait_end; when they + * differ, the owning backend performs the reset of its own counters on + * behalf of whoever called pg_stat_reset_wait_event_timing(target). + */ +static uint32 my_last_reset_generation = 0; + /* * Backend-local cached pointer to the start of the shared slot array, set * at shmem init (postmaster) and, in EXEC_BACKEND mode, at attach. Slots @@ -407,6 +446,7 @@ WaitEventTimingShmemInit(void *arg) LWLockTimingHashEntry *entries; int j; + pg_atomic_init_u32(&slot->reset_generation, 0); slot->lwlock_hash.num_used = 0; slot->lwlock_hash.hash_size = wait_event_timing_hash_size; slot->lwlock_hash.max_entries = wait_event_timing_max_entries; @@ -449,9 +489,17 @@ pgstat_set_wait_event_timing_storage(int procNumber) lwlock_timing_hash_clear(slot); slot->lwlock_overflow_count = 0; slot->flat_overflow_count = 0; + slot->reset_count = 0; slot->current_event = 0; INSTR_TIME_SET_ZERO(slot->wait_start); + /* + * Adopt the current shared reset generation as our baseline; the + * reset_generation counter persists across slot reuse, so a new backend + * must not treat the prior occupant's resets as its own. + */ + my_last_reset_generation = pg_atomic_read_u32(&slot->reset_generation); + /* Publish only after the slot is fully initialised. */ my_wait_event_timing = slot; } @@ -524,6 +572,7 @@ void pgstat_report_wait_end_timing(int capture_level) { uint32 event; + uint32 cur_reset_gen; (void) capture_level; @@ -532,6 +581,27 @@ pgstat_report_wait_end_timing(int capture_level) event = my_wait_event_timing->current_event; + /* + * Service a pending cross-backend reset request. A single relaxed atomic + * load; when the shared generation has advanced past the value we last + * acted on, clear our own counters on behalf of the requester and record + * the reset. wait_start is left untouched so the in-flight measurement + * still lands (in the freshly-zeroed counters), and current_event is + * zeroed so external readers do not see stale state. + */ + cur_reset_gen = pg_atomic_read_u32(&my_wait_event_timing->reset_generation); + if (cur_reset_gen != my_last_reset_generation) + { + memset(my_wait_event_timing->events, 0, + sizeof(my_wait_event_timing->events)); + lwlock_timing_hash_clear(my_wait_event_timing); + my_wait_event_timing->reset_count++; + my_wait_event_timing->lwlock_overflow_count = 0; + my_wait_event_timing->flat_overflow_count = 0; + my_wait_event_timing->current_event = 0; + my_last_reset_generation = cur_reset_gen; + } + if (event != 0 && !INSTR_TIME_IS_ZERO(my_wait_event_timing->wait_start)) { instr_time now; @@ -758,4 +828,176 @@ pg_stat_get_wait_event_timing(PG_FUNCTION_ARGS) PG_RETURN_VOID(); } +/* + * SQL function: pg_stat_get_wait_event_timing_overflow(pid int4, OUT ...) + * + * Exposes the per-backend truncation counters that the recording path + * maintains: lwlock_overflow_count (LWLock waits dropped because the + * per-backend tranche hash was full), flat_overflow_count (events whose + * class index was out of range), and reset_count (resets the backend has + * observed and acted on). pid has the same optional semantics as + * pg_stat_get_wait_event_timing(). + */ +Datum +pg_stat_get_wait_event_timing_overflow(PG_FUNCTION_ARGS) +{ + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + int start_idx; + int end_idx; + int backend_idx; + + InitMaterializedSRF(fcinfo, 0); + + if (WaitEventTimingArray == NULL) + PG_RETURN_VOID(); + + if (!wait_event_timing_pid_range(fcinfo, &start_idx, &end_idx)) + PG_RETURN_VOID(); + + for (backend_idx = start_idx; backend_idx < end_idx; backend_idx++) + { + WaitEventTimingState *state = wet_slot(backend_idx); + PgBackendStatus *beentry; + Datum values[6]; + bool nulls[6]; + + beentry = pgstat_get_beentry_by_proc_number(backend_idx); + if (beentry == NULL) + continue; + if (!HAS_PGSTAT_PERMISSIONS(beentry->st_userid)) + continue; + + memset(nulls, 0, sizeof(nulls)); + + values[0] = Int32GetDatum(beentry->st_procpid); + values[1] = CStringGetTextDatum(GetBackendTypeDesc(beentry->st_backendType)); + values[2] = Int32GetDatum(backend_idx); + values[3] = Int64GetDatum(state->lwlock_overflow_count); + values[4] = Int64GetDatum(state->flat_overflow_count); + values[5] = Int64GetDatum(state->reset_count); + + tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls); + } + + PG_RETURN_VOID(); +} + +/* + * Request a cross-backend self-reset on the given slot: bump the slot's + * reset_generation and wake the target so it promptly observes the change + * and clears its own counters at its next wait_end. Lock-free: only the + * owning backend ever writes its statistics. + */ +static void +wait_event_timing_request_reset(int slot_idx) +{ + Assert(slot_idx >= 0 && slot_idx < NUM_WAIT_EVENT_TIMING_SLOTS); + + if (WaitEventTimingArray == NULL) + return; + + pg_atomic_fetch_add_u32(&wet_slot(slot_idx)->reset_generation, 1); + + /* + * The slot index is also the PGPROC array index. Waking the target + * shortens the time before it completes its current wait and notices the + * request; setting a latch on a slot with no live owner is harmless. + */ + if (ProcGlobal != NULL && ProcGlobal->allProcs != NULL) + SetLatch(&ProcGlobal->allProcs[slot_idx].procLatch); +} + +/* + * SQL function: pg_stat_reset_wait_event_timing(pid int4) + * + * NULL or own pid : reset the caller's own counters synchronously. + * another pid : request a cross-backend reset (pg_signal_backend). + * unknown pid : silent no-op. + * + * Cross-backend resets are asynchronous: the target clears its counters at + * its next wait_end. Callers needing read-after-reset semantics should + * target their own backend, or poll reset_count in + * pg_stat_wait_event_timing_overflow until it increments. + */ +Datum +pg_stat_reset_wait_event_timing(PG_FUNCTION_ARGS) +{ + int target_pid; + PGPROC *proc; + int procNumber; + + if (PG_ARGISNULL(0) || PG_GETARG_INT32(0) == MyProcPid) + { + /* + * Own backend: synchronous, no lock needed (single writer). + * wait_start is already zero (every wait_end zeroes it and we cannot + * be mid-wait while running this function), so there is no in-flight + * measurement to preserve. + */ + if (my_wait_event_timing != NULL) + { + memset(my_wait_event_timing->events, 0, + sizeof(my_wait_event_timing->events)); + lwlock_timing_hash_clear(my_wait_event_timing); + my_wait_event_timing->reset_count++; + my_wait_event_timing->lwlock_overflow_count = 0; + my_wait_event_timing->flat_overflow_count = 0; + my_wait_event_timing->current_event = 0; + } + PG_RETURN_VOID(); + } + + /* + * Cross-backend reset requires pg_signal_backend, matching + * pg_stat_reset_backend_stats(pid): anyone who can terminate the target + * backend can already destroy more forensic state than a counter wipe. + */ + if (!has_privs_of_role(GetUserId(), ROLE_PG_SIGNAL_BACKEND)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("permission denied to reset another backend's wait event timing"), + errdetail("Only roles with privileges of the \"pg_signal_backend\" role may reset another backend's wait event timing."))); + + target_pid = PG_GETARG_INT32(0); + + proc = BackendPidGetProc(target_pid); + if (proc == NULL) + proc = AuxiliaryPidGetProc(target_pid); + if (proc == NULL) + PG_RETURN_VOID(); /* unknown/dead pid: silent no-op */ + + procNumber = GetNumberFromPGProc(proc); + if (procNumber < 0 || procNumber >= NUM_WAIT_EVENT_TIMING_SLOTS) + PG_RETURN_VOID(); + + wait_event_timing_request_reset(procNumber); + + PG_RETURN_VOID(); +} + +/* + * SQL function: pg_stat_reset_wait_event_timing_all() + * + * Request a reset on every backend. Execution is revoked from PUBLIC by + * default (the blast radius is the whole cluster, a different decision + * from the per-backend variant); administrators can delegate with GRANT. + */ +Datum +pg_stat_reset_wait_event_timing_all(PG_FUNCTION_ARGS) +{ + int i; + + /* + * Execution is revoked from PUBLIC in system_views.sql; administrators + * can delegate with GRANT EXECUTE. + */ + if (WaitEventTimingArray == NULL) + PG_RETURN_VOID(); + + for (i = 0; i < NUM_WAIT_EVENT_TIMING_SLOTS; i++) + wait_event_timing_request_reset(i); + + PG_RETURN_VOID(); +} + #endif /* USE_WAIT_EVENT_TIMING */ diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index 0f6c2e16cb7fa..3c33a44bfe69e 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -12725,4 +12725,27 @@ proargnames => '{pid,pid,backend_type,procnumber,wait_event_type,wait_event,calls,total_time_ms,avg_time_us,max_time_us,histogram}', prosrc => 'pg_stat_get_wait_event_timing' }, +{ oid => '9958', + descr => 'statistics: reset wait event timing counters for the given backend (NULL = own)', + proname => 'pg_stat_reset_wait_event_timing', proisstrict => 'f', + provolatile => 'v', prorettype => 'void', proargtypes => 'int4', + proargnames => '{pid}', proargdefaults => '{NULL}', + prosrc => 'pg_stat_reset_wait_event_timing' }, + +{ oid => '9959', + descr => 'statistics: per-backend wait event timing overflow counters (rows lost to LWLock hash / flat array overflow)', + proname => 'pg_stat_get_wait_event_timing_overflow', prorows => '1000', + proisstrict => 'f', proretset => 't', provolatile => 's', proparallel => 'r', + prorettype => 'record', proargtypes => 'int4', + proallargtypes => '{int4,int4,text,int4,int8,int8,int8}', + proargmodes => '{i,o,o,o,o,o,o}', + proargnames => '{pid,pid,backend_type,procnumber,lwlock_overflow_count,flat_overflow_count,reset_count}', + prosrc => 'pg_stat_get_wait_event_timing_overflow' }, + +{ oid => '9960', + descr => 'statistics: reset wait event timing counters for all backends (superuser only)', + proname => 'pg_stat_reset_wait_event_timing_all', + provolatile => 'v', prorettype => 'void', proargtypes => '', + prosrc => 'pg_stat_reset_wait_event_timing_all' }, + ] diff --git a/src/include/utils/wait_event_timing.h b/src/include/utils/wait_event_timing.h index 87b444d4763e8..fdfeea6ce1a05 100644 --- a/src/include/utils/wait_event_timing.h +++ b/src/include/utils/wait_event_timing.h @@ -25,6 +25,7 @@ #ifndef WAIT_EVENT_TIMING_H #define WAIT_EVENT_TIMING_H +#include "port/atomics.h" #include "portability/instr_time.h" #include "utils/wait_event_types.h" @@ -136,12 +137,28 @@ extern PGDLLIMPORT int wait_event_timing_max_tranches; */ typedef struct WaitEventTimingState { + /* + * Generation counter for cross-backend reset requests. Bumped atomically + * by pg_stat_reset_wait_event_timing(target); the owning backend notices + * the change at its next wait_end and clears its own counters. This + * keeps the hot path lock-free: only the owning backend ever writes its + * statistics, so there is no writer/resetter race. + */ + pg_atomic_uint32 reset_generation; + /* Current wait start timestamp (set by pgstat_report_wait_start). */ instr_time wait_start; /* Current wait_event_info (cached for use in wait_end). */ uint32 current_event; + /* + * Number of resets this backend has observed and acted on. Own-backend + * resets are synchronous (one bump per call); cross-backend resets + * coalesce (multiple requests between two wait_ends count as one). + */ + int64 reset_count; + /* Per-event statistics: flat array for bounded classes. */ WaitEventTimingEntry events[WAIT_EVENT_TIMING_NUM_EVENTS]; @@ -150,8 +167,8 @@ typedef struct WaitEventTimingState /* * Count of LWLock events dropped because the LWLock-timing hash reached - * its cap (wait_event_timing_max_tranches). Written here; a later commit - * in the series exposes it via SQL. + * its cap (wait_event_timing_max_tranches). Exposed via + * pg_stat_wait_event_timing_overflow. */ int64 lwlock_overflow_count; diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out index 54fee3f420655..bfb84304e39da 100644 --- a/src/test/regress/expected/rules.out +++ b/src/test/regress/expected/rules.out @@ -2437,6 +2437,13 @@ pg_stat_wait_event_timing| SELECT pid, max_time_us, histogram FROM pg_stat_get_wait_event_timing(NULL::integer) t(pid, backend_type, procnumber, wait_event_type, wait_event, calls, total_time_ms, avg_time_us, max_time_us, histogram); +pg_stat_wait_event_timing_overflow| SELECT pid, + backend_type, + procnumber, + lwlock_overflow_count, + flat_overflow_count, + reset_count + FROM pg_stat_get_wait_event_timing_overflow(NULL::integer) t(pid, backend_type, procnumber, lwlock_overflow_count, flat_overflow_count, reset_count); pg_stat_wal| SELECT wal_records, wal_fpi, wal_bytes, diff --git a/src/test/regress/expected/wait_event_timing.out b/src/test/regress/expected/wait_event_timing.out index 925b315c4e856..8938ffe5fb063 100644 --- a/src/test/regress/expected/wait_event_timing.out +++ b/src/test/regress/expected/wait_event_timing.out @@ -81,4 +81,55 @@ FROM pg_stat_get_wait_event_timing(-1); 0 (1 row) +-- Overflow/reset counters for this backend. A simple test backend uses +-- few LWLock tranches and no out-of-range classes, so both overflow +-- counters are zero, and a fresh backend has not been reset. +SELECT lwlock_overflow_count, flat_overflow_count, reset_count +FROM pg_stat_wait_event_timing_overflow +WHERE pid = pg_backend_pid(); + lwlock_overflow_count | flat_overflow_count | reset_count +-----------------------+---------------------+------------- + 0 | 0 | 0 +(1 row) + +-- Resetting our own backend is synchronous: the PgSleep row is cleared and +-- reset_count advances. (We filter to PgSleep because inter-command waits +-- such as ClientRead may be recorded again before the next statement runs.) +SELECT pg_stat_reset_wait_event_timing(NULL); + pg_stat_reset_wait_event_timing +--------------------------------- + +(1 row) + +SELECT count(*) AS pgsleep_rows_after_reset +FROM pg_stat_wait_event_timing +WHERE pid = pg_backend_pid() AND wait_event = 'PgSleep'; + pgsleep_rows_after_reset +-------------------------- + 0 +(1 row) + +SELECT reset_count +FROM pg_stat_wait_event_timing_overflow +WHERE pid = pg_backend_pid(); + reset_count +------------- + 1 +(1 row) + +-- The pid argument defaults to NULL, so a no-argument call resets the +-- caller's own backend. +SELECT pg_stat_reset_wait_event_timing(); + pg_stat_reset_wait_event_timing +--------------------------------- + +(1 row) + +-- Resetting an unknown pid is a silent no-op, not an error. +SELECT pg_stat_reset_wait_event_timing(2147483647); + pg_stat_reset_wait_event_timing +--------------------------------- + +(1 row) + RESET wait_event_capture; diff --git a/src/test/regress/expected/wait_event_timing_1.out b/src/test/regress/expected/wait_event_timing_1.out index 038a85656578b..3aad898d9bf0f 100644 --- a/src/test/regress/expected/wait_event_timing_1.out +++ b/src/test/regress/expected/wait_event_timing_1.out @@ -82,4 +82,44 @@ FROM pg_stat_get_wait_event_timing(-1); 0 (1 row) +-- Overflow/reset counters for this backend. A simple test backend uses +-- few LWLock tranches and no out-of-range classes, so both overflow +-- counters are zero, and a fresh backend has not been reset. +SELECT lwlock_overflow_count, flat_overflow_count, reset_count +FROM pg_stat_wait_event_timing_overflow +WHERE pid = pg_backend_pid(); + lwlock_overflow_count | flat_overflow_count | reset_count +-----------------------+---------------------+------------- +(0 rows) + +-- Resetting our own backend is synchronous: the PgSleep row is cleared and +-- reset_count advances. (We filter to PgSleep because inter-command waits +-- such as ClientRead may be recorded again before the next statement runs.) +SELECT pg_stat_reset_wait_event_timing(NULL); +ERROR: wait event capture is not supported by this build +HINT: Compile PostgreSQL with --enable-wait-event-timing. +SELECT count(*) AS pgsleep_rows_after_reset +FROM pg_stat_wait_event_timing +WHERE pid = pg_backend_pid() AND wait_event = 'PgSleep'; + pgsleep_rows_after_reset +-------------------------- + 0 +(1 row) + +SELECT reset_count +FROM pg_stat_wait_event_timing_overflow +WHERE pid = pg_backend_pid(); + reset_count +------------- +(0 rows) + +-- The pid argument defaults to NULL, so a no-argument call resets the +-- caller's own backend. +SELECT pg_stat_reset_wait_event_timing(); +ERROR: wait event capture is not supported by this build +HINT: Compile PostgreSQL with --enable-wait-event-timing. +-- Resetting an unknown pid is a silent no-op, not an error. +SELECT pg_stat_reset_wait_event_timing(2147483647); +ERROR: wait event capture is not supported by this build +HINT: Compile PostgreSQL with --enable-wait-event-timing. RESET wait_event_capture; diff --git a/src/test/regress/sql/wait_event_timing.sql b/src/test/regress/sql/wait_event_timing.sql index da15dbbe4950b..a30fcd155fd13 100644 --- a/src/test/regress/sql/wait_event_timing.sql +++ b/src/test/regress/sql/wait_event_timing.sql @@ -51,4 +51,29 @@ WHERE pid = pg_backend_pid() AND wait_event = 'PgSleep'; SELECT count(*) AS rows_for_bogus_pid FROM pg_stat_get_wait_event_timing(-1); +-- Overflow/reset counters for this backend. A simple test backend uses +-- few LWLock tranches and no out-of-range classes, so both overflow +-- counters are zero, and a fresh backend has not been reset. +SELECT lwlock_overflow_count, flat_overflow_count, reset_count +FROM pg_stat_wait_event_timing_overflow +WHERE pid = pg_backend_pid(); + +-- Resetting our own backend is synchronous: the PgSleep row is cleared and +-- reset_count advances. (We filter to PgSleep because inter-command waits +-- such as ClientRead may be recorded again before the next statement runs.) +SELECT pg_stat_reset_wait_event_timing(NULL); +SELECT count(*) AS pgsleep_rows_after_reset +FROM pg_stat_wait_event_timing +WHERE pid = pg_backend_pid() AND wait_event = 'PgSleep'; +SELECT reset_count +FROM pg_stat_wait_event_timing_overflow +WHERE pid = pg_backend_pid(); + +-- The pid argument defaults to NULL, so a no-argument call resets the +-- caller's own backend. +SELECT pg_stat_reset_wait_event_timing(); + +-- Resetting an unknown pid is a silent no-op, not an error. +SELECT pg_stat_reset_wait_event_timing(2147483647); + RESET wait_event_capture; From c49c7cc7dd8837b322ddd848a103d30df32d750b Mon Sep 17 00:00:00 2001 From: Dmitry Fomin Date: Tue, 9 Jun 2026 19:22:22 +0000 Subject: [PATCH 40/43] ci: build one task with --enable-wait-event-timing Add --enable-wait-event-timing to the "Linux - Autoconf" GitHub Actions task so the wait-event-timing build path -- including the expected output src/test/regress/expected/wait_event_timing.out -- is exercised on every push. That task already runs check-world under the undefined/alignment sanitizers with a small segment size, giving the timing code meaningful coverage. Every other CI task keeps building without the flag, so the stub path and its alternate output wait_event_timing_1.out remain covered as well. --- .github/workflows/pg-ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/pg-ci.yml b/.github/workflows/pg-ci.yml index a2629c8335a1b..318b417c7b733 100644 --- a/.github/workflows/pg-ci.yml +++ b/.github/workflows/pg-ci.yml @@ -499,10 +499,17 @@ jobs: - name: Configure shell: *su_postgres_shell + # --enable-wait-event-timing is added on this one task so the + # wait-event-timing build path -- including the expected output at + # src/test/regress/expected/wait_event_timing.out -- is exercised by + # CI on every push. The other tasks build without the flag, so the + # stub path and its alternate output wait_event_timing_1.out stay + # covered as well. run: | ./configure \ --enable-cassert --enable-injection-points --enable-debug \ --enable-tap-tests --enable-nls \ + --enable-wait-event-timing \ --with-segsize-blocks=6 \ --with-libnuma \ --with-liburing \ From 2cee54cbdf4c7067205422d26b2c3e61f46c3038 Mon Sep 17 00:00:00 2001 From: Dmitry Fomin Date: Thu, 11 Jun 2026 08:32:11 +0000 Subject: [PATCH 41/43] wait_event_timing: allocate the per-backend array lazily in DSA Convert the per-backend wait-event-timing slot array from eager main-segment shared memory to a lazily-allocated DSA region. Only a small control struct (a DSA handle plus an LWLock) now lives in fixed shared memory; the large array -- ~30 KB per backend at the default wait_event_timing_max_tranches -- is allocated the first time any backend in the cluster sets wait_event_capture to a non-off value. A build that compiles the feature in but never enables it therefore pays no per-backend memory, and a SELECT against the views on a cluster that never enabled capture does not even create the DSA. This is a pure refactor: the SQL surface and observable behavior are unchanged, and the existing regression tests pass without modification. Backends attach to the array on their first wait event under capture, in pgstat_wait_event_timing_lazy_attach(). Because that runs from the wait-event hot path, it carries the guards that make DSA work safe there: - skip while CritSectionCount > 0 (dsa_attach -> MemoryContextAlloc asserts inside a critical section); - skip while MyProc->lwWaiting != LW_WS_NOT_WAITING (a nested LWLockQueueSelf on the control lock would PANIC); - an in_attach re-entrancy guard, because dsa_create / dsa_allocate / the control-lock acquisition can themselves emit LWLock wait events that re-enter the hot path; - a before_shmem_exit gate (wait_event_timing_writes_disabled) so the hot path stops touching DSA once proc_exit begins tearing the mappings down -- shmem_exit runs all before_shmem_exit callbacks before dsm_backend_shutdown, so the gate is up before any unmap. A new LWLock tranche, WaitEventTimingDSA, names the control lock. --- .../utils/activity/wait_event_names.txt | 1 + .../utils/activity/wait_event_timing.c | 448 ++++++++++++++---- src/include/storage/lwlocklist.h | 1 + src/include/utils/wait_event_timing.h | 15 +- src/tools/pgindent/typedefs.list | 1 + 5 files changed, 379 insertions(+), 87 deletions(-) diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt index 1016502d042eb..f48dc93fc2f37 100644 --- a/src/backend/utils/activity/wait_event_names.txt +++ b/src/backend/utils/activity/wait_event_names.txt @@ -417,6 +417,7 @@ XactSLRU "Waiting to access the transaction status SLRU cache." ParallelVacuumDSA "Waiting for parallel vacuum dynamic shared memory allocation." AioUringCompletion "Waiting for another process to complete IO via io_uring." ShmemIndex "Waiting to find or allocate space in shared memory." +WaitEventTimingDSA "Waiting for wait event timing dynamic shared memory allocation." # No "ABI_compatibility" region here as WaitEventLWLock has its own C code. diff --git a/src/backend/utils/activity/wait_event_timing.c b/src/backend/utils/activity/wait_event_timing.c index 6e50f3db16b5f..48e417d147027 100644 --- a/src/backend/utils/activity/wait_event_timing.c +++ b/src/backend/utils/activity/wait_event_timing.c @@ -9,10 +9,11 @@ * nanoseconds, and a log2 duration histogram -- in shared memory. Each * backend writes only to its own slot, so the hot path needs no locking. * - * The per-backend slot array lives in the main shared memory segment, - * sized at postmaster start from wait_event_timing_max_tranches, so it is - * valid for the entire life of every backend -- no lazy attach and no - * teardown gating are required. + * The per-backend slot array is allocated lazily in a DSA the first time + * any backend in the cluster enables capture, so a build that compiles the + * feature in but never enables it pays no per-backend memory. Backends + * attach on their first wait event under capture; a before_shmem_exit gate + * keeps the hot path away from DSA mappings that proc_exit has torn down. * * Controlled by the wait_event_capture GUC (off | stats, default off) and * the compile-time option --enable-wait-event-timing. In builds without @@ -155,7 +156,9 @@ pgstat_reset_wait_event_timing_storage(void) #include "funcapi.h" #include "miscadmin.h" #include "port/pg_bitutils.h" +#include "storage/ipc.h" #include "storage/latch.h" +#include "storage/lwlock.h" #include "storage/proc.h" #include "storage/procarray.h" #include "storage/procnumber.h" @@ -163,6 +166,7 @@ pgstat_reset_wait_event_timing_storage(void) #include "utils/array.h" #include "utils/backend_status.h" #include "utils/builtins.h" +#include "utils/dsa.h" #include "utils/tuplestore.h" #include "utils/wait_event.h" @@ -184,10 +188,38 @@ WaitEventTimingState *my_wait_event_timing = NULL; static uint32 my_last_reset_generation = 0; /* - * Backend-local cached pointer to the start of the shared slot array, set - * at shmem init (postmaster) and, in EXEC_BACKEND mode, at attach. Slots - * are NOT a simple C array: each has a runtime-determined stride (header + - * variable-size hash arrays); use wet_slot() to index. + * DSA-based control struct in fixed shared memory. The large per-backend + * WaitEventTimingState array is allocated lazily in DSA the first time any + * backend in the cluster sets wait_event_capture != off, so a build that + * compiles the feature in but never enables it pays no per-backend memory. + */ +typedef struct WaitEventTimingControl +{ + LWLock lock; /* protects first-time DSA create + array + * alloc */ + dsa_handle timing_dsa_handle; /* DSA_HANDLE_INVALID until first enable */ + dsa_pointer timing_array; /* InvalidDsaPointer until first enable */ +} WaitEventTimingControl; + +static WaitEventTimingControl *WaitEventTimingCtl = NULL; +static dsa_area *timing_dsa = NULL; + +/* + * Per-backend gate raised by the before_shmem_exit callback once proc_exit + * begins tearing down DSA mappings (dsm_backend_shutdown runs as a later + * on_shmem_exit callback). Once set, the wait-event hot path skips all + * timing work -- including the lazy re-attach branch -- so it cannot + * dereference my_wait_event_timing or run DSA primitives on already-detached + * memory. Per-backend (process-local), so the hot-path check is a single + * cache-warm load. + */ +static bool wait_event_timing_writes_disabled = false; + +/* + * Backend-local cached pointer to the start of the shared slot array, set on + * first lazy-attach. Slots are NOT a simple C array: each has a + * runtime-determined stride (header + variable-size hash arrays); use + * wet_slot() to index. */ static char *WaitEventTimingArray = NULL; @@ -240,17 +272,183 @@ wait_event_timing_slot_size(int max_entries) mul_size(max_entries, sizeof(WaitEventTimingEntry)))); } -/* Cache the backend-local layout dimensions from the GUC (idempotent). */ +/* + * Ensure this backend is attached to the timing DSA. The DSA is created by + * whichever backend first reaches here with an empty control struct; + * subsequent callers attach to the existing handle. The backend-local + * dsa_area pointer is cached in timing_dsa for the backend's lifetime. + */ static void -wait_event_timing_init_local_dims(void) +wait_event_timing_ensure_dsa(void) { - if (wait_event_timing_per_backend_stride != 0) + MemoryContext oldcontext; + + if (timing_dsa != NULL) return; - wait_event_timing_max_entries = wait_event_timing_max_tranches; - wait_event_timing_hash_size = - wait_event_timing_hash_size_for(wait_event_timing_max_entries); - wait_event_timing_per_backend_stride = - wait_event_timing_slot_size(wait_event_timing_max_entries); + + if (WaitEventTimingCtl == NULL) + return; /* pre-ShmemInit; nothing to attach to */ + + oldcontext = MemoryContextSwitchTo(TopMemoryContext); + + LWLockAcquire(&WaitEventTimingCtl->lock, LW_EXCLUSIVE); + + if (WaitEventTimingCtl->timing_dsa_handle == DSA_HANDLE_INVALID) + { + timing_dsa = dsa_create(LWTRANCHE_WAIT_EVENT_TIMING_DSA); + dsa_pin(timing_dsa); + dsa_pin_mapping(timing_dsa); + WaitEventTimingCtl->timing_dsa_handle = dsa_get_handle(timing_dsa); + } + else + { + timing_dsa = dsa_attach(WaitEventTimingCtl->timing_dsa_handle); + dsa_pin_mapping(timing_dsa); + } + + LWLockRelease(&WaitEventTimingCtl->lock); + + MemoryContextSwitchTo(oldcontext); +} + +/* + * Attach this backend to the shared per-backend slot array, allocating it in + * DSA on first use when allocate_if_missing is true. Returns true if the + * array is now available (WaitEventTimingArray non-NULL). Readers pass + * false so a SELECT against an empty view does not force a big allocation; + * the hot path passes true so the first wait event under capture != off + * creates the storage. + * + * Re-entrancy guard: dsa_create / dsa_allocate / the LWLockAcquire inside + * ensure_dsa can themselves emit LWLock wait events, which feed back into + * the wait-end timing hot path, which lazy-attaches by calling this + * function. The in_attach guard prevents deadlock on the control lock and + * recursion with a half-initialised pointer. + */ +static bool +wait_event_timing_attach_array(bool allocate_if_missing) +{ + static bool in_attach = false; + bool attached = false; + + if (WaitEventTimingArray != NULL) + return true; + + if (WaitEventTimingCtl == NULL) + return false; + + /* + * Reader fast path: if no backend has ever enabled capture, the DSA was + * never created. Return without calling ensure_dsa() so a plain SELECT + * against an empty view does not create (and pin for the postmaster's + * lifetime) an otherwise-unused DSA segment. Only the first enabler + * (allocate_if_missing) brings the DSA into existence. + */ + if (!allocate_if_missing && + WaitEventTimingCtl->timing_dsa_handle == DSA_HANDLE_INVALID) + return false; + + if (in_attach) + return false; + + in_attach = true; + PG_TRY(); + { + wait_event_timing_ensure_dsa(); + + if (WaitEventTimingCtl->timing_array == InvalidDsaPointer) + { + if (!allocate_if_missing) + { + attached = false; + } + else + { + int max_entries; + int hash_size; + Size stride; + Size total; + + /* + * Snapshot the GUC once for the cluster-wide first-enable + * allocation; every slot shares these dimensions for the + * cluster's lifetime (the GUC is PGC_POSTMASTER). + */ + max_entries = wait_event_timing_max_tranches; + hash_size = wait_event_timing_hash_size_for(max_entries); + stride = wait_event_timing_slot_size(max_entries); + total = mul_size(NUM_WAIT_EVENT_TIMING_SLOTS, stride); + + LWLockAcquire(&WaitEventTimingCtl->lock, LW_EXCLUSIVE); + + if (WaitEventTimingCtl->timing_array == InvalidDsaPointer) + { + dsa_pointer p; + char *region; + int i; + + p = dsa_allocate_extended(timing_dsa, total, DSA_ALLOC_ZERO); + region = (char *) dsa_get_address(timing_dsa, p); + + for (i = 0; i < NUM_WAIT_EVENT_TIMING_SLOTS; i++) + { + WaitEventTimingState *slot; + LWLockTimingHashEntry *slot_entries; + int j; + + slot = (WaitEventTimingState *) (region + (Size) i * stride); + + pg_atomic_init_u32(&slot->reset_generation, 0); + slot->lwlock_hash.num_used = 0; + slot->lwlock_hash.hash_size = hash_size; + slot->lwlock_hash.max_entries = max_entries; + + /* + * DSA_ALLOC_ZERO zeroed the region, but the empty + * sentinel is 0xFFFF, not 0. + */ + slot_entries = (LWLockTimingHashEntry *) + ((char *) slot + sizeof(WaitEventTimingState)); + for (j = 0; j < hash_size; j++) + slot_entries[j].tranche_id = LWLOCK_TIMING_EMPTY_SLOT; + } + + WaitEventTimingCtl->timing_array = p; + } + + LWLockRelease(&WaitEventTimingCtl->lock); + attached = true; + } + } + else + attached = true; + + if (attached) + { + WaitEventTimingState *first; + + WaitEventTimingArray = (char *) + dsa_get_address(timing_dsa, WaitEventTimingCtl->timing_array); + + /* + * Recover the dimensions from the first slot's header (all slots + * share them) and cache the stride so wet_slot() is a single + * multiply-and-add. + */ + first = (WaitEventTimingState *) WaitEventTimingArray; + wait_event_timing_max_entries = first->lwlock_hash.max_entries; + wait_event_timing_hash_size = first->lwlock_hash.hash_size; + wait_event_timing_per_backend_stride = + wait_event_timing_slot_size(wait_event_timing_max_entries); + } + } + PG_FINALLY(); + { + in_attach = false; + } + PG_END_TRY(); + + return WaitEventTimingArray != NULL; } /* Resolve the address of slot `idx` within WaitEventTimingArray. */ @@ -411,51 +609,25 @@ wait_event_timing_bucket(int64 duration_ns) } /* - * ShmemRequest: reserve the per-backend slot array. Sized from - * wait_event_timing_max_tranches; the framework stores the allocated - * address in WaitEventTimingArray before WaitEventTimingShmemInit runs. + * ShmemRequest/Init: reserve only the small control struct in fixed shmem. + * The large per-backend array is allocated lazily in DSA on first enable. */ static void WaitEventTimingShmemRequest(void *arg) { - Size stride; - - wait_event_timing_init_local_dims(); - stride = wait_event_timing_per_backend_stride; - - ShmemRequestStruct(.name = "WaitEventTimingArray", - .size = mul_size(NUM_WAIT_EVENT_TIMING_SLOTS, stride), - .ptr = (void **) &WaitEventTimingArray); + ShmemRequestStruct(.name = "WaitEventTimingControl", + .size = sizeof(WaitEventTimingControl), + .ptr = (void **) &WaitEventTimingCtl); } -/* ShmemInit: zero the array and initialise each slot's hash header. */ static void WaitEventTimingShmemInit(void *arg) { - int i; - - wait_event_timing_init_local_dims(); - - memset(WaitEventTimingArray, 0, - mul_size(NUM_WAIT_EVENT_TIMING_SLOTS, - wait_event_timing_per_backend_stride)); - - for (i = 0; i < NUM_WAIT_EVENT_TIMING_SLOTS; i++) - { - WaitEventTimingState *slot = wet_slot(i); - LWLockTimingHashEntry *entries; - int j; - - pg_atomic_init_u32(&slot->reset_generation, 0); - slot->lwlock_hash.num_used = 0; - slot->lwlock_hash.hash_size = wait_event_timing_hash_size; - slot->lwlock_hash.max_entries = wait_event_timing_max_entries; - - /* The array was zeroed above, but the empty sentinel is 0xFFFF. */ - entries = wet_lwlock_hash_entries(slot); - for (j = 0; j < wait_event_timing_hash_size; j++) - entries[j].tranche_id = LWLOCK_TIMING_EMPTY_SLOT; - } + LWLockInitialize(&WaitEventTimingCtl->lock, + LWTRANCHE_WAIT_EVENT_TIMING_DSA); + WaitEventTimingCtl->timing_dsa_handle = DSA_HANDLE_INVALID; + WaitEventTimingCtl->timing_array = InvalidDsaPointer; + WaitEventTimingArray = NULL; } const ShmemCallbacks WaitEventTimingShmemCallbacks = { @@ -464,50 +636,137 @@ const ShmemCallbacks WaitEventTimingShmemCallbacks = { }; /* - * Point my_wait_event_timing at this backend's slot. Called from - * InitProcess()/InitAuxiliaryProcess() once the backend has a procNumber. - * The slot is cleared here so stats do not leak across slot reuse when a - * new backend inherits a procNumber previously held by an exited one. + * before_shmem_exit callback: disable the inline hot path for the rest of + * proc_exit, so it does not dereference my_wait_event_timing or attempt a + * fresh lazy-attach after dsm_backend_shutdown has unmapped the DSA segment + * behind the slot. We do NOT null my_wait_event_timing here: a NULL pointer + * would route the hot path through the lazy-attach branch, which then + * re-attaches using DSA primitives that operate on already-detached memory. */ -void -pgstat_set_wait_event_timing_storage(int procNumber) +static void +pgstat_wait_event_timing_before_shmem_exit(int code, Datum arg) { + wait_event_timing_writes_disabled = true; +} + +/* + * Point my_wait_event_timing at this backend's slot, allocating the DSA + * array on first call. Reached from the hot path the first time this + * backend observes wait_event_capture != off; after the first successful + * attach the cached pointer stays valid for the backend's lifetime, so this + * is a cold branch. + */ +static void +pgstat_wait_event_timing_lazy_attach(void) +{ + int procNumber; WaitEventTimingState *slot; - if (procNumber < 0 || procNumber >= NUM_WAIT_EVENT_TIMING_SLOTS || - WaitEventTimingArray == NULL) - { - my_wait_event_timing = NULL; + if (my_wait_event_timing != NULL) + return; + + if (MyProc == NULL) + return; + + /* + * Skip during backend startup. InitPostgres runs in InitProcessing mode + * (PostgresMain switches to NormalProcessing only after it returns), and a + * wait event can fire from InitPostgres's own transaction cleanup -- + * CommitTransactionCommand -> LockReleaseAll -> LWLockAcquire -- while the + * current resource owner is already mid-release. The attach path's + * dsm_create_descriptor() would then call ResourceOwnerEnlarge() after + * release has started and FATAL. Skipping here keeps the backend alive; + * the first wait once it reaches normal processing attaches successfully. + */ + if (!IsNormalProcessingMode()) + return; + + /* + * Lazy attach allocates memory (dsa_attach -> dsm_attach -> + * MemoryContextAlloc), which Assert-fails inside a critical section. A + * backend's very first wait event after capture is enabled can land in + * one (e.g. a parallel worker in XLogInsert). Skipping silently drops + * that in-flight event but keeps the backend alive; the next wait event + * outside any critical section attaches successfully. + */ + if (CritSectionCount > 0) return; - } - wait_event_timing_init_local_dims(); + /* + * Skip if MyProc is already on an LWLock wait queue: we run inside + * LWLockAcquire after LWLockQueueSelf set MyProc->lwWaiting, and our + * attach path's own LWLockAcquire would hit the "queueing while waiting + * on another lock" PANIC. The next wait outside an LWLock-wait context + * retries successfully. + */ + if (MyProc->lwWaiting != LW_WS_NOT_WAITING) + return; + + procNumber = GetNumberFromPGProc(MyProc); + if (procNumber < 0 || procNumber >= NUM_WAIT_EVENT_TIMING_SLOTS) + return; + + if (!wait_event_timing_attach_array(true)) + return; slot = wet_slot(procNumber); + /* + * Clear the slot before publishing it: the DSA region is zeroed at + * creation, but a later backend may inherit a slot from an exited one. + * Zero through the local `slot` first, THEN publish to + * my_wait_event_timing, so a non-NULL pointer always means the slot is + * ready for the very next store. + */ memset(slot->events, 0, sizeof(slot->events)); lwlock_timing_hash_clear(slot); + slot->reset_count = 0; slot->lwlock_overflow_count = 0; slot->flat_overflow_count = 0; - slot->reset_count = 0; slot->current_event = 0; INSTR_TIME_SET_ZERO(slot->wait_start); - /* - * Adopt the current shared reset generation as our baseline; the - * reset_generation counter persists across slot reuse, so a new backend - * must not treat the prior occupant's resets as its own. - */ my_last_reset_generation = pg_atomic_read_u32(&slot->reset_generation); /* Publish only after the slot is fully initialised. */ my_wait_event_timing = slot; + + /* + * Register a before_shmem_exit callback (once) to raise the + * writes-disabled gate before the DSA mappings go away. shmem_exit() + * runs all before_shmem_exit callbacks (which sets the gate) and only + * then calls dsm_backend_shutdown(), so the gate is guaranteed up before + * any DSA segment is unmapped -- independent of callback ordering. + */ + { + static bool registered = false; + + if (!registered) + { + before_shmem_exit(pgstat_wait_event_timing_before_shmem_exit, + (Datum) 0); + registered = true; + } + } } /* - * Detach from the timing slot on backend exit. The slot itself stays in - * shared memory; clearing the pointer keeps the late-shutdown wait-event - * hot path from touching it. + * Called from InitProcess()/InitAuxiliaryProcess(). In the lazy-DSA design + * there is no slot to point at yet -- the slot attaches on the first wait + * event under capture != off (pgstat_wait_event_timing_lazy_attach) -- so a + * backend that never enables capture pays zero per-backend memory. Just + * make sure the pointer starts NULL. + */ +void +pgstat_set_wait_event_timing_storage(int procNumber) +{ + my_wait_event_timing = NULL; +} + +/* + * Detach from the timing slot on backend exit. The slot stays in DSA; + * clearing the local pointer keeps the late-shutdown hot path from touching + * already-detached memory. */ void pgstat_reset_wait_event_timing_storage(void) @@ -552,11 +811,31 @@ assign_wait_event_capture(int newval, void *extra) void pgstat_report_wait_start_timing(uint32 wait_event_info) { - if (my_wait_event_timing == NULL) + /* + * Stay out of the timing path once proc_exit has begun tearing down DSA + * mappings (see the before_shmem_exit callback). + */ + if (wait_event_timing_writes_disabled) return; - INSTR_TIME_SET_CURRENT(my_wait_event_timing->wait_start); - my_wait_event_timing->current_event = wait_event_info; + if (my_wait_event_timing == NULL) + { + pgstat_wait_event_timing_lazy_attach(); + + /* + * lazy_attach can dispatch nested wait events while it sets up DSA + * (dsa_attach takes an internal LWLock); those nested wait_end calls + * clear my_wait_event_info to 0. Re-publish so the outer wait stays + * visible in pg_stat_activity. Only needed on the first-attach path. + */ + *(volatile uint32 *) my_wait_event_info = wait_event_info; + } + + if (my_wait_event_timing != NULL) + { + INSTR_TIME_SET_CURRENT(my_wait_event_timing->wait_start); + my_wait_event_timing->current_event = wait_event_info; + } } /* @@ -576,9 +855,16 @@ pgstat_report_wait_end_timing(int capture_level) (void) capture_level; - if (my_wait_event_timing == NULL) + if (wait_event_timing_writes_disabled) return; + if (my_wait_event_timing == NULL) + { + pgstat_wait_event_timing_lazy_attach(); + if (my_wait_event_timing == NULL) + return; + } + event = my_wait_event_timing->current_event; /* @@ -745,7 +1031,7 @@ pg_stat_get_wait_event_timing(PG_FUNCTION_ARGS) InitMaterializedSRF(fcinfo, 0); - if (WaitEventTimingArray == NULL) + if (!wait_event_timing_attach_array(false)) PG_RETURN_VOID(); if (!wait_event_timing_pid_range(fcinfo, &start_idx, &end_idx)) @@ -848,7 +1134,7 @@ pg_stat_get_wait_event_timing_overflow(PG_FUNCTION_ARGS) InitMaterializedSRF(fcinfo, 0); - if (WaitEventTimingArray == NULL) + if (!wait_event_timing_attach_array(false)) PG_RETURN_VOID(); if (!wait_event_timing_pid_range(fcinfo, &start_idx, &end_idx)) @@ -893,7 +1179,7 @@ wait_event_timing_request_reset(int slot_idx) { Assert(slot_idx >= 0 && slot_idx < NUM_WAIT_EVENT_TIMING_SLOTS); - if (WaitEventTimingArray == NULL) + if (!wait_event_timing_attach_array(false)) return; pg_atomic_fetch_add_u32(&wet_slot(slot_idx)->reset_generation, 1); @@ -991,7 +1277,7 @@ pg_stat_reset_wait_event_timing_all(PG_FUNCTION_ARGS) * Execution is revoked from PUBLIC in system_views.sql; administrators * can delegate with GRANT EXECUTE. */ - if (WaitEventTimingArray == NULL) + if (!wait_event_timing_attach_array(false)) PG_RETURN_VOID(); for (i = 0; i < NUM_WAIT_EVENT_TIMING_SLOTS; i++) diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h index d7eb648bd2758..f0a27019a114f 100644 --- a/src/include/storage/lwlocklist.h +++ b/src/include/storage/lwlocklist.h @@ -140,3 +140,4 @@ PG_LWLOCKTRANCHE(XACT_SLRU, XactSLRU) PG_LWLOCKTRANCHE(PARALLEL_VACUUM_DSA, ParallelVacuumDSA) PG_LWLOCKTRANCHE(AIO_URING_COMPLETION, AioUringCompletion) PG_LWLOCKTRANCHE(SHMEM_INDEX, ShmemIndex) +PG_LWLOCKTRANCHE(WAIT_EVENT_TIMING_DSA, WaitEventTimingDSA) diff --git a/src/include/utils/wait_event_timing.h b/src/include/utils/wait_event_timing.h index fdfeea6ce1a05..f1408dab279e8 100644 --- a/src/include/utils/wait_event_timing.h +++ b/src/include/utils/wait_event_timing.h @@ -12,10 +12,13 @@ * SRF) read lock-free and tolerate torn reads of 64-bit fields on 32-bit * platforms, which is acceptable for statistics. * - * The per-backend slot array lives in the main shared memory segment, - * sized at postmaster start (see WaitEventTimingShmemCallbacks). It is - * therefore valid for the entire life of every backend, including the - * proc_exit cascade -- no lazy attach and no teardown gating are needed. + * The per-backend slot array is allocated lazily in a DSA the first time + * any backend in the cluster sets wait_event_capture != off, so a build + * that compiles the feature in but never enables it pays no per-backend + * memory. Backends attach to the array on their first wait event under + * capture (pgstat_wait_event_timing_lazy_attach), and a before_shmem_exit + * gate stops the hot path from touching DSA mappings that proc_exit has + * already torn down. * * Copyright (c) 2026, PostgreSQL Global Development Group * @@ -131,9 +134,9 @@ extern PGDLLIMPORT int wait_event_timing_max_tranches; * * where hash_size and max_entries are runtime-derived from the GUC * wait_event_timing_max_tranches and recorded in lwlock_hash. Slots are - * laid out contiguously in the main shared memory segment using a runtime + * laid out contiguously in the lazily-allocated DSA region using a runtime * stride rather than C array indexing, since the per-backend size is - * determined at server start. + * determined at first enable. */ typedef struct WaitEventTimingState { diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 7443ecadd78e5..2d9d751ef7627 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -3430,6 +3430,7 @@ WaitEventIO WaitEventIPC WaitEventSet WaitEventTimeout +WaitEventTimingControl WaitEventTimingEntry WaitEventTimingState WaitLSNProcInfo From 95ed40e09b3e0b7d37b0b7384b84b14412ad46b7 Mon Sep 17 00:00:00 2001 From: Dmitry Fomin Date: Thu, 11 Jun 2026 15:10:36 +0000 Subject: [PATCH 42/43] wait_event_timing: add trace level with a per-session ring buffer Add the third capture level, wait_event_capture = trace. On top of the STATS aggregates, every completed wait is pushed into a per-session ring buffer in DSA -- one record per wait -- allocated lazily on first use so only sessions that enable trace pay the (default 4 MB) per-ring cost. The ring is exposed two ways: - pg_get_backend_wait_event_trace() / the pg_backend_wait_event_trace view read the calling backend's own ring; - pg_get_wait_event_trace(procnumber) reads any backend's ring cross-process, including rings left behind by exited backends. A backend's ring is not freed when it exits: the slot transitions to ORPHANED and the ring stays in DSA so cross-backend consumers can read the dying backend's final waits (important for short-lived parallel workers). An orphan is reclaimed when a new backend reuses the same procNumber (clear-on-init) or by pg_stat_clear_orphaned_wait_event_rings() (execution revoked from PUBLIC; delegable with GRANT). Slot transitions (FREE/OWNED/ORPHANED) are serialised by the WaitEventTraceControl lock; the ring size is fixed cluster-wide at server start by wait_event_trace_ring_size (power of two, default 4 MB). The single-writer hot path writes each record under a seqlock (odd seq while writing, even when complete). Cross-backend readers use a POSITION-ENCODED IDENTITY seqlock -- a record at ring index i is valid only if its seq equals the writer's complete value for that exact position -- which rejects stale previous-cycle reads that a parity-only seqlock would accept under cross-process visibility lag. A TAP test drives an injection point between the writer's write_pos advance and its seq stamp to prove exactly that: with the ring wrapped and the writer wedged mid-record, a cross-backend read returns ring_size - 1 records, skipping the in-flight slot whose stale prior-cycle record a parity-only check would have emitted. The trace ring writer carries the same teardown discipline as the stats path: a wait_event_trace_writes_disabled gate (raised around slot transitions and at proc_exit) plus in_attach/in_release re-entrancy guards keep the DSA-internal LWLock waits that those operations emit from recursing into a ring that is being freed or orphaned. A further re-entrancy guard on the record writer itself keeps wait events emitted mid-record-write (reachable only via the injection point; the write is plain stores in production) from recursing into the writer. Privileges: both trace SRFs and the session-local view are REVOKE'd from PUBLIC and GRANT'ed to pg_read_all_stats (reading a session's trace exposes its wait sequence). Query-attribution markers and their executor/protocol hooks are added in the next commit; this commit records wait events only. --- doc/src/sgml/config.sgml | 37 +- doc/src/sgml/monitoring.sgml | 301 ++- src/backend/catalog/system_views.sql | 24 + src/backend/postmaster/auxprocess.c | 11 + .../utils/activity/wait_event_names.txt | 1 + .../utils/activity/wait_event_timing.c | 1966 ++++++++++++++++- src/backend/utils/init/postinit.c | 11 + src/backend/utils/misc/guc_parameters.dat | 11 + src/backend/utils/misc/postgresql.conf.sample | 4 +- src/include/catalog/pg_proc.dat | 26 + src/include/storage/lwlocklist.h | 1 + src/include/storage/subsystemlist.h | 1 + src/include/utils/guc_hooks.h | 1 + src/include/utils/wait_event_timing.h | 184 +- src/test/modules/test_misc/meson.build | 1 + .../t/015_wait_event_trace_seqlock.pl | 122 + src/test/regress/expected/rules.out | 7 + .../regress/expected/wait_event_timing.out | 46 +- .../regress/expected/wait_event_timing_1.out | 49 +- src/test/regress/sql/wait_event_timing.sql | 31 +- src/tools/pgindent/typedefs.list | 6 + 21 files changed, 2737 insertions(+), 104 deletions(-) create mode 100644 src/test/modules/test_misc/t/015_wait_event_trace_seqlock.pl diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index dcee5390389cb..a7f4c906522c0 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -9165,7 +9165,9 @@ COPY postgres_log FROM '/full/path/to/logfile.csv' WITH csv; Controls collection of wait event timing instrumentation. Requires the server to be compiled with . Possible values are - off (the default) and stats. + off (the default), stats, and + trace; each level is a strict superset of the + previous one. At stats, the server records per-backend wait @@ -9179,6 +9181,16 @@ COPY postgres_log FROM '/full/path/to/logfile.csv' WITH csv; wait_event_capture is off the hot path adds only a single predictable branch. + + At trace, the server additionally records every + individual wait event into a per-session ring buffer exposed via the + + pg_backend_wait_event_trace view. + The ring is allocated lazily from dynamic shared memory on first + use (default 4 MB per backend; see + ), so only + sessions that enable trace pay the per-ring memory cost. + Only superusers and users with the appropriate SET privilege can change this setting. Read access to the resulting @@ -9220,6 +9232,29 @@ GRANT SET ON PARAMETER wait_event_capture TO pg_monitor; + + wait_event_trace_ring_size (integer) + + wait_event_trace_ring_size configuration parameter + + + + + Sets the size of the per-backend ring buffer used at the + trace level of + . Each backend that enables + trace allocates a ring of this size from a cluster-wide dynamic + shared memory area. The value must be a power of two; larger rings + retain a longer history of wait events before wrapping. If this + parameter is specified without units, it is taken as kilobytes. The + default is 4096 (4 MB). This parameter has + no effect on builds compiled without + , and can only be set at + server start. + + + + track_functions (enum) diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml index bf22ec4175a2d..a8bfdc232ba90 100644 --- a/doc/src/sgml/monitoring.sgml +++ b/doc/src/sgml/monitoring.sgml @@ -589,6 +589,15 @@ postgres 27093 0.0 0.0 30096 2752 ? Ss 11:34 0:00 postgres: ser + + pg_backend_wait_event_tracepg_backend_wait_event_trace + Individual wait event records from the current backend's trace + ring buffer. See + + pg_backend_wait_event_trace for details. + + + @@ -4022,7 +4031,8 @@ description | Waiting for a newly initialized WAL file to reach durable storage The pg_stat_wait_event_timing view contains one row for each combination of backend and wait event that has a non-zero call count. It shows accumulated timing statistics collected when - is set to stats. Requires the server to be compiled with + is set to stats + or trace. Requires the server to be compiled with . @@ -4388,6 +4398,200 @@ ORDER BY b.bucket_idx; + + <structname>pg_backend_wait_event_trace</structname> + + + pg_backend_wait_event_trace + + + + The pg_backend_wait_event_trace view shows + individual wait event records from the current backend's + trace ring buffer. Each record captures a single wait event + (with timestamp and duration). + Requires to be set to + trace. The ring buffer is sized by + (default 4 MB = + 131072 records of 32 bytes each); older records are overwritten in + FIFO order. The view is session-local + and analogous in scope to + + pg_backend_memory_contexts; querying it + from a superuser session still returns only that session's own + records, never another backend's. + + + + The pg_backend_wait_event_trace view is + intended for session-local interactive diagnostics: + running ad-hoc SELECT queries against your own + session's trace from psql while + investigating wait-event behaviour. The view materialises up to + one ring's worth of records (default ~4 MB, controlled by + ) into a + tuplestore on each call, which is bounded and acceptable for that + use; for narrow result sets, append + ORDER BY seq DESC LIMIT N + to get the most recent records. + + + + Cross-backend monitoring tools — extensions and background + workers that read wait events losslessly from every backend's + ring — should not consume through this + view. The in-tree cross-backend reader is + pg_get_wait_event_trace + (see ); the underlying + per-session SQL function returns only the calling backend's own + ring, so a background worker invoking + SELECT * FROM pg_backend_wait_event_trace via + SPI would receive only its own (typically empty) ring, not the + target backend's data. External tools that need cross-backend + access without going through SQL use the shared-memory snapshot + pattern documented on + WaitEventTraceControl in + src/include/utils/wait_event_timing.h: + snapshot trace_slots[procNumber].generation, + acquire WaitEventTraceCtl->lock in + LW_SHARED, resolve the target slot's + ring_ptr via + dsa_get_address, snapshot the relevant slice + of the ring into local memory, release the lock, re-snapshot + generation and discard the read if it + changed, then process the snapshot off the lock. That bypasses + this view entirely and is the supported cross-backend interface + for monitoring extensions. + + + + Slot lifecycle. Per-backend trace rings are + not freed when their owner backend exits. The ring stays + allocated in shared memory in an orphaned state + so the dying backend's final waits remain readable by the + cross-backend interface — + pg_get_wait_event_trace + (see ) for in-tree + access, or external background workers that follow the + snapshot pattern documented above. + This does not change the behaviour of this view, + which always reads the calling backend's own ring and is + unaffected by orphan-state slots belonging to other + procnumbers. The lifecycle change matters for short-lived + backends that exit before any monitoring tool has read their + data: parallel workers in particular exit in milliseconds at + end-of-parallel-query, well below typical reader polling + intervals, and without orphan-persistence their final waits + would be lost. Orphaned rings are reclaimed automatically when a new + backend takes over the same procNumber + slot, and the DBA can force a sweep at any time via + pg_stat_clear_orphaned_wait_event_rings. + The worst-case orphan-memory footprint is bounded by the slot + count times ~4 MB; see + pg_stat_clear_orphaned_wait_event_rings + under for details and + the deployment patterns where the function is most useful. + + + + The ring buffer is designed as a lock-free transport mechanism for + external consumption. At high wait event rates (e.g., 220K events/sec), + the ring wraps in roughly 0.5–1 seconds. Consumers should poll + the ring buffer before it wraps. + + + + The seq column is the absolute write + position of each record; it is monotonically increasing and never + resets while the ring is alive. A consumer polling the ring + repeatedly can detect wraparound losses by tracking + max(seq) between successive scrapes: given two + consecutive polls returning N2 rows with + maximum seq values + S1 (previous poll) and + S2 (current poll), the number of records + overwritten before the second poll could read them is + max(0, (S2 - S1) - N2). No separate + trace overflow counter is exposed because this + information is exact and derivable from seq + alone. + + + + + <structname>pg_backend_wait_event_trace</structname> View + + + + + Column Type + + + Description + + + + + + + + seq bigint + + + Sequence number of this record in the ring buffer + + + + + + timestamp_ns bigint + + + Monotonic clock timestamp in nanoseconds + + + + + + wait_event_type text + + + Wait event type + + + + + + wait_event text + + + Wait event name + + + + + + duration_us double precision + + + Wait duration in microseconds + + + + + + query_id bigint + + + Reserved; always 0 for wait event records + + + + + +
+
+ <structname>pg_stat_database</structname> @@ -6495,6 +6699,101 @@ ORDER BY b.bucket_idx;
+ + + + pg_get_wait_event_trace + + pg_get_wait_event_trace ( procnumber integer ) + setof record + + + Returns individual wait event records from the trace ring of + the backend that currently or previously occupied the slot + identified by procnumber. Reads slots + in OWNED state (live writer) and + ORPHANED state (writer has exited but the + ring is preserved for post-mortem reading) uniformly. An + empty result indicates the slot is in FREE + state (no ring) or no records have been written. Concurrent + slot transitions cannot interrupt the read because the + function holds the cross-backend trace lock in + SHARED mode throughout the iteration; the + per-record seqlock protocol skips any record being written + by a concurrent live writer. + + + This is the canonical cross-backend reader. External + monitoring extensions that need cross-backend access without + going through SQL should follow the same snapshot pattern + documented on WaitEventTraceControl + in src/include/utils/wait_event_timing.h; + this function serves as both the reference implementation and + a DBA-facing diagnostic tool. The + procnumber argument can be obtained + from the procnumber column of + pg_stat_get_wait_event_timing or + pg_stat_get_wait_event_timing_overflow + for live backends. For post-mortem reads of short-lived + backends (parallel workers, autovacuum, walsender) the + procnumber must be captured while the + backend is still alive, or discovered by iterating slots in a + monitoring background worker. A pid-keyed lookup for live + backends only is one query away: + + +SELECT * FROM pg_get_wait_event_trace( + (SELECT procnumber FROM pg_stat_get_wait_event_timing(target_pid) + WHERE pid = target_pid LIMIT 1)); + + + + Requires membership in pg_read_all_stats + (matching the privilege model of the session-local view + pg_backend_wait_event_trace). + + + + + + + pg_stat_clear_orphaned_wait_event_rings + + pg_stat_clear_orphaned_wait_event_rings () + bigint + + + Frees every wait-event-trace ring whose owner backend has + exited. Returns the number of rings released. Requires + superuser. + + + When a backend that had wait_event_capture = + trace exits, its ~4 MB trace ring is + intentionally not freed at exit so that + cross-backend consumers + (pg_get_wait_event_trace and extensions + following the snapshot pattern) can still read the dying + backend's final waits. The + memory is reclaimed lazily: in the common case, the ring is + freed automatically when a new backend takes over the same + procNumber slot. This function is the + explicit DBA-driven sweep for the pathological case where + capture was briefly enabled, then disabled, on a cluster with + long-lived pooled connections that never recycle the + procNumber. The maximum amount of memory + this can release is bounded by the slot count times the + per-ring size (~400 MB at max_connections + = 100, ~4 GB at 1000); on most deployments the function will + report 0 because connection churn already drained orphans + naturally. + + + Safe to call when capture is currently off + and even when no orphans exist (returns 0 in both cases). + + + diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql index 2ef679f7fc66f..3b1167832e704 100644 --- a/src/backend/catalog/system_views.sql +++ b/src/backend/catalog/system_views.sql @@ -1643,7 +1643,31 @@ CREATE VIEW pg_stat_wait_event_timing_overflow AS REVOKE ALL ON pg_stat_wait_event_timing_overflow FROM PUBLIC; GRANT SELECT ON pg_stat_wait_event_timing_overflow TO pg_read_all_stats; +-- Per-session wait event trace ring, one record per completed wait +-- (wait_event_capture = trace). +-- Reading a session's trace exposes its query_id and wait sequence, which can +-- leak across SECURITY DEFINER call chains, so the view AND both underlying +-- SRFs are locked to pg_read_all_stats. +CREATE VIEW pg_backend_wait_event_trace AS + SELECT + t.seq, + t.timestamp_ns, + t.wait_event_type, + t.wait_event, + t.duration_us, + t.query_id + FROM pg_get_backend_wait_event_trace() t; +REVOKE ALL ON pg_backend_wait_event_trace FROM PUBLIC; +GRANT SELECT ON pg_backend_wait_event_trace TO pg_read_all_stats; +-- Revoke the session-local SRF itself, not just the view, so a role that can +-- enable trace cannot read its own ring via the function and bypass the view. +REVOKE EXECUTE ON FUNCTION pg_get_backend_wait_event_trace() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION pg_get_backend_wait_event_trace() TO pg_read_all_stats; +-- Cross-backend reader, keyed by procnumber (reads OWNED and ORPHANED slots). +REVOKE EXECUTE ON FUNCTION pg_get_wait_event_trace(int4) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION pg_get_wait_event_trace(int4) TO pg_read_all_stats; -- Cluster-scope operations: revoked from PUBLIC (administrators can -- delegate with GRANT EXECUTE); not granted to pg_read_all_stats because -- they mutate state rather than read it. REVOKE EXECUTE ON FUNCTION pg_stat_reset_wait_event_timing_all() FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION pg_stat_clear_orphaned_wait_event_rings() FROM PUBLIC; diff --git a/src/backend/postmaster/auxprocess.c b/src/backend/postmaster/auxprocess.c index ad4bf4bd2a8e9..6575dec4f00c3 100644 --- a/src/backend/postmaster/auxprocess.c +++ b/src/backend/postmaster/auxprocess.c @@ -26,6 +26,7 @@ #include "utils/memutils.h" #include "utils/ps_status.h" #include "utils/wait_event.h" +#include "utils/wait_event_timing.h" static void ShutdownAuxiliaryProcess(int code, Datum arg); @@ -113,6 +114,16 @@ AuxiliaryProcessMainCommon(void) */ CreateAuxProcessResourceOwner(); +#ifdef USE_WAIT_EVENT_TIMING + + /* + * Attach trace ring if wait_event_capture = trace was set via + * postgresql.conf + */ + if (wait_event_capture == WAIT_EVENT_CAPTURE_TRACE && my_trace_proc_number >= 0) + wait_event_trace_attach(my_trace_proc_number); +#endif + /* Initialize backend status information */ pgstat_beinit(); diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt index f48dc93fc2f37..54e63f49b2fac 100644 --- a/src/backend/utils/activity/wait_event_names.txt +++ b/src/backend/utils/activity/wait_event_names.txt @@ -418,6 +418,7 @@ ParallelVacuumDSA "Waiting for parallel vacuum dynamic shared memory allocation. AioUringCompletion "Waiting for another process to complete IO via io_uring." ShmemIndex "Waiting to find or allocate space in shared memory." WaitEventTimingDSA "Waiting for wait event timing dynamic shared memory allocation." +WaitEventTraceDSA "Waiting for wait event trace dynamic shared memory allocation." # No "ABI_compatibility" region here as WaitEventLWLock has its own C code. diff --git a/src/backend/utils/activity/wait_event_timing.c b/src/backend/utils/activity/wait_event_timing.c index 48e417d147027..5346d4f8cf99c 100644 --- a/src/backend/utils/activity/wait_event_timing.c +++ b/src/backend/utils/activity/wait_event_timing.c @@ -15,11 +15,16 @@ * attach on their first wait event under capture; a before_shmem_exit gate * keeps the hot path away from DSA mappings that proc_exit has torn down. * - * Controlled by the wait_event_capture GUC (off | stats, default off) and - * the compile-time option --enable-wait-event-timing. In builds without - * that option the file still compiles (the GUC backing variable, the enum - * table, the rejecting check hook, and empty-result SQL stubs), so the GUC - * and the catalog functions exist uniformly. + * At the trace level, every completed wait (and a set of query-attribution + * markers) is additionally pushed into a per-session DSA ring buffer that + * survives backend exit for post-mortem reading; see the trace section + * below and the lifecycle discussion on WaitEventTraceControl. + * + * Controlled by the wait_event_capture GUC (off | stats | trace, default + * off) and the compile-time option --enable-wait-event-timing. In builds + * without that option the file still compiles (the GUC backing variables, + * the enum table, the rejecting check hook, and empty-result SQL stubs), + * so the GUC and the catalog functions exist uniformly. * * Copyright (c) 2026, PostgreSQL Global Development Group * @@ -41,17 +46,46 @@ */ int wait_event_capture = WAIT_EVENT_CAPTURE_OFF; int wait_event_timing_max_tranches = 192; +int wait_event_trace_ring_size = 4096; + +/* + * Records-per-ring derived from wait_event_trace_ring_size at server + * start. Read by the writer (via the per-ring cached mask) and by the ring + * allocator. Zero until the GUC framework commits the boot value. + */ +uint32 WaitEventTraceRingSize = 0; /* * Enum value table consumed by guc.c. Order matches the - * WaitEventCaptureLevel enum and the documented "off < stats" ordering. + * WaitEventCaptureLevel enum and the documented "off < stats < trace". */ const struct config_enum_entry wait_event_capture_options[] = { {"off", WAIT_EVENT_CAPTURE_OFF, false}, {"stats", WAIT_EVENT_CAPTURE_STATS, false}, + {"trace", WAIT_EVENT_CAPTURE_TRACE, false}, {NULL, 0, false} }; +/* + * GUC check hook for wait_event_trace_ring_size. The ring size in records + * must be a power of two for the writer's mask-indexing (pos & ring_mask). + * Each record is 32 bytes, so kb is a power of two iff the record count is. + * Defined for both build configurations so the GUC framework validates it + * uniformly. + */ +bool +check_wait_event_trace_ring_size(int *newval, void **extra, GucSource source) +{ + int v = *newval; + + if (v <= 0 || (v & (v - 1)) != 0) + { + GUC_check_errdetail("wait_event_trace_ring_size must be a positive power of two."); + return false; + } + return true; +} + #ifndef USE_WAIT_EVENT_TIMING /* @@ -65,6 +99,9 @@ Datum pg_stat_get_wait_event_timing(PG_FUNCTION_ARGS); Datum pg_stat_get_wait_event_timing_overflow(PG_FUNCTION_ARGS); Datum pg_stat_reset_wait_event_timing(PG_FUNCTION_ARGS); Datum pg_stat_reset_wait_event_timing_all(PG_FUNCTION_ARGS); +Datum pg_get_backend_wait_event_trace(PG_FUNCTION_ARGS); +Datum pg_get_wait_event_trace(PG_FUNCTION_ARGS); +Datum pg_stat_clear_orphaned_wait_event_rings(PG_FUNCTION_ARGS); Datum pg_stat_get_wait_event_timing(PG_FUNCTION_ARGS) @@ -80,6 +117,27 @@ pg_stat_get_wait_event_timing_overflow(PG_FUNCTION_ARGS) PG_RETURN_VOID(); } +Datum +pg_get_backend_wait_event_trace(PG_FUNCTION_ARGS) +{ + InitMaterializedSRF(fcinfo, 0); + PG_RETURN_VOID(); +} + +Datum +pg_get_wait_event_trace(PG_FUNCTION_ARGS) +{ + InitMaterializedSRF(fcinfo, 0); + PG_RETURN_VOID(); +} + +Datum +pg_stat_clear_orphaned_wait_event_rings(PG_FUNCTION_ARGS) +{ + /* No trace infrastructure in this build, so never any orphans. */ + PG_RETURN_INT64(0); +} + Datum pg_stat_reset_wait_event_timing(PG_FUNCTION_ARGS) { @@ -135,9 +193,11 @@ assign_wait_event_capture(int newval, void *extra) /* No shared memory is reserved in the stub build. */ const ShmemCallbacks WaitEventTimingShmemCallbacks = {0}; +const ShmemCallbacks WaitEventTraceControlShmemCallbacks = {0}; /* Defined so every extern in wait_event_timing.h resolves in stub builds. */ WaitEventTimingState *my_wait_event_timing = NULL; +int my_trace_proc_number = -1; void pgstat_set_wait_event_timing_storage(int procNumber) @@ -149,6 +209,35 @@ pgstat_reset_wait_event_timing_storage(void) { } +void +wait_event_trace_attach(int procNumber) +{ +} + +/* + * Stub trace-marker entry points -- declared unconditionally in the header + * so call sites in execMain.c/postgres.c/backend_status.c need no #ifdef. + */ +void +wait_event_trace_query_start(int64 query_id) +{ +} + +void +wait_event_trace_query_end(int64 query_id) +{ +} + +void +wait_event_trace_exec_start(int64 query_id) +{ +} + +void +wait_event_trace_exec_end(int64 query_id) +{ +} + #else /* USE_WAIT_EVENT_TIMING */ #include "catalog/pg_authid.h" @@ -167,6 +256,7 @@ pgstat_reset_wait_event_timing_storage(void) #include "utils/backend_status.h" #include "utils/builtins.h" #include "utils/dsa.h" +#include "utils/injection_point.h" #include "utils/tuplestore.h" #include "utils/wait_event.h" @@ -233,6 +323,107 @@ static Size wait_event_timing_per_backend_stride = 0; static int wait_event_timing_hash_size = 0; static int wait_event_timing_max_entries = 0; +/* ---- Trace-level (wait_event_capture = trace) state ---- */ +/* Pointer to this backend's trace ring buffer */ +static WaitEventTraceState *my_wait_event_trace = NULL; + +/* DSA-based trace ring buffer control */ +static WaitEventTraceControl *WaitEventTraceCtl = NULL; +static dsa_area *trace_dsa = NULL; +int my_trace_proc_number = -1; + +/* + * Same-backend coordination between pg_get_backend_wait_event_trace (the + * own-session SRF reader) and wait_event_trace_release_slot (the GUC + * step-down path that frees this backend's ring). Both paths run in this + * same backend, single-threaded, so a plain bool is sufficient -- no + * atomics needed. + * + * srf_in_progress set true while the SRF is iterating the ring; the + * release path observes this and defers the dsa_free + * instead of yanking the chunk out from under us. + * + * release_pending set by the release path when it had to defer; the + * SRF's PG_FINALLY checks it and performs the deferred + * dsa_free after the iteration completes. + * + * Cross-backend readers (extensions, bgworkers reading another backend's + * ring) cannot use this mechanism -- they coordinate with the release + * path via WaitEventTraceCtl->lock instead. See the header for the + * recommended snapshot-under-lock pattern for those consumers. + */ +static bool wait_event_trace_srf_in_progress = false; +static bool wait_event_trace_release_pending = false; + +/* + * Per-backend gate that disables the trace-ring writer in the wait- + * event hot path while a slot-state transition is in progress. + * + * Set true around code paths that either free the local trace ring + * (wait_event_trace_release_slot's dsa_free) or transition the slot + * out of OWNED (wait_event_trace_before_shmem_exit's OWNED -> + * ORPHANED publish). In both cases an internal LWLock inside + * dsa_free / dsa_attach / dsa_pin_mapping / dsa_pin can in + * principle contend long enough to dispatch a wait event; that + * wait event's pgstat_report_wait_end_timing inline path runs in + * the SAME backend, sees capture_level == TRACE (the GUC hasn't + * been committed yet by the time the assign hook runs), and would: + * + * * during release_slot's dsa_free: write into a ring that has + * already been returned to the DSA freelist -- if another + * allocator has since reused the chunk, this is a stray write + * into someone else's allocation. + * + * * during release_slot's dsa_free, alternative timing: see + * my_wait_event_trace == NULL on a naive "clear before free" + * fix and recurse into wait_event_trace_attach, which would + * either deadlock on the WaitEventTraceCtl->lock the outer + * release_slot already holds, or (on a lock-free moment) + * allocate a fresh ring that the outer release_slot would + * then free again as part of its post-acquire DsaPointerIsValid + * check -- a different use-after-free of a freshly-allocated + * chunk. + * + * * during before_shmem_exit: write into the ring after the slot + * has been published as ORPHANED, violating the post-mortem + * read-only contract that cross-backend readers rely on. + * + * The flag is per-backend (static at file scope means per-process + * in PG's process-per-backend model), so the hot path's check is a + * single cache-warm load and a branch; no atomic, no fence. The + * trace branch is already gated by capture_level == TRACE so the + * additional check costs nothing in the common case where capture + * is off or stats-only. The flag is set on the very same backend + * that may later read it from the hot path, so there is no + * cross-process visibility concern. + * + * See the release_slot and before_shmem_exit doc comments for the + * specific transition each uses this flag around. + */ +static bool wait_event_trace_writes_disabled = false; + +/* + * Re-entrancy guard for the trace-record writer in + * pgstat_report_wait_end_timing(): wait events emitted while a trace + * record is mid-write are not themselves ring-recorded. In production the + * record write is a handful of plain stores and cannot wait, so this never + * fires; it exists for the injection point inside the writer, whose wait + * machinery emits nested wait events that would otherwise recurse into the + * writer (and, by re-running the injection point, self-deadlock on locks + * the outer invocation holds). Per-backend, single-threaded -- a plain + * bool suffices. + */ +static bool wait_event_trace_in_write = false; + +/* + * Forward declarations for trace functions referenced before their + * definitions (the storage/assign hooks call them; the bodies are in the + * trace machinery section below). + */ +static void wait_event_trace_detach(int procNumber); +static void wait_event_trace_release_slot(int procNumber); +static void wait_event_trace_clear_orphan_at_init(int procNumber); + /* * Mapping arrays for the flat events[] array, generated from * wait_event_names.txt by generate-wait_event_types.pl. Defines @@ -761,17 +952,39 @@ void pgstat_set_wait_event_timing_storage(int procNumber) { my_wait_event_timing = NULL; + my_wait_event_trace = NULL; + + if (procNumber < 0 || procNumber >= NUM_WAIT_EVENT_TIMING_SLOTS) + { + my_trace_proc_number = -1; + return; + } + + my_trace_proc_number = procNumber; + + /* + * If the previous occupant of this procNumber left an ORPHANED trace ring + * behind (we do not free trace rings at backend exit -- see + * WaitEventTraceControl), free it now so this backend starts with a clean + * FREE slot. + */ + wait_event_trace_clear_orphan_at_init(procNumber); } /* - * Detach from the timing slot on backend exit. The slot stays in DSA; - * clearing the local pointer keeps the late-shutdown hot path from touching + * Detach from the timing slot on backend exit. The slots stay in DSA; + * clearing the local pointers keeps the late-shutdown hot path from touching * already-detached memory. */ void pgstat_reset_wait_event_timing_storage(void) { + if (my_trace_proc_number >= 0) + wait_event_trace_detach(my_trace_proc_number); + my_wait_event_timing = NULL; + my_wait_event_trace = NULL; + my_trace_proc_number = -1; } /* @@ -801,118 +1014,898 @@ assign_wait_event_capture(int newval, void *extra) INSTR_TIME_SET_ZERO(my_wait_event_timing->wait_start); my_wait_event_timing->current_event = 0; } + + /* + * Stepping down from TRACE: release the ~4 MB DSA ring now rather than + * holding it pinned for the rest of the session. Only fires when a ring + * is actually attached, so OFF -> TRACE -> OFF without ever emitting a + * trace record stays a no-op. Re-enable re-allocates a fresh ring on the + * first wait event via wait_event_trace_attach. dsa_free is non-raising + * LWLock bookkeeping, so it is safe from an assign hook. + */ + if (newval != WAIT_EVENT_CAPTURE_TRACE && my_wait_event_trace != NULL) + wait_event_trace_release_slot(my_trace_proc_number); } +/* ================= Trace-level ring machinery ================= */ + + /* - * Out-of-line body for pgstat_report_wait_start()'s timing path. Records - * the start timestamp and the event being waited on. Reached only when - * wait_event_capture != OFF. + * Write a trace ring marker record. Shared helper for all marker types. */ -void -pgstat_report_wait_start_timing(uint32 wait_event_info) +static void +wait_event_trace_write_marker(uint8 record_type, int64 query_id) { + uint64 pos; + WaitEventTraceRecord *rec; + uint32 seq; + instr_time now; + /* - * Stay out of the timing path once proc_exit has begun tearing down DSA - * mappings (see the before_shmem_exit callback). + * Single capture-level gate: markers only land in the ring when + * wait_event_capture is at TRACE. This guarantees consistency with the + * wait-event hot path (also gated on the same level) -- there is no + * configuration in which one half of the trace fires and the other + * doesn't. query_id == 0 means "no query ID available" (utility command + * or compute_query_id = off), which we skip. + * + * wait_event_trace_writes_disabled is the same per-backend gate the + * wait-event hot path uses; it is raised by release_slot and + * before_shmem_exit around slot-state transitions to keep both writers + * consistent. Markers cannot fire during those transitions today + * (single-threaded execution, no nested executor), but checking here + * keeps the contract uniform across all trace-ring writers and is robust + * to future code paths that might invoke a marker from a nested context. + * + * No likely()/unlikely() annotation: this function is called at + * query/exec boundaries (a handful per query, not per wait event), so + * neither side of the branch dominates often enough for static layout to + * matter, and the meaningful production configuration (wait_event_capture + * = trace) is exactly when the body is hot -- an annotation on the + * early-return would point the wrong way. */ - if (wait_event_timing_writes_disabled) + if (wait_event_capture != WAIT_EVENT_CAPTURE_TRACE || + wait_event_trace_writes_disabled || + query_id == 0) return; - if (my_wait_event_timing == NULL) + /* + * Lazy attach on first use. Allocation lives here (not in the assign + * hook) because dsa_allocate_extended() can ereport(ERROR) on OOM, which + * is forbidden in assign-hook context but legitimate here. Idempotent: + * wait_event_trace_attach() short-circuits on subsequent calls. + */ + if (my_wait_event_trace == NULL) { - pgstat_wait_event_timing_lazy_attach(); + if (my_trace_proc_number < 0) + return; + wait_event_trace_attach(my_trace_proc_number); + if (my_wait_event_trace == NULL) + return; /* attach path unable to allocate */ + } - /* - * lazy_attach can dispatch nested wait events while it sets up DSA - * (dsa_attach takes an internal LWLock); those nested wait_end calls - * clear my_wait_event_info to 0. Re-publish so the outer wait stays - * visible in pg_stat_activity. Only needed on the first-attach path. - */ - *(volatile uint32 *) my_wait_event_info = wait_event_info; + /* + * Claim the next slot. Single-writer counter (only the owning backend + * writes its own ring), so a plain read+write is sufficient and avoids + * the LOCK XADD that pg_atomic_fetch_add_u64 would emit -- a wasted + * cache-coherence trip on an unshared cache line at this rate (one per + * wait event). Cross-backend readers use pg_atomic_read_u64, which + * compiles to a plain MOV on x86 and tolerates concurrent writes here + * (their actual safety against the records[] window is the per-record + * seqlock below). Same idiom as injection_point.c's per-entry generation + * counter (single writer + multiple lock-free readers). + */ + pos = pg_atomic_read_u64(&my_wait_event_trace->write_pos); + pg_atomic_write_u64(&my_wait_event_trace->write_pos, pos + 1); + rec = &my_wait_event_trace->records[pos & my_wait_event_trace->ring_mask]; + seq = (uint32) (pos * 2 + 1); + + rec->seq = seq; + pg_write_barrier(); /* release: payload stores must not rise above + * seq=odd */ + + INSTR_TIME_SET_CURRENT(now); + rec->record_type = record_type; + rec->timestamp_ns = INSTR_TIME_GET_NANOSEC(now); + rec->data.query.query_id = query_id; + rec->data.query.pad2 = 0; + + pg_write_barrier(); /* release: payload stores must land before + * seq=even */ + rec->seq = seq + 1; +} + +void +wait_event_trace_query_start(int64 query_id) +{ + wait_event_trace_write_marker(TRACE_QUERY_START, query_id); +} + +void +wait_event_trace_query_end(int64 query_id) +{ + wait_event_trace_write_marker(TRACE_QUERY_END, query_id); +} + +void +wait_event_trace_exec_start(int64 query_id) +{ + wait_event_trace_write_marker(TRACE_EXEC_START, query_id); +} + +void +wait_event_trace_exec_end(int64 query_id) +{ + wait_event_trace_write_marker(TRACE_EXEC_END, query_id); +} + +/* + * Report the shared memory space needed for trace ring buffer control. + * Only a small control struct is in fixed shmem; the actual ring buffers + * are allocated lazily via DSA. At ~24 bytes/slot, the slot array adds + * ~26 KB at a default MaxBackends, negligible compared to the ring + * memory itself. + */ +static Size +WaitEventTraceControlShmemSize(void) +{ + return add_size(offsetof(WaitEventTraceControl, trace_slots), + mul_size(NUM_WAIT_EVENT_TIMING_SLOTS, + sizeof(WaitEventTraceSlot))); +} + +static void +WaitEventTraceControlShmemRequest(void *arg) +{ + ShmemRequestStruct(.name = "WaitEventTraceControl", + .size = WaitEventTraceControlShmemSize(), + .ptr = (void **) &WaitEventTraceCtl); +} + +/* + * Initialize shared memory for trace ring buffer control. + */ +static void +WaitEventTraceControlShmemInit(void *arg) +{ + int i; + + WaitEventTraceCtl->trace_dsa_handle = DSA_HANDLE_INVALID; + LWLockInitialize(&WaitEventTraceCtl->lock, + LWTRANCHE_WAIT_EVENT_TRACE_DSA); + for (i = 0; i < NUM_WAIT_EVENT_TIMING_SLOTS; i++) + { + WaitEventTraceSlot *s = &WaitEventTraceCtl->trace_slots[i]; + + pg_atomic_init_u64(&s->generation, 0); + pg_atomic_init_u32(&s->state, WAIT_EVENT_TRACE_SLOT_FREE); + s->pad = 0; + s->ring_ptr = InvalidDsaPointer; } +} - if (my_wait_event_timing != NULL) +const ShmemCallbacks WaitEventTraceControlShmemCallbacks = { + .request_fn = WaitEventTraceControlShmemRequest, + .init_fn = WaitEventTraceControlShmemInit, +}; + +/* + * Ensure the shared DSA for trace ring buffers exists and is attached. + * Creates it on first call (any backend), attaches on subsequent calls. + * Must be called from a backend context (not postmaster). + */ +static void +wait_event_trace_ensure_dsa(void) +{ + MemoryContext oldcontext; + + if (trace_dsa != NULL) + return; + + oldcontext = MemoryContextSwitchTo(TopMemoryContext); + + LWLockAcquire(&WaitEventTraceCtl->lock, LW_EXCLUSIVE); + + if (WaitEventTraceCtl->trace_dsa_handle == DSA_HANDLE_INVALID) { - INSTR_TIME_SET_CURRENT(my_wait_event_timing->wait_start); - my_wait_event_timing->current_event = wait_event_info; + trace_dsa = dsa_create(LWTRANCHE_WAIT_EVENT_TRACE_DSA); + dsa_pin(trace_dsa); + dsa_pin_mapping(trace_dsa); + WaitEventTraceCtl->trace_dsa_handle = dsa_get_handle(trace_dsa); + } + else + { + trace_dsa = dsa_attach(WaitEventTraceCtl->trace_dsa_handle); + dsa_pin_mapping(trace_dsa); } + + LWLockRelease(&WaitEventTraceCtl->lock); + + MemoryContextSwitchTo(oldcontext); } /* - * Out-of-line body for pgstat_report_wait_end()'s timing path. Computes - * the wait duration and accumulates per-event statistics. + * Transition our trace ring slot to ORPHANED on backend exit. * - * capture_level is the value of wait_event_capture observed at the inline - * gate; in this commit only STATS exists, so it is not branched on, but it - * is threaded through to keep the gate ABI stable for the trace level - * added later in the series. + * Registered as a before_shmem_exit callback. Runs BEFORE + * dsm_backend_shutdown() detaches the DSA. + * + * Crucially, we do NOT free the ring here. The ring stays allocated in + * DSA so that cross-backend consumers -- the in-tree + * pg_get_wait_event_trace SRF and any extension following the + * snapshot pattern documented on WaitEventTraceControl -- can read + * the dying backend's final waits. The original "free at exit" + * design lost data the instant a worker terminated, which was + * particularly bad for parallel workers exiting in milliseconds at + * end-of-parallel-query. See the lifecycle comment on + * WaitEventTraceControl for the full design + * rationale and the bounded-memory cost we accept in exchange. + * + * The ORPHANED slot is reclaimed in one of two ways: + * (a) a new backend at this procNumber calls + * wait_event_trace_clear_orphan_at_init() at backend init, or + * (b) the DBA calls pg_stat_clear_orphaned_wait_event_rings(). + * + * State transition order matters: bump generation BEFORE storing the + * new state, so cross-backend readers that snapshot + * (generation_before, state, ring_ptr, generation_after) under the + * lock see a consistent (state, ring_ptr) pair iff generation didn't + * change. We hold the lock for the whole transition, but readers do + * not have to (they just take it briefly to snapshot the ring + * contents); the generation check is what makes the unlocked-read + * path safe. */ -void -pgstat_report_wait_end_timing(int capture_level) +static void +wait_event_trace_before_shmem_exit(int code, Datum arg) { - uint32 event; - uint32 cur_reset_gen; + int procNumber = DatumGetInt32(arg); + WaitEventTraceSlot *slot; - (void) capture_level; + if (WaitEventTraceCtl == NULL) + return; - if (wait_event_timing_writes_disabled) + if (procNumber < 0 || procNumber >= NUM_WAIT_EVENT_TIMING_SLOTS) return; - if (my_wait_event_timing == NULL) + slot = &WaitEventTraceCtl->trace_slots[procNumber]; + + /* + * If this backend never ended up with an OWNED slot (e.g. capture was off + * the whole session, or the trace was released back to FREE via + * assign_wait_event_capture going trace -> off), there is nothing to + * transition. Read state without the lock first as a fast-path check; + * the authoritative re-check happens under the lock below. + */ + if (pg_atomic_read_u32(&slot->state) != WAIT_EVENT_TRACE_SLOT_OWNED) { - pgstat_wait_event_timing_lazy_attach(); - if (my_wait_event_timing == NULL) - return; + wait_event_trace_writes_disabled = true; + my_wait_event_trace = NULL; + return; } - event = my_wait_event_timing->current_event; + /* + * Disable trace-ring writes on this backend before we touch the lock. + * Writes after this point would race with the OWNED -> ORPHANED state + * publish below: a wait event whose end-timing path runs after the state + * has been published as ORPHANED would write into a ring that the patch + * contract declares read-only post-mortem. Cross-backend readers + * snapshot ORPHANED rings without expecting concurrent writes from the + * dying owner. See wait_event_trace_writes_disabled for the full UAF / + * contract-violation analysis. + * + * The flag stays true for the remainder of this backend's life (we are in + * proc_exit; there is no subsequent capture re-enable to handle), so we + * do not reset it. + */ + wait_event_trace_writes_disabled = true; + + LWLockAcquire(&WaitEventTraceCtl->lock, LW_EXCLUSIVE); /* - * Service a pending cross-backend reset request. A single relaxed atomic - * load; when the shared generation has advanced past the value we last - * acted on, clear our own counters on behalf of the requester and record - * the reset. wait_start is left untouched so the in-flight measurement - * still lands (in the freshly-zeroed counters), and current_event is - * zeroed so external readers do not see stale state. + * Drop the local pointer inside the lock-held region as a second line of + * defense; the writes-disabled flag above is the primary gate. */ - cur_reset_gen = pg_atomic_read_u32(&my_wait_event_timing->reset_generation); - if (cur_reset_gen != my_last_reset_generation) + my_wait_event_trace = NULL; + + if (pg_atomic_read_u32(&slot->state) == WAIT_EVENT_TRACE_SLOT_OWNED && + DsaPointerIsValid(slot->ring_ptr)) { - memset(my_wait_event_timing->events, 0, - sizeof(my_wait_event_timing->events)); - lwlock_timing_hash_clear(my_wait_event_timing); - my_wait_event_timing->reset_count++; - my_wait_event_timing->lwlock_overflow_count = 0; - my_wait_event_timing->flat_overflow_count = 0; - my_wait_event_timing->current_event = 0; - my_last_reset_generation = cur_reset_gen; + /* + * Bump generation first so any reader that snapped the old generation + * will detect the change on its post-read recheck and discard its + * read. Then publish the ORPHANED state. Keep ring_ptr valid -- the + * data is what we want to preserve. + */ + pg_atomic_fetch_add_u64(&slot->generation, 1); + pg_atomic_write_u32(&slot->state, WAIT_EVENT_TRACE_SLOT_ORPHANED); } - if (event != 0 && !INSTR_TIME_IS_ZERO(my_wait_event_timing->wait_start)) - { - instr_time now; - int64 duration_ns; - int idx; - WaitEventTimingEntry *entry = NULL; + LWLockRelease(&WaitEventTraceCtl->lock); +} - INSTR_TIME_SET_CURRENT(now); - duration_ns = INSTR_TIME_GET_NANOSEC(now) - - INSTR_TIME_GET_NANOSEC(my_wait_event_timing->wait_start); +/* + * Allocate (or re-acquire) a trace ring buffer for this backend via DSA. + * Called when wait_event_capture is set to 'trace'. + * + * Slot state at entry will be one of: + * + * FREE fresh slot (or one cleared on this backend's init by + * wait_event_trace_clear_orphan_at_init): allocate a new + * ring, transition slot to OWNED, bump generation. + * + * OWNED we already attached earlier in this same backend's life + * (e.g. user toggled capture trace->stats->trace; the + * stats step calls wait_event_trace_release_slot which + * transitions back to FREE, but our cached + * my_wait_event_trace was cleared on the way down -- so + * seeing OWNED here at attach time means a different + * backend somehow ended up with this procNumber, which + * cannot happen because procNumber is per-backend and a + * single backend can only run one attach at a time. We + * still tolerate this state defensively by re-mapping the + * existing ring rather than leaking a second allocation. + * + * ORPHANED can never be observed here: a new backend's + * pgstat_set_wait_event_timing_storage() called + * wait_event_trace_clear_orphan_at_init() before any + * wait-event capture path can run, so any prior orphan has + * already been demoted to FREE. Treated as a safety check + * (Assert in debug builds). + */ +void +wait_event_trace_attach(int procNumber) +{ + /* + * Re-entrancy guard. dsa_create / dsa_allocate_extended below can emit + * wait events internally; those reach the lazy-attach hot path which + * calls back into this function while we still hold + * WaitEventTraceCtl->lock or are mid-allocation. See the + * function-local-static-bool pattern explainer on + * wait_event_timing_attach_array. + */ + static bool in_attach = false; + static bool shmem_exit_registered = false; + WaitEventTraceSlot *slot; + dsa_pointer p; + WaitEventTraceState *ts; + uint32 state_now; - if (duration_ns < 0) - duration_ns = 0; + if (in_attach) + return; - idx = wait_event_timing_index(event); + if (WaitEventTraceCtl == NULL) + return; - /* - * Single-writer hot path: each slot has exactly one writer (the - * owning backend), and the SRF reader is lock-free, so no locking is - * needed here. Events that do not map to a slot -- an LWLock tranche - * beyond the per-backend cap, or a class unknown to the timing tables - * -- bump a per-backend overflow counter. We deliberately do not log - * here: this runs inline in every wait_end, potentially deep in the - * backend stack, so the overflow counters are surfaced through a - * statistics view rather than through ereport(). + if (procNumber < 0 || procNumber >= NUM_WAIT_EVENT_TIMING_SLOTS) + return; + + /* + * Skip the attach if we are inside a critical section. Below this point + * we call dsa_create / dsa_attach / dsa_allocate_extended, all of which + * can allocate memory via MemoryContextAlloc and Assert-fail on + * "CritSectionCount == 0 || allowInCritSection". The very-first wait + * event after wait_event_capture = trace can land inside a critical + * section (e.g. a parallel worker scanning a heap page hits + * BufferSetHintBits16 -> XLogSaveBufferForHint -> XLogInsert -> + * LWLockAcquire, with the XLogInsert critical section open). + * + * Skipping here silently drops the in-flight wait event (it is not + * traced) but keeps the backend alive. The next wait event outside any + * critical section will hit this function again and attach successfully. + * See the matching guard in pgstat_wait_event_timing_lazy_attach. + */ + if (CritSectionCount > 0) + return; + + /* + * Skip the attach if MyProc is already on an LWLock wait queue. We are + * called from the wait-event hot path which fires AFTER LWLockQueueSelf + * has set MyProc->lwWaiting; a nested LWLockAcquire on our internal lock + * (via wait_event_trace_ensure_dsa) would PANIC at lwlock.c:1029. See the + * matching guard in pgstat_wait_event_timing_lazy_ attach for the full + * rationale. + */ + if (MyProc != NULL && MyProc->lwWaiting != LW_WS_NOT_WAITING) + return; + + slot = &WaitEventTraceCtl->trace_slots[procNumber]; + + in_attach = true; + PG_TRY(); + { + state_now = pg_atomic_read_u32(&slot->state); + + /* + * ORPHANED is normally impossible at attach time -- + * pgstat_set_wait_event_timing_storage() at backend init calls + * wait_event_trace_clear_orphan_at_init() which demotes any inherited + * orphan to FREE. But there is one case where this backend can + * legitimately observe its own slot in the ORPHANED state: after we + * have already run wait_event_trace_before_shmem_exit() + * (transitioning the slot to ORPHANED on exit), a later + * before_shmem_exit callback (e.g. pgstat_io_flush_cb during + * proc_exit shutdown) can contend on an LWLock that emits a wait + * event, which calls pgstat_report_wait_end_timing() -> + * wait_event_trace_attach() after my_wait_event_trace has been + * cleared. We must not re-attach in that case: we are dying, the + * ring is now post-mortem data for cross-backend readers, and the + * writer invariant must hold. Skip the trace for any wait events + * emitted after our own exit transition. + */ + if (state_now == WAIT_EVENT_TRACE_SLOT_ORPHANED) + { + /* PG_FINALLY below clears in_attach. */ + } + else if (state_now == WAIT_EVENT_TRACE_SLOT_OWNED && + DsaPointerIsValid(slot->ring_ptr)) + { + /* Already have a ring buffer; re-map to it. */ + wait_event_trace_ensure_dsa(); + my_wait_event_trace = dsa_get_address(trace_dsa, slot->ring_ptr); + my_trace_proc_number = procNumber; + } + else + { + Size alloc_size; + + wait_event_trace_ensure_dsa(); + + /* + * Cache the cluster-wide ring size on first allocation in this + * backend. wait_event_trace_ring_size is PGC_POSTMASTER, so by + * the time any backend reaches here, its boot value has been + * committed by the GUC framework. All rings in the postmaster + * run share the same dimensions. + */ + if (WaitEventTraceRingSize == 0) + WaitEventTraceRingSize = + (uint32) wait_event_trace_ring_size * 1024U / + (uint32) sizeof(WaitEventTraceRecord); + + alloc_size = offsetof(WaitEventTraceState, records) + + (Size) WaitEventTraceRingSize * sizeof(WaitEventTraceRecord); + + p = dsa_allocate_extended(trace_dsa, alloc_size, DSA_ALLOC_ZERO); + ts = dsa_get_address(trace_dsa, p); + pg_atomic_init_u64(&ts->write_pos, 0); + ts->ring_mask = WaitEventTraceRingSize - 1; + + LWLockAcquire(&WaitEventTraceCtl->lock, LW_EXCLUSIVE); + + /* + * Publish ring_ptr BEFORE transitioning state to OWNED. + * Cross-backend readers that observe state==OWNED outside the + * lock then see a valid ring_ptr. Bump generation last so any + * reader that snapped the prior generation will detect the + * change. + */ + slot->ring_ptr = p; + pg_atomic_write_u32(&slot->state, WAIT_EVENT_TRACE_SLOT_OWNED); + pg_atomic_fetch_add_u64(&slot->generation, 1); + LWLockRelease(&WaitEventTraceCtl->lock); + + my_wait_event_trace = ts; + my_trace_proc_number = procNumber; + + /* + * Register cleanup to run BEFORE dsm_backend_shutdown() detaches + * the DSA. The before_shmem_exit callbacks run in LIFO order + * before DSM detach, so the ORPHANED transition (which does not + * actually free the ring) is safe at that point. + * + * Guarded by shmem_exit_registered because under the + * release-on-disable policy (see wait_event_trace_release_slot + * and assign_wait_event_capture) the allocate branch can run + * multiple times per backend lifetime -- once per off/stats -> + * trace re-enable cycle. The cleanup itself is idempotent (it + * short-circuits when state is not OWNED), so it is safe to + * invoke after a release-then-reattach cycle, but we still avoid + * growing the before_shmem_exit list. + */ + if (!shmem_exit_registered) + { + before_shmem_exit(wait_event_trace_before_shmem_exit, + Int32GetDatum(procNumber)); + shmem_exit_registered = true; + } + } + } + PG_FINALLY(); + { + in_attach = false; + } + PG_END_TRY(); +} + +/* + * Free trace ring buffer for this backend on exit. + */ +static void +wait_event_trace_detach(int procNumber) +{ + /* + * Only clear local pointers here. The actual DSA free happens in + * wait_event_trace_before_shmem_exit(), which runs before + * dsm_backend_shutdown() detaches the DSA segments. + */ + my_wait_event_trace = NULL; + my_trace_proc_number = -1; +} + +/* + * Release this backend's trace ring buffer back to DSA immediately. + * + * Called from assign_wait_event_capture when the user steps down from + * TRACE to STATS or OFF. Without this, a ~4 MB ring allocated by a + * brief investigation would remain pinned for the rest of the session's + * lifetime, which can leak gigabytes across large connection pools. + * + * Important contrast with wait_event_trace_before_shmem_exit: backend + * exit transitions the slot to ORPHANED (preserving data for + * cross-backend readers); release_slot fully frees and returns to FREE + * because the operator has explicitly disabled trace -- they have + * affirmatively decided not to keep the data, so we honour that and + * reclaim the memory immediately. Subsequent re-enable allocates a + * fresh ring via wait_event_trace_attach's allocate branch. + * + * The operation is LWLock-safe and does not raise -- dsa_free is pure + * bookkeeping on the DSA freelist, no allocation and no ereport paths. + * Safe to call from a GUC assign hook. + * + * If pg_get_backend_wait_event_trace is currently iterating our own ring + * (wait_event_trace_srf_in_progress), we must NOT free the chunk out + * from under it: that would be a use-after-free on the records[] the SRF + * is still reading. Set wait_event_trace_release_pending instead and + * return; the SRF's PG_FINALLY block will perform the deferred free + * after iteration completes. In practice this branch is unreachable in + * current PG (assign hooks fire only at command boundaries and the SRF + * is a single command), but it makes the invariant explicit and the + * future-proofing free. + */ +static void +wait_event_trace_release_slot(int procNumber) +{ + /* + * Re-entrancy guard. dsa_free takes a DSA-internal LWLock which can in + * principle emit a wait event; if a nested assign hook re-enters we must + * not recurse. See the function-local-static-bool pattern explainer on + * wait_event_timing_attach_array. + */ + static bool in_release = false; + WaitEventTraceSlot *slot; + + if (in_release) + return; + + if (WaitEventTraceCtl == NULL || trace_dsa == NULL) + return; + + /* + * Same-backend SRF is iterating our own ring. Defer the free until the + * SRF's PG_FINALLY runs. + */ + if (wait_event_trace_srf_in_progress) + { + wait_event_trace_release_pending = true; + return; + } + + if (procNumber < 0 || procNumber >= NUM_WAIT_EVENT_TIMING_SLOTS) + return; + + slot = &WaitEventTraceCtl->trace_slots[procNumber]; + + in_release = true; + + /* + * Disable trace-ring writes on this backend before we touch the lock or + * call dsa_free. An internal LWLock inside dsa_free can dispatch a wait + * event whose end-timing path would otherwise see capture_level == TRACE + * (the GUC assign hook is in flight; the variable has not been committed + * by the framework yet) and write into the very chunk we are returning to + * the DSA freelist. See the comment on wait_event_trace_writes_disabled + * for the full UAF analysis. + */ + wait_event_trace_writes_disabled = true; + + PG_TRY(); + { + LWLockAcquire(&WaitEventTraceCtl->lock, LW_EXCLUSIVE); + + /* + * Drop the local pointer BEFORE the dsa_free as a second line of + * defense (the writes-disabled flag above is the primary gate). Any + * wait event whose hot path slips past the gate check via a compiler + * or memory-ordering surprise would at least see my_wait_event_trace + * == NULL and skip the write. + */ + my_wait_event_trace = NULL; + + if (DsaPointerIsValid(slot->ring_ptr)) + { + /* + * Bump generation first to invalidate any concurrent + * cross-backend snapshot, then free, then publish the FREE state + * with a NULL ring_ptr. Order matters for unlocked readers that + * have already passed the state check. + */ + pg_atomic_fetch_add_u64(&slot->generation, 1); + dsa_free(trace_dsa, slot->ring_ptr); + slot->ring_ptr = InvalidDsaPointer; + pg_atomic_write_u32(&slot->state, WAIT_EVENT_TRACE_SLOT_FREE); + } + LWLockRelease(&WaitEventTraceCtl->lock); + } + PG_FINALLY(); + { + wait_event_trace_writes_disabled = false; + in_release = false; + } + PG_END_TRY(); +} + +/* + * Clear an orphaned trace ring at backend init time. + * + * Called from pgstat_set_wait_event_timing_storage() once the new + * backend has its procNumber. If the slot we're inheriting was left + * ORPHANED by a previous backend (because we deliberately do not free + * trace rings on backend exit -- see the lifecycle discussion on + * WaitEventTraceControl), free the ring now so the new backend starts + * with a clean FREE slot. Subsequent wait_event_trace_attach() calls + * (when this backend itself enables trace) will then take the + * allocate branch. + * + * No-op when the slot is already FREE or OWNED: FREE means there's + * nothing to clear; OWNED is impossible at backend init (only a + * not-yet-exited backend can leave a slot OWNED, and procNumbers are + * assigned exclusively). We assert OWNED is not observed in debug + * builds and conservatively skip the free in production. + * + * Robustness: this runs during InitProcess() (before the backend can + * accept any work), and the work it performs -- dsa_attach() and + * dsa_free() -- can raise ERROR on rare runtime failures (corrupted + * DSA segment headers, descriptor exhaustion, mmap ENOMEM, etc.). + * An uncaught ERROR here would propagate out of InitProcess() and + * abort backend startup entirely, even for sessions that never + * intended to use wait_event_capture. To prevent the trace + * feature's housekeeping from gating connection establishment, the + * body is wrapped in PG_TRY()/PG_CATCH(): any error from dsa_attach + * or dsa_free is captured, downgraded to a WARNING with a hint + * pointing at the admin sweep function, and execution continues. + * The orphan stays in place; it can be reclaimed by the next + * backend that inherits the same procNumber (if the underlying + * problem was transient), by pg_stat_clear_orphaned_wait_event_rings(), + * or at next cluster restart. + */ +static void +wait_event_trace_clear_orphan_at_init(int procNumber) +{ + WaitEventTraceSlot *slot; + uint32 state_now; + MemoryContext caller_cxt; + + if (WaitEventTraceCtl == NULL) + return; + + if (procNumber < 0 || procNumber >= NUM_WAIT_EVENT_TIMING_SLOTS) + return; + + slot = &WaitEventTraceCtl->trace_slots[procNumber]; + + state_now = pg_atomic_read_u32(&slot->state); + if (state_now != WAIT_EVENT_TRACE_SLOT_ORPHANED) + { + Assert(state_now != WAIT_EVENT_TRACE_SLOT_OWNED); + return; + } + + /* + * Save CurrentMemoryContext so the PG_CATCH path can copy the error data + * into a context that survives FlushErrorState(). FlushErrorState() calls + * MemoryContextReset(ErrorContext), so CopyErrorData() must run in a + * different context or the returned ErrorData becomes a dangling pointer. + */ + caller_cxt = CurrentMemoryContext; + + PG_TRY(); + { + /* + * The trace DSA is shared across the cluster. We must attach to it + * before calling dsa_free (which needs the dsa_area pointer). The + * DSA was created by some earlier backend that wrote a trace record + * (otherwise the slot couldn't have ended up ORPHANED), so the handle + * in WaitEventTraceCtl is valid; ensure_dsa() will attach. Both + * ensure_dsa() and dsa_free() can raise ERROR; the PG_CATCH below + * downgrades any such error to a WARNING so backend startup is not + * blocked. + */ + wait_event_trace_ensure_dsa(); + + LWLockAcquire(&WaitEventTraceCtl->lock, LW_EXCLUSIVE); + if (pg_atomic_read_u32(&slot->state) == WAIT_EVENT_TRACE_SLOT_ORPHANED && + DsaPointerIsValid(slot->ring_ptr)) + { + pg_atomic_fetch_add_u64(&slot->generation, 1); + dsa_free(trace_dsa, slot->ring_ptr); + slot->ring_ptr = InvalidDsaPointer; + pg_atomic_write_u32(&slot->state, WAIT_EVENT_TRACE_SLOT_FREE); + } + LWLockRelease(&WaitEventTraceCtl->lock); + } + PG_CATCH(); + { + ErrorData *edata; + + /* + * Release any LWLocks we (or anything we called) might still hold. + * Two paths can leave WaitEventTraceCtl->lock held when control + * reaches here: + * + * 1. The outer LWLockAcquire above succeeded and dsa_free raised + * before we reached LWLockRelease. 2. wait_event_trace_ensure_dsa() + * raised inside its own LWLockAcquire/dsa_attach/LWLockRelease + * region. + * + * We are running during InitProcess(), BEFORE any transaction or + * PostgresMain sigsetjmp has been set up, so PG's standard + * "AbortTransaction -> LWLockReleaseAll" cleanup does NOT fire on the + * longjmp into PG_CATCH. Without an explicit release here the lock + * would stay held for the lifetime of this backend, blocking every + * future LW_EXCLUSIVE acquirer (the orphan-clear sweep, release_slot, + * before_shmem_exit transitions, and subsequent backends' + * clear_orphan_at_init). That would be strictly worse than the + * original failure-startup behavior this commit set out to fix. + * + * LWLockReleaseAll() is the idiomatic catch-path lock cleanup used by + * the standalone aux-process error handlers (walwriter.c, + * checkpointer.c, pgarch.c). It is safe to call broadly here because + * pgstat_set_wait_ event_timing_storage runs at a fixed point in + * InitProcess where the caller frame holds no other LWLocks across + * our return: the earlier InitProcess steps that touch LWLocks + * (ProcArrayAdd, etc.) release them before returning, and the + * subsequent steps that acquire LWLocks have not yet run. + */ + LWLockReleaseAll(); + + /* + * Switch BACK to the caller's context before CopyErrorData so that + * edata is allocated in a context that survives FlushErrorState(). + * FlushErrorState() calls MemoryContextReset(ErrorContext); + * allocating edata in ErrorContext (the default at PG_CATCH entry on + * the error path) would make it a dangling pointer the moment we + * flush. See the matching pattern in spi.c PG_CATCH branches. + */ + MemoryContextSwitchTo(caller_cxt); + edata = CopyErrorData(); + FlushErrorState(); + + ereport(WARNING, + (errcode(edata->sqlerrcode), + errmsg("could not clear orphaned wait-event trace ring " + "at backend init: %s", edata->message), + errdetail("Backend startup proceeds with the orphan " + "still allocated for procnumber %d.", + procNumber), + errhint("Run pg_stat_clear_orphaned_wait_event_rings() " + "to release the orphan when the underlying " + "condition is resolved."))); + + FreeErrorData(edata); + } + PG_END_TRY(); +} + +/* + * Out-of-line body for pgstat_report_wait_start()'s timing path. Records + * the start timestamp and the event being waited on. Reached only when + * wait_event_capture != OFF. + */ +void +pgstat_report_wait_start_timing(uint32 wait_event_info) +{ + /* + * Stay out of the timing path once proc_exit has begun tearing down DSA + * mappings (see the before_shmem_exit callback). + */ + if (wait_event_timing_writes_disabled) + return; + + if (my_wait_event_timing == NULL) + { + pgstat_wait_event_timing_lazy_attach(); + + /* + * lazy_attach can dispatch nested wait events while it sets up DSA + * (dsa_attach takes an internal LWLock); those nested wait_end calls + * clear my_wait_event_info to 0. Re-publish so the outer wait stays + * visible in pg_stat_activity. Only needed on the first-attach path. + */ + *(volatile uint32 *) my_wait_event_info = wait_event_info; + } + + if (my_wait_event_timing != NULL) + { + INSTR_TIME_SET_CURRENT(my_wait_event_timing->wait_start); + my_wait_event_timing->current_event = wait_event_info; + } +} + +/* + * Out-of-line body for pgstat_report_wait_end()'s timing path. Computes + * the wait duration, accumulates per-event statistics, and at the trace + * level pushes the completed wait into the per-session ring. + * + * capture_level is the value of wait_event_capture observed at the inline + * gate. Passing it through (rather than re-loading the global here) avoids + * a redundant load on the trace branch below -- the function-call boundary + * defeats CSE -- and means a concurrent GUC change cannot half-apply to + * this call: we run entirely at the gate's view of the level. + */ +void +pgstat_report_wait_end_timing(int capture_level) +{ + uint32 event; + uint32 cur_reset_gen; + + if (wait_event_timing_writes_disabled) + return; + + if (my_wait_event_timing == NULL) + { + pgstat_wait_event_timing_lazy_attach(); + if (my_wait_event_timing == NULL) + return; + } + + event = my_wait_event_timing->current_event; + + /* + * Service a pending cross-backend reset request. A single relaxed atomic + * load; when the shared generation has advanced past the value we last + * acted on, clear our own counters on behalf of the requester and record + * the reset. wait_start is left untouched so the in-flight measurement + * still lands (in the freshly-zeroed counters), and current_event is + * zeroed so external readers do not see stale state. + */ + cur_reset_gen = pg_atomic_read_u32(&my_wait_event_timing->reset_generation); + if (cur_reset_gen != my_last_reset_generation) + { + memset(my_wait_event_timing->events, 0, + sizeof(my_wait_event_timing->events)); + lwlock_timing_hash_clear(my_wait_event_timing); + my_wait_event_timing->reset_count++; + my_wait_event_timing->lwlock_overflow_count = 0; + my_wait_event_timing->flat_overflow_count = 0; + my_wait_event_timing->current_event = 0; + my_last_reset_generation = cur_reset_gen; + } + + if (event != 0 && !INSTR_TIME_IS_ZERO(my_wait_event_timing->wait_start)) + { + instr_time now; + int64 duration_ns; + int idx; + WaitEventTimingEntry *entry = NULL; + + INSTR_TIME_SET_CURRENT(now); + duration_ns = INSTR_TIME_GET_NANOSEC(now) - + INSTR_TIME_GET_NANOSEC(my_wait_event_timing->wait_start); + + if (duration_ns < 0) + duration_ns = 0; + + idx = wait_event_timing_index(event); + + /* + * Single-writer hot path: each slot has exactly one writer (the + * owning backend), and the SRF reader is lock-free, so no locking is + * needed here. Events that do not map to a slot -- an LWLock tranche + * beyond the per-backend cap, or a class unknown to the timing tables + * -- bump a per-backend overflow counter. We deliberately do not log + * here: this runs inline in every wait_end, potentially deep in the + * backend stack, so the overflow counters are surfaced through a + * statistics view rather than through ereport(). */ if (idx == WAIT_EVENT_TIMING_IDX_LWLOCK) entry = lwlock_timing_lookup(my_wait_event_timing, event & 0xFFFF); @@ -932,6 +1925,81 @@ pgstat_report_wait_end_timing(int capture_level) else if (idx == -1) my_wait_event_timing->flat_overflow_count++; + /* Trace level: push the completed wait into the per-session ring. */ + if (capture_level == WAIT_EVENT_CAPTURE_TRACE && + !wait_event_trace_writes_disabled && + !wait_event_trace_in_write) + { + /* + * Lazy-attach the ring on first use here (not in the assign hook, + * which must not ereport on OOM). The writes-disabled gate also + * blocks this re-attach during slot-state transitions + * (release_slot / before_shmem_exit): without it a nested wait + * event mid-transition could recurse into a fresh attach that + * deadlocks on the lock the transition already holds. + */ + if (my_wait_event_trace == NULL && my_trace_proc_number >= 0) + wait_event_trace_attach(my_trace_proc_number); + + if (my_wait_event_trace != NULL) + { + /* + * Single-writer claim: a plain read+write avoids the LOCK + * XADD that pg_atomic_fetch_add_u64 would emit on every wait + * event. Cross-backend readers use pg_atomic_read_u64 and + * rely on the per-record seqlock below for safety. + */ + uint64 pos = pg_atomic_read_u64(&my_wait_event_trace->write_pos); + WaitEventTraceRecord *rec; + uint32 seq; + + /* + * Wait events emitted while a trace record is being written + * must not themselves be ring-recorded (the gate above): the + * record write is plain stores and cannot wait, but the + * injection point below can, and the wait machinery it runs + * (DSM registry lookup, condition-variable sleep) emits wait + * events whose wait_end would recurse into this block -- + * re-running the injection point and self-deadlocking on + * locks the outer invocation still holds. Costs one + * process-local store each way; nested waits still + * accumulate in the stats above. + */ + wait_event_trace_in_write = true; + + pg_atomic_write_u64(&my_wait_event_trace->write_pos, pos + 1); + + /* + * Injection point for the regression test of the + * position-encoded identity seqlock: stalling here widens the + * window between the write_pos store and the rec->seq store, + * simulating weak-memory visibility that would otherwise be + * unreachable on x86. Compiled out unless + * --enable-injection-points. + */ + INJECTION_POINT("wait-event-trace-after-write-pos", NULL); + + rec = &my_wait_event_trace->records[pos & my_wait_event_trace->ring_mask]; + seq = (uint32) (pos * 2 + 1); + + rec->seq = seq; + pg_write_barrier(); /* payload stores must not rise above + * seq=odd */ + + rec->record_type = TRACE_WAIT_EVENT; + rec->timestamp_ns = INSTR_TIME_GET_NANOSEC(now); + rec->data.wait.event = event; + rec->data.wait.pad2 = 0; + rec->data.wait.duration_ns = duration_ns; + + pg_write_barrier(); /* payload stores must land before + * seq=even */ + rec->seq = seq + 1; + + wait_event_trace_in_write = false; + } + } + INSTR_TIME_SET_ZERO(my_wait_event_timing->wait_start); } } @@ -1286,4 +2354,738 @@ pg_stat_reset_wait_event_timing_all(PG_FUNCTION_ARGS) PG_RETURN_VOID(); } +/* ================= Trace-level readers ================= */ + +/* + * SQL function: pg_get_backend_wait_event_trace() + * + * Returns trace records from the current backend's own ring buffer. This + * function is deliberately session-local; cross-backend reading goes + * through pg_get_wait_event_trace(procnumber) below, which implements the + * snapshot-under-lock protocol documented on WaitEventTraceControl in + * wait_event_timing.h. The name mirrors pg_get_backend_memory_contexts() + * to make the session-local scope explicit at the API level. + * + * Same-backend coordination with wait_event_trace_release_slot uses the + * wait_event_trace_srf_in_progress / _release_pending flags rather than + * an LWLock: same-backend serialization is implicit, so a per-backend + * bool plus a deferred-free path is sufficient and avoids any of the + * cross-backend lock-hold latency that the cross-backend reader pattern + * has to manage. PG_TRY/PG_FINALLY guarantees the flag is cleared and + * any deferred dsa_free is performed even on ereport(ERROR). + * + * Uses InitMaterializedSRF (materialize-all). The ring holds up to + * WaitEventTraceRingSize records (set at server start from the + * wait_event_trace_ring_size GUC; default 4 MB = 131072 records); + * full materialization caps the per-call cost at the ring size of + * tuplestore memory, which is acceptable for the use case this SRF + * is designed for: interactive own-session diagnostics from psql. + * + * This SRF is NOT the path for cross-backend monitoring tools: it is + * hard-coded to the calling backend's own ring, so a bgworker selecting + * from pg_backend_wait_event_trace via SPI would see only its own + * (typically empty) ring. Cross-backend consumers use + * pg_get_wait_event_trace(procnumber) for SQL access, or implement the + * snapshot-under-lock protocol on WaitEventTraceControl directly. + * + * value-per-call (deferred) SRF mode would let an interactive + * "SELECT ... FROM pg_backend_wait_event_trace LIMIT N" short-circuit + * the materialisation, but converting this function would require + * spanning the wait_event_trace_srf_in_progress flag (and its + * deferred-free coordination with assign_wait_event_capture) across + * multiple SRF callbacks plus a transaction-cleanup registration to + * handle LIMIT abandonment. The complexity is not + * justified for the diagnostic use case, especially since cross- + * backend monitoring (the consumer that would actually benefit from + * streaming) goes through the snapshot pattern above instead. + * Interactive callers who want only recent records should use + * "ORDER BY seq DESC LIMIT N" -- the LIMIT is applied after + * materialisation but the cost stays bounded by the ring size. + */ +Datum +pg_get_backend_wait_event_trace(PG_FUNCTION_ARGS) +{ + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + WaitEventTraceState *ts; + uint64 write_pos; + uint64 read_start; + uint64 i; + + InitMaterializedSRF(fcinfo, 0); + + if (my_wait_event_trace == NULL) + PG_RETURN_VOID(); + + ts = my_wait_event_trace; + + write_pos = pg_atomic_read_u64(&ts->write_pos); + + if (write_pos == 0) + PG_RETURN_VOID(); + + /* Read from oldest available to newest */ + { + uint64 ring_size = (uint64) ts->ring_mask + 1; + + read_start = (write_pos > ring_size) + ? write_pos - ring_size : 0; + } + + /* + * Mark the iteration in progress so wait_event_trace_release_slot defers + * any concurrent dsa_free of our own ring (see the comment on that + * function for the deferral protocol). PG_FINALLY clears the flag and + * performs any deferred free, even on ereport(ERROR). + */ + wait_event_trace_srf_in_progress = true; + PG_TRY(); + { + for (i = read_start; i < write_pos; i++) + { + WaitEventTraceRecord *rec = + &ts->records[i & ts->ring_mask]; + Datum values[6]; + bool nulls[6]; + const char *event_type; + const char *event_name; + uint32 seq_before; + uint32 seq_after; + uint8 rtype; + int64 timestamp_ns; + uint32 event_info; + int64 duration_ns; + int64 query_id; + + /* Seqlock read */ + seq_before = rec->seq; + pg_read_barrier(); /* acquire: payload loads below must not rise + * above this */ + + if (seq_before & 1) + continue; + + rtype = rec->record_type; + timestamp_ns = rec->timestamp_ns; + + if (rtype == TRACE_WAIT_EVENT) + { + event_info = rec->data.wait.event; + duration_ns = rec->data.wait.duration_ns; + query_id = 0; + } + else if (rtype == TRACE_QUERY_START || rtype == TRACE_QUERY_END || + rtype == TRACE_EXEC_START || rtype == TRACE_EXEC_END) + { + event_info = 0; + duration_ns = 0; + query_id = rec->data.query.query_id; + } + else + { + pg_read_barrier(); /* acquire: pair with seq_before read + * above before skipping */ + continue; + } + + pg_read_barrier(); /* acquire: payload loads must have landed + * before seq_after */ + seq_after = rec->seq; + + if (seq_before != seq_after) + continue; + + /* Skip empty wait events */ + if (rtype == TRACE_WAIT_EVENT && event_info == 0) + continue; + + if (rtype == TRACE_WAIT_EVENT) + { + event_type = pgstat_get_wait_event_type(event_info); + event_name = pgstat_get_wait_event(event_info); + } + else if (rtype == TRACE_QUERY_START) + { + event_type = "Query"; + event_name = "QueryStart"; + } + else if (rtype == TRACE_EXEC_START) + { + event_type = "Query"; + event_name = "ExecStart"; + } + else if (rtype == TRACE_EXEC_END) + { + event_type = "Query"; + event_name = "ExecEnd"; + } + else + { + event_type = "Query"; + event_name = "QueryEnd"; + } + + if (event_type == NULL || event_name == NULL) + continue; + + memset(nulls, 0, sizeof(nulls)); + + values[0] = Int64GetDatum((int64) i); + values[1] = Int64GetDatum(timestamp_ns); + values[2] = CStringGetTextDatum(event_type); + values[3] = CStringGetTextDatum(event_name); + values[4] = Float8GetDatum((double) duration_ns / 1000.0); + values[5] = Int64GetDatum(query_id); + + tuplestore_putvalues(rsinfo->setResult, + rsinfo->setDesc, + values, nulls); + } + } + PG_FINALLY(); + { + wait_event_trace_srf_in_progress = false; + + /* + * If a GUC step-down fired during iteration, it deferred the + * dsa_free. Process it now that we're safely past the loop. Re-check + * release_pending under the same flag to handle the + * (impossible-today, possible-tomorrow) case of a nested SRF. + */ + if (wait_event_trace_release_pending) + { + wait_event_trace_release_pending = false; + if (my_trace_proc_number >= 0) + wait_event_trace_release_slot(my_trace_proc_number); + } + } + PG_END_TRY(); + + PG_RETURN_VOID(); +} + +/* + * One element of the local result buffer. Pairs a per-record copy + * with the original ring index (used as the seq output column). + */ +typedef struct WetValidRecord +{ + uint64 ring_index; /* original index in the writer's ring */ + WaitEventTraceRecord rec; +} WetValidRecord; + +/* + * Snapshot the trace ring for a given procNumber and emit records into + * the SRF's tuplestore. Returns silently for FREE slots, out-of-range + * procnumbers, slots whose ring was never allocated, and slots whose + * write_pos is zero. + * + * Cross-backend reader protocol implemented here: + * + * 1. Read slot->state without the lock as a cheap "worth visiting" + * check; FREE -> nothing to emit. + * 2. Allocate the worst-case result buffer BEFORE taking the lock, + * so the palloc -- which can bottom out in a glibc mmap syscall + * for the worst-case (full-ring) size -- runs without holding the + * WaitEventTraceCtl lock. + * 3. Acquire WaitEventTraceCtl->lock in LW_SHARED. All slot + * transitions take LW_EXCLUSIVE, so the slot's identity, state, + * and ring_ptr are stable for the duration of the iteration. + * 4. Re-check state under the lock and resolve ring_ptr via + * dsa_get_address. Read write_pos. + * 5. Iterate every live ring index [read_start, write_pos). For + * each record do the per-record POSITION-ENCODED IDENTITY + * seqlock check ON SHARED MEMORY (see the comment on the loop + * below). + * 6. Release the lock. + * 7. Walk the local result array and emit rows into the tuplestore. + * This is the expensive part (potential disk spill); doing it + * after release minimises lock-hold time. + * + * Why per-record seqlock against shared memory, not against a local + * memcpy of the full ring: the protocol requires the two seq reads + * to go to the SAME shared-memory location at DIFFERENT TIMES, with + * the payload read between them. A bulk memcpy then seqlock-on- + * local-copy reads the same frozen byte twice, the check degenerates + * to a no-op, and torn / stale-cycle reads slip through. + * + * Why position-encoded identity, not just parity: the writer encodes + * the ring position into the seq value (mid-write = pos*2+1, complete + * = pos*2+2). After RING_SIZE writes the slot wraps and is rewritten + * with a new numerically-distinct seq. A parity-only check accepts + * any stable even seq -- including the PREVIOUS cycle's seq if cross- + * process visibility puts the new write_pos ahead of the new seq + * update. See the loop body for the four failure modes the identity + * check rejects. + * + * Holding LW_SHARED throughout the iteration also makes the + * generation-counter retry unnecessary for this caller: slot + * transitions take LW_EXCLUSIVE and therefore cannot happen while we + * hold LW_SHARED. The generation counter is still part of the + * cross-backend reader contract on WaitEventTraceControl for external + * readers that follow a different lock-release pattern (e.g. an + * extension that wants to release the lock between batches of records + * and re-acquire), but this in-tree implementation does not release + * the lock mid-iteration. + * + * Both OWNED and ORPHANED slots are read uniformly. For OWNED the + * live owner is concurrently writing; the seqlock catches torn reads. + * For ORPHANED the records are immutable post-mortem so the check is + * essentially a pass-through (it still correctly skips at most one + * trailing odd-seq record if the owner died mid-write). + * + * Lock-hold is O(write_pos - read_start) shared-memory loads, at + * roughly the same wall-clock cost as a single 4 MB memcpy of the + * full ring (~1 ms on modern hardware), with no I/O and no syscalls. + */ +static void +emit_wait_event_trace_for_procnumber(int procNumber, ReturnSetInfo *rsinfo) +{ + WaitEventTraceSlot *slot; + WaitEventTraceState *ts; + WetValidRecord *valid_records = NULL; + uint64 valid_count = 0; + uint64 write_pos; + uint64 read_start; + uint64 i; + uint32 state_now; + + if (WaitEventTraceCtl == NULL) + return; + + /* + * Range check. Negative or out-of-range procnumbers return an empty + * result rather than ERRORing because the most natural use pattern for + * cross-backend readers is to iterate every possible slot index (a + * monitoring background worker doesn't know the exact + * NUM_WAIT_EVENT_TIMING_SLOTS at SQL level), and silent- empty for + * out-of-range matches the behaviour of sister functions like + * pg_stat_get_wait_event_timing(NULL) which iterate the shared array + * internally. FREE-but-in-range slots also return empty (see the state + * check below); the caller cannot distinguish out-of-range from FREE, + * which is fine. + */ + if (procNumber < 0 || procNumber >= NUM_WAIT_EVENT_TIMING_SLOTS) + return; + + slot = &WaitEventTraceCtl->trace_slots[procNumber]; + + /* + * If the trace DSA was never created (no backend in the cluster has ever + * set wait_event_capture = trace), every slot is still in its initial + * FREE state. Skip without taking the lock. + */ + if (WaitEventTraceCtl->trace_dsa_handle == DSA_HANDLE_INVALID) + return; + + /* + * Unlocked fast-path check; the authoritative check is under the lock + * below. + */ + if (pg_atomic_read_u32(&slot->state) == WAIT_EVENT_TRACE_SLOT_FREE) + return; + + wait_event_trace_ensure_dsa(); + if (trace_dsa == NULL) + return; + + /* + * Allocate the worst-case result buffer BEFORE taking the lock. The + * buffer is sized for the full ring (sizeof(WetValidRecord) * + * WaitEventTraceRingSize, e.g. ~5 MB at the 4 MB default ring and up to + * ~42 MB at the 32 MB maximum); on a near-empty ring most goes unused, + * but that is preferable to holding the WaitEventTraceCtl lock during a + * palloc that may bottom out in a glibc mmap() syscall (allocations above + * the malloc-mmap threshold). Glibc's arena-internal mutex around the + * syscall would serialise every concurrent reader of this lock through + * one VMA-modifying kernel operation; sizing the alloc outside the lock + * keeps the lock-hold time bounded by the per-record loop alone. + * + * After we acquire the lock we will either consume this buffer (writing + * up to (write_pos - read_start) entries) or release it unused on an + * early return. + */ + + /* + * Worst-case size = ring size. Derive it from the GUC on first use in + * this backend; subsequent calls see the cached value. The GUC is + * PGC_POSTMASTER so the value is the same across every backend in this + * postmaster run and never changes. + */ + if (WaitEventTraceRingSize == 0) + WaitEventTraceRingSize = + (uint32) wait_event_trace_ring_size * 1024U / + (uint32) sizeof(WaitEventTraceRecord); + valid_records = palloc(sizeof(WetValidRecord) * WaitEventTraceRingSize); + + LWLockAcquire(&WaitEventTraceCtl->lock, LW_SHARED); + + state_now = pg_atomic_read_u32(&slot->state); + if (state_now == WAIT_EVENT_TRACE_SLOT_FREE || + !DsaPointerIsValid(slot->ring_ptr)) + { + LWLockRelease(&WaitEventTraceCtl->lock); + pfree(valid_records); + return; + } + + ts = (WaitEventTraceState *) dsa_get_address(trace_dsa, slot->ring_ptr); + write_pos = pg_atomic_read_u64(&ts->write_pos); + + if (write_pos == 0) + { + LWLockRelease(&WaitEventTraceCtl->lock); + pfree(valid_records); + return; + } + + /* Live range: oldest available to newest. */ + { + uint64 ring_size = (uint64) ts->ring_mask + 1; + + read_start = (write_pos > ring_size) + ? write_pos - ring_size : 0; + } + + for (i = read_start; i < write_pos; i++) + { + WaitEventTraceRecord *rec_shared = + &ts->records[i & ts->ring_mask]; + WetValidRecord *out = &valid_records[valid_count]; + uint32 expected_seq; + uint32 seq_before; + uint32 seq_after; + + /* + * Position-encoded seqlock identity check (NOT just parity). + * + * The writer encodes the ring position into the seq value: mid-write + * -> (uint32)(pos * 2 + 1), complete -> + 2. After RING_SIZE writes + * the slot wraps and the same memory location gets a new seq value + * (next_pos * 2 + 2) that is numerically distinct from the previous + * cycle's seq. + * + * A parity-only check (skip on odd seq, accept on stable even) is + * INSUFFICIENT for this layout in the cross-backend case: if the + * writer just incremented write_pos to pos+1 but cross-process cache + * coherence has not yet propagated the subsequent rec->seq = + * (pos*2+1) store, this reader at i = pos would see the previous + * cycle's complete-even seq (from logical position pos - RING_SIZE). + * Both seq_before and seq_after would read that stale even value, + * parity passes, identity-against-itself passes, and a record + * belonging to the PREVIOUS cycle gets emitted with the new + * ring_index = pos. Silent data corruption (wrong attribution, not + * torn bytes). + * + * The fix is identity against EXPECTED: a record is valid for + * iterator position i if and only if its seq equals (uint32)(i * 2 + + * 2) -- the writer's encoded "complete" value for that exact ring + * position. This rejects: + * + * * Stale prior cycle (seq < expected): writer hasn't yet advanced + * rec->seq for the current cycle. * Mid-write current cycle (seq == + * expected - 1, odd): writer is in the payload write window. * Ring + * wrapped past us (seq > expected): the writer completed a later + * cycle on this slot during our read. + * + * The uint32 wraparound at 2^31 cycles is safe: we use exact + * equality, and the writer's existing wrap-safety argument + * (sizeof(seq) > worst-case in-flight window by 11 orders of + * magnitude) covers the seq value. + */ + expected_seq = (uint32) (i * 2 + 2); + + seq_before = rec_shared->seq; + pg_read_barrier(); + + if (seq_before != expected_seq) + continue; + + out->rec = *rec_shared; /* one 32-byte structure copy */ + + pg_read_barrier(); + seq_after = rec_shared->seq; + + if (seq_after != expected_seq) + continue; + + out->ring_index = i; + valid_count++; + } + + LWLockRelease(&WaitEventTraceCtl->lock); + + /* + * Walk the local result array and emit rows. No shared-memory access + * from here on, so spills to disk by the tuplestore (if the result is + * large) do not hold any wait-event-timing lock. + */ + for (i = 0; i < valid_count; i++) + { + WetValidRecord *vr = &valid_records[i]; + WaitEventTraceRecord *rec = &vr->rec; + Datum values[6]; + bool nulls[6]; + const char *event_type; + const char *event_name; + uint8 rtype = rec->record_type; + uint32 event_info; + int64 duration_ns; + int64 query_id; + + if (rtype == TRACE_WAIT_EVENT) + { + event_info = rec->data.wait.event; + duration_ns = rec->data.wait.duration_ns; + query_id = 0; + + /* Skip empty wait events. */ + if (event_info == 0) + continue; + + event_type = pgstat_get_wait_event_type(event_info); + event_name = pgstat_get_wait_event(event_info); + } + else if (rtype == TRACE_QUERY_START) + { + event_info = 0; + duration_ns = 0; + query_id = rec->data.query.query_id; + event_type = "Query"; + event_name = "QueryStart"; + } + else if (rtype == TRACE_QUERY_END) + { + event_info = 0; + duration_ns = 0; + query_id = rec->data.query.query_id; + event_type = "Query"; + event_name = "QueryEnd"; + } + else if (rtype == TRACE_EXEC_START) + { + event_info = 0; + duration_ns = 0; + query_id = rec->data.query.query_id; + event_type = "Query"; + event_name = "ExecStart"; + } + else if (rtype == TRACE_EXEC_END) + { + event_info = 0; + duration_ns = 0; + query_id = rec->data.query.query_id; + event_type = "Query"; + event_name = "ExecEnd"; + } + else + { + /* Unrecognised record_type -- skip defensively. */ + continue; + } + + if (event_type == NULL || event_name == NULL) + continue; + + memset(nulls, 0, sizeof(nulls)); + + values[0] = Int64GetDatum((int64) vr->ring_index); + values[1] = Int64GetDatum(rec->timestamp_ns); + values[2] = CStringGetTextDatum(event_type); + values[3] = CStringGetTextDatum(event_name); + values[4] = Float8GetDatum((double) duration_ns / 1000.0); + values[5] = Int64GetDatum(query_id); + + tuplestore_putvalues(rsinfo->setResult, + rsinfo->setDesc, + values, nulls); + } + + pfree(valid_records); +} + +/* + * SQL function: pg_get_wait_event_trace(procnumber int4) + * + * Cross-backend trace ring reader. Returns the records from the trace + * ring belonging to the backend that currently or previously occupied + * the given procNumber slot. Reads OWNED and ORPHANED slots uniformly; + * FREE slots return an empty result. + * + * This SRF is the in-tree consumer of the orphan-preserved trace data: + * a backend that exited while wait_event_capture = trace leaves its + * ring allocated in DSA in ORPHANED state, and this function reads it + * until either a new backend takes over the same procNumber or the + * DBA calls pg_stat_clear_orphaned_wait_event_rings(). External + * extensions that need cross-backend access follow the same + * snapshot pattern documented on WaitEventTraceControl in + * wait_event_timing.h; this function serves as both the reference + * implementation and a DBA-facing diagnostic tool. + * + * Privileges: REVOKE'd from PUBLIC and GRANT'ed to pg_read_all_stats + * in system_views.sql, matching the privilege model of the session- + * local view pg_backend_wait_event_trace. + * + * The procnumber argument can be obtained from the procnumber column + * of pg_stat_get_wait_event_timing or pg_stat_get_wait_event_timing_ + * overflow. For pid-keyed access against live backends, callers can + * do: + * + * SELECT * FROM pg_get_wait_event_trace( + * (SELECT procnumber FROM pg_stat_get_wait_event_timing() + * WHERE pid = LIMIT 1)); + * + * Note that pid-keyed access cannot read ORPHANED slots because a + * dying backend's pid is removed from procArray on exit; for + * post-mortem reading of short-lived backends (parallel workers, + * autovacuum, walsender) the procNumber must be captured before the + * backend exits, or discovered by iterating procnumbers in a + * monitoring background worker. + */ +Datum +pg_get_wait_event_trace(PG_FUNCTION_ARGS) +{ + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + int32 procNumber = PG_GETARG_INT32(0); + + InitMaterializedSRF(fcinfo, 0); + + emit_wait_event_trace_for_procnumber((int) procNumber, rsinfo); + + PG_RETURN_VOID(); +} + +/* + * SQL function: pg_stat_clear_orphaned_wait_event_rings() + * + * Free every trace ring whose owner has exited (slot state ORPHANED). + * Returns the number of rings released. + * + * Why this exists. When a backend that had wait_event_capture = trace + * exits, we deliberately do NOT free its ~4 MB trace ring (see the + * lifecycle discussion on WaitEventTraceControl): the data must remain + * readable by cross-backend consumers -- the in-tree + * pg_get_wait_event_trace SRF and any extension following the + * snapshot pattern on WaitEventTraceControl -- and an exit-time + * dsa_free would defeat that. + * The reclaim instead happens lazily in two places: + * + * (a) wait_event_trace_clear_orphan_at_init(): when a new backend + * inherits the same procNumber slot at init, it frees the prior + * orphan as part of starting clean. This handles the common + * case (busy clusters with connection churn) automatically. + * + * (b) THIS FUNCTION: an explicit DBA-driven sweep that releases + * every currently orphaned ring at once. + * + * The pathological case (a) does not handle is "capture briefly + * enabled, then disabled, on a cluster with long-lived pooled + * connections that never exit". In that scenario procNumbers do not + * recycle, so prior orphans persist until cluster restart unless the + * DBA calls this function. Worst-case bound is + * NUM_WAIT_EVENT_TIMING_SLOTS * sizeof(WaitEventTraceState) which is + * ~400 MB at MaxBackends=100, ~4 GB at MaxBackends=1000 -- bounded + * but worth a kill switch. + * + * Permissions: execution is revoked from PUBLIC by default, matching the + * cluster-wide reset (pg_stat_reset_wait_event_timing_all). This is a + * cluster-scope memory-reclamation operation: it can disrupt any + * concurrent cross-backend reader on any orphaned slot. The + * disruption is bounded (readers retry via the generation counter + * and at worst skip one read) but the operation is still + * cluster-wide, so the default privilege matches the reset variant + * with the same blast radius; administrators can delegate with GRANT. + * + * The function is safe to call even when no orphans exist (returns + * 0) and even when capture is currently OFF (the slot array exists + * unconditionally; only the rings are lazy). + */ +Datum +pg_stat_clear_orphaned_wait_event_rings(PG_FUNCTION_ARGS) +{ + int64 freed = 0; + int i; + + /* + * Execution is revoked from PUBLIC in system_views.sql; administrators + * can delegate with GRANT EXECUTE. + */ + if (WaitEventTraceCtl == NULL) + PG_RETURN_INT64(0); + + /* + * If no backend has ever enabled trace, the trace DSA was never created + * and there cannot be any ORPHANED slots: every slot is still in its + * initial FREE state. Nothing to do. + */ + if (WaitEventTraceCtl->trace_dsa_handle == DSA_HANDLE_INVALID) + PG_RETURN_INT64(0); + + /* Attach to the trace DSA so dsa_free() can be called. */ + wait_event_trace_ensure_dsa(); + if (trace_dsa == NULL) + PG_RETURN_INT64(0); + + /* + * Walk every slot, taking and releasing WaitEventTraceCtl->lock per slot + * rather than holding it across the entire sweep. + * + * Rationale: at MaxBackends = 1000 with a fully-orphaned cluster the + * per-slot work (atomic state read + dsa_free + ring_ptr clear + atomic + * state write) totals a few microseconds; holding the lock across all + * slots would yield a millisecond-scale lock-hold window during which + * every concurrent backend startup (the lazy + * wait_event_trace_clear_orphan_at_init path), every cross-backend reader + * (pg_get_wait_event_trace and the external snapshot pattern), and every + * capture step-down or restore would stall. PG's general convention is + * to keep LWLock-held windows in paths that compete with regular activity + * well under 100 microseconds; per-slot release/reacquire gives us a + * worst- case lock-hold of one slot's worth of work regardless of how + * many orphans exist cluster-wide. + * + * An unlocked fast-path read of slot->state skips non-ORPHANED slots + * without an LWLockAcquire/Release pair. This is safe: if a slot races + * from non-ORPHANED to ORPHANED after we read it, we miss that orphan -- + * but the function is documented as a snapshot sweep, the missed orphan + * can be cleared by a subsequent call, and the same race exists for + * orphans that appear after the loop ends. The authoritative re-check + * under the lock prevents racing on the dsa_free direction (we never free + * a slot whose owner became OWNED again). + * + * CHECK_FOR_INTERRUPTS at the top of the loop body lets the caller cancel + * a long sweep; with the previous single-lock structure the + * InterruptHoldoffCount elevation from LWLockAcquire deferred all + * cancellation until release. + */ + for (i = 0; i < NUM_WAIT_EVENT_TIMING_SLOTS; i++) + { + WaitEventTraceSlot *slot = &WaitEventTraceCtl->trace_slots[i]; + + CHECK_FOR_INTERRUPTS(); + + /* Unlocked fast-path: skip non-ORPHANED slots cheaply. */ + if (pg_atomic_read_u32(&slot->state) != WAIT_EVENT_TRACE_SLOT_ORPHANED) + continue; + + LWLockAcquire(&WaitEventTraceCtl->lock, LW_EXCLUSIVE); + + /* + * Authoritative re-check under the lock. A concurrent + * clear_orphan_at_init may have already freed this slot. + */ + if (pg_atomic_read_u32(&slot->state) == WAIT_EVENT_TRACE_SLOT_ORPHANED && + DsaPointerIsValid(slot->ring_ptr)) + { + pg_atomic_fetch_add_u64(&slot->generation, 1); + dsa_free(trace_dsa, slot->ring_ptr); + slot->ring_ptr = InvalidDsaPointer; + pg_atomic_write_u32(&slot->state, WAIT_EVENT_TRACE_SLOT_FREE); + freed++; + } + + LWLockRelease(&WaitEventTraceCtl->lock); + } + + PG_RETURN_INT64(freed); +} + #endif /* USE_WAIT_EVENT_TIMING */ diff --git a/src/backend/utils/init/postinit.c b/src/backend/utils/init/postinit.c index 3d8c9bdebd559..b7e4caa9e3b8c 100644 --- a/src/backend/utils/init/postinit.c +++ b/src/backend/utils/init/postinit.c @@ -70,6 +70,7 @@ #include "utils/snapmgr.h" #include "utils/syscache.h" #include "utils/timeout.h" +#include "utils/wait_event_timing.h" /* has this backend called EmitConnectionWarnings()? */ static bool ConnectionWarningsEmitted; @@ -1250,6 +1251,16 @@ InitPostgres(const char *in_dbname, Oid dboid, /* Process pg_db_role_setting options */ process_settings(MyDatabaseId, GetSessionUserId()); +#ifdef USE_WAIT_EVENT_TIMING + + /* + * Attach trace ring if wait_event_capture = trace was set via + * config/db/role settings + */ + if (wait_event_capture == WAIT_EVENT_CAPTURE_TRACE && my_trace_proc_number >= 0) + wait_event_trace_attach(my_trace_proc_number); +#endif + /* Apply PostAuthDelay as soon as we've read all options */ if (PostAuthDelay > 0) pg_usleep(PostAuthDelay * 1000000L); diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index 92b134ccc8f3c..ec56641492873 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -3455,6 +3455,17 @@ max => '65534', }, +{ name => 'wait_event_trace_ring_size', type => 'int', context => 'PGC_POSTMASTER', group => 'STATS_CUMULATIVE', + short_desc => 'Sets the size of each backend\'s wait-event-trace ring buffer.', + long_desc => 'Each backend that enables wait_event_capture = trace allocates a ring buffer of this size from a cluster-wide DSA. The value must be a power of two and is fixed at server start. Larger rings retain longer histories before wrapping.', + flags => 'GUC_UNIT_KB', + variable => 'wait_event_trace_ring_size', + boot_val => '4096', + min => '8', + max => '32768', + check_hook => 'check_wait_event_trace_ring_size', +}, + { name => 'wal_block_size', type => 'int', context => 'PGC_INTERNAL', group => 'PRESET_OPTIONS', short_desc => 'Shows the block size in the write ahead log.', flags => 'GUC_NOT_IN_SAMPLE | GUC_DISALLOW_IN_FILE', diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index bab7de3eee804..3c0958fc464c9 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -704,9 +704,11 @@ #track_cost_delay_timing = off #track_io_timing = off #track_wal_io_timing = off -#wait_event_capture = off # off, stats +#wait_event_capture = off # off, stats, trace # (requires --enable-wait-event-timing) #wait_event_timing_max_tranches = 192 # (change requires restart) +#wait_event_trace_ring_size = 4MB # power of two, 8kB .. 32MB + # (change requires restart) #track_functions = none # none, pl, all #stats_fetch_consistency = cache # cache, none, snapshot diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index 3c33a44bfe69e..353abe1825b28 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -12748,4 +12748,30 @@ provolatile => 'v', prorettype => 'void', proargtypes => '', prosrc => 'pg_stat_reset_wait_event_timing_all' }, +{ oid => '9957', + descr => 'current backend wait event trace ring buffer', + proname => 'pg_get_backend_wait_event_trace', prorows => '1000', + proretset => 't', provolatile => 's', proparallel => 'r', + prorettype => 'record', proargtypes => '', + proallargtypes => '{int8,int8,text,text,float8,int8}', + proargmodes => '{o,o,o,o,o,o}', + proargnames => '{seq,timestamp_ns,wait_event_type,wait_event,duration_us,query_id}', + prosrc => 'pg_get_backend_wait_event_trace' }, + +{ oid => '9962', + descr => 'wait event trace ring for the given procnumber slot, live or post-mortem', + proname => 'pg_get_wait_event_trace', prorows => '1000', + proretset => 't', provolatile => 'v', proparallel => 'r', + prorettype => 'record', proargtypes => 'int4', + proallargtypes => '{int4,int8,int8,text,text,float8,int8}', + proargmodes => '{i,o,o,o,o,o,o}', + proargnames => '{procnumber,seq,timestamp_ns,wait_event_type,wait_event,duration_us,query_id}', + prosrc => 'pg_get_wait_event_trace' }, + +{ oid => '9961', + descr => 'statistics: free wait-event-trace rings whose owner backend has exited (superuser only); returns count freed', + proname => 'pg_stat_clear_orphaned_wait_event_rings', + provolatile => 'v', prorettype => 'int8', proargtypes => '', + prosrc => 'pg_stat_clear_orphaned_wait_event_rings' }, + ] diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h index f0a27019a114f..ab842923db45e 100644 --- a/src/include/storage/lwlocklist.h +++ b/src/include/storage/lwlocklist.h @@ -141,3 +141,4 @@ PG_LWLOCKTRANCHE(PARALLEL_VACUUM_DSA, ParallelVacuumDSA) PG_LWLOCKTRANCHE(AIO_URING_COMPLETION, AioUringCompletion) PG_LWLOCKTRANCHE(SHMEM_INDEX, ShmemIndex) PG_LWLOCKTRANCHE(WAIT_EVENT_TIMING_DSA, WaitEventTimingDSA) +PG_LWLOCKTRANCHE(WAIT_EVENT_TRACE_DSA, WaitEventTraceDSA) diff --git a/src/include/storage/subsystemlist.h b/src/include/storage/subsystemlist.h index 331d60cf703ee..90b142354644c 100644 --- a/src/include/storage/subsystemlist.h +++ b/src/include/storage/subsystemlist.h @@ -80,6 +80,7 @@ PG_SHMEM_SUBSYSTEM(AsyncShmemCallbacks) PG_SHMEM_SUBSYSTEM(StatsShmemCallbacks) PG_SHMEM_SUBSYSTEM(WaitEventCustomShmemCallbacks) PG_SHMEM_SUBSYSTEM(WaitEventTimingShmemCallbacks) +PG_SHMEM_SUBSYSTEM(WaitEventTraceControlShmemCallbacks) #ifdef USE_INJECTION_POINTS PG_SHMEM_SUBSYSTEM(InjectionPointShmemCallbacks) #endif diff --git a/src/include/utils/guc_hooks.h b/src/include/utils/guc_hooks.h index 92bbea1fa3bd9..991d68a088a6b 100644 --- a/src/include/utils/guc_hooks.h +++ b/src/include/utils/guc_hooks.h @@ -171,6 +171,7 @@ extern void assign_transaction_timeout(int newval, void *extra); extern const char *show_unix_socket_permissions(void); extern bool check_wait_event_capture(int *newval, void **extra, GucSource source); extern void assign_wait_event_capture(int newval, void *extra); +extern bool check_wait_event_trace_ring_size(int *newval, void **extra, GucSource source); extern bool check_wal_buffers(int *newval, void **extra, GucSource source); extern bool check_wal_consistency_checking(char **newval, void **extra, GucSource source); diff --git a/src/include/utils/wait_event_timing.h b/src/include/utils/wait_event_timing.h index f1408dab279e8..03f74a84fe1a2 100644 --- a/src/include/utils/wait_event_timing.h +++ b/src/include/utils/wait_event_timing.h @@ -30,6 +30,8 @@ #include "port/atomics.h" #include "portability/instr_time.h" +#include "storage/lwlock.h" +#include "utils/dsa.h" #include "utils/wait_event_types.h" /* @@ -40,23 +42,26 @@ * OFF - No instrumentation, no hot-path cost. * STATS - Aggregated per-event statistics (counts, durations, histogram) * exposed via pg_stat_wait_event_timing. - * - * A further TRACE level is added later in the series. + * TRACE - Everything in STATS plus a per-session ring buffer of individual + * wait events and query-attribution markers, exposed via + * pg_backend_wait_event_trace. */ typedef enum WaitEventCaptureLevel { WAIT_EVENT_CAPTURE_OFF = 0, WAIT_EVENT_CAPTURE_STATS, + WAIT_EVENT_CAPTURE_TRACE, } WaitEventCaptureLevel; /* - * Pin the enum ordering at compile time so future code that compares with - * >= against WAIT_EVENT_CAPTURE_STATS keeps working, and so reordering is - * caught at build time rather than via mysterious runtime mode switches. + * Pin the enum ordering at compile time so code that compares with >= keeps + * working, and so reordering is caught at build time rather than via + * mysterious runtime mode switches. */ StaticAssertDecl(WAIT_EVENT_CAPTURE_OFF == 0 && - WAIT_EVENT_CAPTURE_STATS == 1, - "WaitEventCaptureLevel values must be 0=OFF < 1=STATS"); + WAIT_EVENT_CAPTURE_STATS == 1 && + WAIT_EVENT_CAPTURE_TRACE == 2, + "WaitEventCaptureLevel values must be 0=OFF < 1=STATS < 2=TRACE"); /* * Number of log2 histogram buckets. Bin edges are powers of two on the @@ -186,11 +191,170 @@ extern PGDLLIMPORT int wait_event_capture; extern PGDLLIMPORT WaitEventTimingState *my_wait_event_timing; /* - * Called from InitProcess()/InitAuxiliaryProcess() to point - * my_wait_event_timing at this backend's slot, and from ProcKill() to - * clear it. + * Called from InitProcess()/InitAuxiliaryProcess() to set up this backend's + * timing/trace bookkeeping, and from ProcKill() to clear it. */ extern void pgstat_set_wait_event_timing_storage(int procNumber); extern void pgstat_reset_wait_event_timing_storage(void); + +/* ---------------------------------------------------------------------- + * Trace level (wait_event_capture = trace) + * + * In addition to the STATS aggregates, every completed wait (and a set of + * query-attribution markers) is pushed into a per-session ring buffer -- + * one record per completed wait. The ring is allocated lazily in DSA on + * first use, + * so only sessions that enable trace pay the per-ring memory cost. External + * tools read a session's ring via pg_get_backend_wait_event_trace() (own + * session) or pg_get_wait_event_trace(procnumber) (cross-backend). + * + * Query attribution is by scanning the ring at read time: QUERY/EXEC + * START/END markers delimit which wait events belong to which query_id. + * + * The ring size is set cluster-wide at server start by the + * wait_event_trace_ring_size GUC (PGC_POSTMASTER, default 4 MB). It + * MUST be a power of two: the writer indexes the ring as (pos & ring_mask). + * ---------------------------------------------------------------------- + */ + +/* Trace record types */ +#define TRACE_WAIT_EVENT 0 +#define TRACE_QUERY_START 1 +#define TRACE_QUERY_END 2 +#define TRACE_EXEC_START 3 +#define TRACE_EXEC_END 4 + +typedef struct WaitEventTraceRecord +{ + /* + * Seqlock for torn-read detection. Writers set seq odd before filling + * fields, then even after; readers check seq before and after and skip + * the record if either is odd or they differ. uint32 wrap is irrelevant + * over the ~10-20 ns reader access window. + */ + uint32 seq; + uint8 record_type; /* TRACE_WAIT_EVENT / QUERY_* / EXEC_* */ + uint8 pad[3]; + int64 timestamp_ns; /* monotonic clock */ + union + { + struct /* record_type = TRACE_WAIT_EVENT */ + { + uint32 event; /* wait_event_info */ + uint32 pad2; + int64 duration_ns; + } wait; + struct /* QUERY_START/END or EXEC_START/END */ + { + int64 query_id; + int64 pad2; + } query; + } data; +} WaitEventTraceRecord; /* 32 bytes */ + +/* + * The seqlock wrap-safety argument and the mask-index math both rely on a + * fixed 32-byte record stride; make a stray field addition a build failure. + */ +StaticAssertDecl(sizeof(WaitEventTraceRecord) == 32, + "WaitEventTraceRecord must be exactly 32 bytes"); + +/* + * Per-backend trace ring header followed by the records array. records[] + * is variably sized at allocation time (wait_event_trace_ring_size + * decides the row count). write_pos and ring_mask share a cache line so + * the hot-path index calculation touches one line. + */ +typedef struct WaitEventTraceState +{ + pg_atomic_uint64 write_pos; /* monotonically increasing, wraps via mask */ + uint32 ring_mask; /* ring_size - 1; ring_size is a power of two */ + uint32 ring_size_pad; /* keep the records[] slab 16-byte aligned */ + WaitEventTraceRecord records[FLEXIBLE_ARRAY_MEMBER]; +} WaitEventTraceState; + +/* + * Per-procNumber trace-ring slot lifecycle. Decoupled from backend + * lifecycle on purpose: when a backend exits we transition its slot to + * ORPHANED and leave the ring in DSA so cross-backend consumers can still + * read the dying backend's final waits. An orphan is reclaimed when a new + * backend takes the same procNumber, or by + * pg_stat_clear_orphaned_wait_event_rings(). + * + * FREE no ring allocated (ring_ptr invalid). + * OWNED a live backend at this procNumber is writing to the ring. + * ORPHANED the owner exited; the ring is post-mortem and immutable. + */ +typedef enum WaitEventTraceSlotState +{ + WAIT_EVENT_TRACE_SLOT_FREE = 0, + WAIT_EVENT_TRACE_SLOT_OWNED, + WAIT_EVENT_TRACE_SLOT_ORPHANED, +} WaitEventTraceSlotState; + +/* + * Per-procNumber slot. generation is bumped on every owner transition; + * cross-backend readers snapshot it before+after their read and retry if it + * changed (the BackendStatusArray st_changecount idiom). state is atomic + * only for cheap unlocked "worth visiting" probes; authoritative reads of + * (state, ring_ptr) are done under WaitEventTraceCtl->lock in LW_SHARED, + * while every transition holds it LW_EXCLUSIVE. + */ +typedef struct WaitEventTraceSlot +{ + pg_atomic_uint64 generation; /* bumped on every owner transition */ + pg_atomic_uint32 state; /* WaitEventTraceSlotState */ + uint32 pad; /* keep ring_ptr 8-aligned */ + dsa_pointer ring_ptr; /* InvalidDsaPointer when FREE; else the + * WaitEventTraceState chunk */ +} WaitEventTraceSlot; + +/* + * Control struct in fixed shared memory. trace_slots[] is indexed by + * procNumber. + * + * External cross-backend reader protocol (pg_get_wait_event_trace is the + * reference implementation): + * 1. read trace_slots[procNumber].state unlocked as a "worth visiting" + * probe; FREE -> nothing to read. + * 2. acquire lock LW_SHARED (all transitions take LW_EXCLUSIVE, so the + * slot's state/ring_ptr/ring memory are stable for the iteration). + * 3. re-check state under the lock; resolve ring_ptr via dsa_get_address; + * read write_pos. + * 4. iterate [read_start, write_pos): for each record do the per-record + * POSITION-ENCODED IDENTITY seqlock check against shared memory -- + * expected_seq = (uint32)(i*2 + 2); read seq, barrier, copy record, + * barrier, re-read seq; accept only if both equal expected_seq. This + * rejects stale previous-cycle reads (parity alone would not). + * 5. release the lock; emit the buffered records afterwards. + * 6. optional: snapshot generation before/after if releasing the lock + * between batches. + */ +typedef struct WaitEventTraceControl +{ + dsa_handle trace_dsa_handle; /* DSA_HANDLE_INVALID until first use */ + LWLock lock; /* protects DSA creation and slot transitions */ + WaitEventTraceSlot trace_slots[FLEXIBLE_ARRAY_MEMBER]; /* per procNumber */ +} WaitEventTraceControl; + +/* Trace GUC and the records-per-ring value derived from it at startup. */ +extern PGDLLIMPORT int wait_event_trace_ring_size; +extern PGDLLIMPORT uint32 WaitEventTraceRingSize; + +/* This backend's procNumber for the trace ring, or -1 if not set. */ +extern PGDLLIMPORT int my_trace_proc_number; + +/* + * Lazy DSA-based trace ring allocation -- called on first trace write and + * at backend startup when capture = trace was set via configuration. + */ +extern void wait_event_trace_attach(int procNumber); + +/* Query-attribution markers (defined in wait_event_timing.c). */ +extern void wait_event_trace_query_start(int64 query_id); +extern void wait_event_trace_query_end(int64 query_id); +extern void wait_event_trace_exec_start(int64 query_id); +extern void wait_event_trace_exec_end(int64 query_id); + #endif /* WAIT_EVENT_TIMING_H */ diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build index ee290698b3119..aba6fbdf86e53 100644 --- a/src/test/modules/test_misc/meson.build +++ b/src/test/modules/test_misc/meson.build @@ -23,6 +23,7 @@ tests += { 't/012_ddlutils.pl', 't/013_temp_obj_multisession.pl', 't/014_log_statement_max_length.pl', + 't/015_wait_event_trace_seqlock.pl', ], # The injection points are cluster-wide, so disable installcheck 'runningcheck': false, diff --git a/src/test/modules/test_misc/t/015_wait_event_trace_seqlock.pl b/src/test/modules/test_misc/t/015_wait_event_trace_seqlock.pl new file mode 100644 index 0000000000000..0f0e49970c89e --- /dev/null +++ b/src/test/modules/test_misc/t/015_wait_event_trace_seqlock.pl @@ -0,0 +1,122 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test the position-encoded identity seqlock that protects cross-backend +# reads of the wait-event trace ring (wait_event_capture = trace). +# +# The hazard: the trace writer advances write_pos and only then stamps the +# record's seq. A cross-backend reader that observes the new write_pos +# before the seq store has propagated sees, at the in-flight ring slot, the +# PREVIOUS cycle's record -- complete, with an even seq. A parity-only +# seqlock would accept it and emit a stale record attributed to the wrong +# ring index; the identity check (seq must equal the writer's completion +# value for that exact position) must reject it. +# +# That window is unobservable on TSO hardware without instrumentation, so +# the writer carries INJECTION_POINT("wait-event-trace-after-write-pos") +# between the write_pos advance and the seq stamp. This test: +# +# 1. fills and wraps a minimum-size ring (8kB = 256 records), so every +# slot holds a complete record from the previous cycle; +# 2. wedges the writer at the injection point, mid-record; +# 3. reads the ring cross-backend: the reader must return exactly +# ring_size - 1 records, skipping the in-flight slot whose stale +# prior-cycle record a parity-only check would have emitted; +# 4. releases the writer and verifies the ring reads full again. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +plan skip_all => 'Injection points not supported by this build' + unless $ENV{enable_injection_points} eq 'yes'; + +my $ring_records = 256; # 8kB ring / 32-byte records + +my $node = PostgreSQL::Test::Cluster->new('seqlock'); +$node->init; +$node->append_conf( + 'postgresql.conf', q[ +wait_event_trace_ring_size = '8kB' +]); +$node->start; + +# Skip if the server was not built with --enable-wait-event-timing. +my ($ret, $stdout, $stderr) = + $node->psql('postgres', 'SET wait_event_capture = trace;'); +if ($ret != 0) +{ + $node->stop; + plan skip_all => 'server not built with --enable-wait-event-timing'; +} + +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points;'); + +# Writer session: enable trace and wrap the ring. 400 pg_sleep calls emit +# at least 400 wait events into a 256-record ring, so every slot holds a +# complete record from the current window. +my $writer = $node->background_psql('postgres'); +$writer->query_safe('SET wait_event_capture = trace;'); +$writer->query_safe( + 'SELECT count(pg_sleep(0.001)) FROM generate_series(1, 400);'); + +my $writer_proc = $writer->query_safe( + 'SELECT procnumber FROM pg_stat_get_wait_event_timing(pg_backend_pid())' + . ' LIMIT 1;'); +chomp $writer_proc; +like($writer_proc, qr/^\d+$/, 'writer reported its procnumber'); + +# With the ring wrapped and the writer idle, a cross-backend read returns +# exactly ring_size records: every slot is complete and identity-valid. +my $count_full = $node->safe_psql('postgres', + "SELECT count(*) FROM pg_get_wait_event_trace($writer_proc);"); +is($count_full, $ring_records, 'wrapped ring reads full before the wedge'); + +# Wedge the writer mid-record: arm the injection point, then send a +# statement. The arrival of the statement completes the writer's +# ClientRead wait; its trace write advances write_pos and then blocks at +# the injection point, before stamping the record's seq. +$node->safe_psql('postgres', + "SELECT injection_points_attach('wait-event-trace-after-write-pos', 'wait');" +); +$writer->query_until( + qr/wedge_sent/, q[ +\echo wedge_sent +SELECT 1; +]); +$node->wait_for_event('client backend', 'wait-event-trace-after-write-pos'); + +# The decisive read: the in-flight slot still holds the previous cycle's +# complete record. A parity-only seqlock would emit it (ring_size rows, +# one misattributed); the identity check must skip exactly that slot. +my $count_wedged = $node->safe_psql('postgres', + "SELECT count(*) FROM pg_get_wait_event_trace($writer_proc);"); +is($count_wedged, $ring_records - 1, + 'reader skips the in-flight slot instead of emitting the stale prior-cycle record' +); + +# The read is stable and repeatable while the writer is wedged. +my $count_wedged2 = $node->safe_psql('postgres', + "SELECT count(*) FROM pg_get_wait_event_trace($writer_proc);"); +is($count_wedged2, $count_wedged, 'wedged-ring read is stable'); + +# Release the writer: detach first so the nested wakeup wait does not +# re-arm, then wake it. +$node->safe_psql('postgres', + "SELECT injection_points_detach('wait-event-trace-after-write-pos');"); +$node->safe_psql('postgres', + "SELECT injection_points_wakeup('wait-event-trace-after-write-pos');"); + +# The writer completes the wedged record (and its pending statement); the +# ring must read full again. +$writer->query_safe("SELECT 'resync';"); +my $count_after = $node->safe_psql('postgres', + "SELECT count(*) FROM pg_get_wait_event_trace($writer_proc);"); +is($count_after, $ring_records, 'ring reads full again after release'); + +$writer->quit; +$node->stop; + +done_testing(); diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out index bfb84304e39da..89b9abacff4d4 100644 --- a/src/test/regress/expected/rules.out +++ b/src/test/regress/expected/rules.out @@ -1332,6 +1332,13 @@ pg_backend_memory_contexts| SELECT name, free_chunks, used_bytes FROM pg_get_backend_memory_contexts() pg_get_backend_memory_contexts(name, ident, type, level, path, total_bytes, total_nblocks, free_bytes, free_chunks, used_bytes); +pg_backend_wait_event_trace| SELECT seq, + timestamp_ns, + wait_event_type, + wait_event, + duration_us, + query_id + FROM pg_get_backend_wait_event_trace() t(seq, timestamp_ns, wait_event_type, wait_event, duration_us, query_id); pg_config| SELECT name, setting FROM pg_config() pg_config(name, setting); diff --git a/src/test/regress/expected/wait_event_timing.out b/src/test/regress/expected/wait_event_timing.out index 8938ffe5fb063..9fc9b115e791c 100644 --- a/src/test/regress/expected/wait_event_timing.out +++ b/src/test/regress/expected/wait_event_timing.out @@ -1,9 +1,10 @@ -- -- WAIT_EVENT_TIMING -- --- Exercises the wait_event_capture = stats instrumentation: the GUC, the --- pg_stat_get_wait_event_timing() SRF, the pg_stat_wait_event_timing view, --- and the pg_wait_event_timing_histogram_buckets taxonomy view. +-- Exercises the wait_event_capture instrumentation: the GUC, the stats +-- surface (pg_stat_get_wait_event_timing(), the pg_stat_wait_event_timing +-- and histogram-buckets views, overflow counters, resets), and the trace +-- surface (the per-session ring and its readers). -- -- Two expected outputs are maintained: -- wait_event_timing.out -- --enable-wait-event-timing builds @@ -133,3 +134,42 @@ SELECT pg_stat_reset_wait_event_timing(2147483647); (1 row) RESET wait_event_capture; +-- +-- Trace level: per-session ring of individual waits + query markers. +-- (In a stub build SET trace errors and the trace readers stay empty; +-- that is the documented difference between the two expected files.) +-- +SET wait_event_capture = trace; +SELECT pg_sleep(0.1); + pg_sleep +---------- + +(1 row) + +-- PgSleep is recorded in this backend's ring with a positive duration. +SELECT count(*) >= 1 AS pgsleep_in_ring, + coalesce(bool_and(duration_us > 0), false) AS durations_positive +FROM pg_get_backend_wait_event_trace() +WHERE wait_event = 'PgSleep'; + pgsleep_in_ring | durations_positive +-----------------+-------------------- + t | t +(1 row) + +-- The same records are visible through the view. +SELECT count(*) >= 1 AS view_has_pgsleep +FROM pg_backend_wait_event_trace +WHERE wait_event = 'PgSleep'; + view_has_pgsleep +------------------ + t +(1 row) + +-- Clearing orphaned rings is a no-op here (no orphans) but must succeed. +SELECT pg_stat_clear_orphaned_wait_event_rings() >= 0 AS clear_orphans_ok; + clear_orphans_ok +------------------ + t +(1 row) + +RESET wait_event_capture; diff --git a/src/test/regress/expected/wait_event_timing_1.out b/src/test/regress/expected/wait_event_timing_1.out index 3aad898d9bf0f..f8b3beec02f62 100644 --- a/src/test/regress/expected/wait_event_timing_1.out +++ b/src/test/regress/expected/wait_event_timing_1.out @@ -1,9 +1,10 @@ -- -- WAIT_EVENT_TIMING -- --- Exercises the wait_event_capture = stats instrumentation: the GUC, the --- pg_stat_get_wait_event_timing() SRF, the pg_stat_wait_event_timing view, --- and the pg_wait_event_timing_histogram_buckets taxonomy view. +-- Exercises the wait_event_capture instrumentation: the GUC, the stats +-- surface (pg_stat_get_wait_event_timing(), the pg_stat_wait_event_timing +-- and histogram-buckets views, overflow counters, resets), and the trace +-- surface (the per-session ring and its readers). -- -- Two expected outputs are maintained: -- wait_event_timing.out -- --enable-wait-event-timing builds @@ -123,3 +124,45 @@ SELECT pg_stat_reset_wait_event_timing(2147483647); ERROR: wait event capture is not supported by this build HINT: Compile PostgreSQL with --enable-wait-event-timing. RESET wait_event_capture; +-- +-- Trace level: per-session ring of individual waits + query markers. +-- (In a stub build SET trace errors and the trace readers stay empty; +-- that is the documented difference between the two expected files.) +-- +SET wait_event_capture = trace; +ERROR: invalid value for parameter "wait_event_capture": "trace" +DETAIL: This build does not support wait event capture. +HINT: Compile PostgreSQL with --enable-wait-event-timing. +SELECT pg_sleep(0.1); + pg_sleep +---------- + +(1 row) + +-- PgSleep is recorded in this backend's ring with a positive duration. +SELECT count(*) >= 1 AS pgsleep_in_ring, + coalesce(bool_and(duration_us > 0), false) AS durations_positive +FROM pg_get_backend_wait_event_trace() +WHERE wait_event = 'PgSleep'; + pgsleep_in_ring | durations_positive +-----------------+-------------------- + f | f +(1 row) + +-- The same records are visible through the view. +SELECT count(*) >= 1 AS view_has_pgsleep +FROM pg_backend_wait_event_trace +WHERE wait_event = 'PgSleep'; + view_has_pgsleep +------------------ + f +(1 row) + +-- Clearing orphaned rings is a no-op here (no orphans) but must succeed. +SELECT pg_stat_clear_orphaned_wait_event_rings() >= 0 AS clear_orphans_ok; + clear_orphans_ok +------------------ + t +(1 row) + +RESET wait_event_capture; diff --git a/src/test/regress/sql/wait_event_timing.sql b/src/test/regress/sql/wait_event_timing.sql index a30fcd155fd13..49206afef66b0 100644 --- a/src/test/regress/sql/wait_event_timing.sql +++ b/src/test/regress/sql/wait_event_timing.sql @@ -1,9 +1,10 @@ -- -- WAIT_EVENT_TIMING -- --- Exercises the wait_event_capture = stats instrumentation: the GUC, the --- pg_stat_get_wait_event_timing() SRF, the pg_stat_wait_event_timing view, --- and the pg_wait_event_timing_histogram_buckets taxonomy view. +-- Exercises the wait_event_capture instrumentation: the GUC, the stats +-- surface (pg_stat_get_wait_event_timing(), the pg_stat_wait_event_timing +-- and histogram-buckets views, overflow counters, resets), and the trace +-- surface (the per-session ring and its readers). -- -- Two expected outputs are maintained: -- wait_event_timing.out -- --enable-wait-event-timing builds @@ -77,3 +78,27 @@ SELECT pg_stat_reset_wait_event_timing(); SELECT pg_stat_reset_wait_event_timing(2147483647); RESET wait_event_capture; + +-- +-- Trace level: per-session ring of individual waits + query markers. +-- (In a stub build SET trace errors and the trace readers stay empty; +-- that is the documented difference between the two expected files.) +-- +SET wait_event_capture = trace; +SELECT pg_sleep(0.1); + +-- PgSleep is recorded in this backend's ring with a positive duration. +SELECT count(*) >= 1 AS pgsleep_in_ring, + coalesce(bool_and(duration_us > 0), false) AS durations_positive +FROM pg_get_backend_wait_event_trace() +WHERE wait_event = 'PgSleep'; + +-- The same records are visible through the view. +SELECT count(*) >= 1 AS view_has_pgsleep +FROM pg_backend_wait_event_trace +WHERE wait_event = 'PgSleep'; + +-- Clearing orphaned rings is a no-op here (no orphans) but must succeed. +SELECT pg_stat_clear_orphaned_wait_event_rings() >= 0 AS clear_orphans_ok; + +RESET wait_event_capture; diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 2d9d751ef7627..9e54cecb761f4 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -3433,6 +3433,11 @@ WaitEventTimeout WaitEventTimingControl WaitEventTimingEntry WaitEventTimingState +WaitEventTraceControl +WaitEventTraceRecord +WaitEventTraceSlot +WaitEventTraceSlotState +WaitEventTraceState WaitLSNProcInfo WaitLSNResult WaitLSNState @@ -3463,6 +3468,7 @@ WalUsage WalWriteMethod WalWriteMethodOps Walfile +WetValidRecord WindowAgg WindowAggPath WindowAggState From ff9a94bab9315edb36ea05af95688d19c4826173 Mon Sep 17 00:00:00 2001 From: Dmitry Fomin Date: Thu, 11 Jun 2026 15:11:09 +0000 Subject: [PATCH 43/43] wait_event_timing: add query-attribution markers to the trace ring At the trace level, interleave query-boundary markers with the wait events in the per-session ring so a reader can tell which query each wait belongs to. Two marker families are emitted: - ExecStart/ExecEnd bracket every executor run (ExecutorStart/ExecutorEnd), the primary attribution signal -- every executable statement, including those in parallel workers and pipelined extended-protocol messages, is bracketed; - QueryStart/QueryEnd fire at top-level query_id transitions (pgstat_report_query_id) and at the transition to idle (send_ready_for_query), providing the inter-statement boundaries the executor markers cannot -- e.g. the ClientRead wait between statements. In the pipelined extended protocol a Parse/Bind/Execute can arrive while the previous query's id is still set and the session is still RUNNING (no Sync->idle in between), so the prior id is flushed with force=true at those message boundaries to fire its QUERY_END before the new query starts. That flush is gated on wait_event_capture = trace, so when trace is off it is a no-op and pg_stat_activity.query_id behaves exactly as before. Marker emission requires a non-zero query_id (compute_query_id) and track_activities; a WARNING is logged when trace is enabled without them. The markers themselves are no-ops unless capture is at trace, so the only cost when the feature is off is the inline gate already present. --- doc/src/sgml/config.sgml | 9 +++- doc/src/sgml/monitoring.sgml | 54 ++++++++++++++++--- src/backend/executor/execMain.c | 5 ++ src/backend/tcop/postgres.c | 39 ++++++++++++++ src/backend/utils/activity/backend_status.c | 13 +++++ .../utils/activity/wait_event_timing.c | 18 +++++++ .../regress/expected/wait_event_timing.out | 15 +++++- .../regress/expected/wait_event_timing_1.out | 15 +++++- src/test/regress/sql/wait_event_timing.sql | 11 +++- 9 files changed, 166 insertions(+), 13 deletions(-) diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index a7f4c906522c0..8f07263482436 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -9183,13 +9183,18 @@ COPY postgres_log FROM '/full/path/to/logfile.csv' WITH csv; At trace, the server additionally records every - individual wait event into a per-session ring buffer exposed via the + individual wait event, together with query-attribution markers, + into a per-session ring buffer exposed via the pg_backend_wait_event_trace view. The ring is allocated lazily from dynamic shared memory on first use (default 4 MB per backend; see ), so only - sessions that enable trace pay the per-ring memory cost. + sessions that enable trace pay the per-ring memory cost. Query + attribution — matching each wait to the query that incurred + it — requires both and + ; a warning is logged if + either is missing when trace is enabled. Only superusers and users with the appropriate SET diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml index a8bfdc232ba90..7fb19326def15 100644 --- a/doc/src/sgml/monitoring.sgml +++ b/doc/src/sgml/monitoring.sgml @@ -4408,8 +4408,14 @@ ORDER BY b.bucket_idx; The pg_backend_wait_event_trace view shows individual wait event records from the current backend's - trace ring buffer. Each record captures a single wait event - (with timestamp and duration). + trace ring buffer. Each record captures either a single wait event + (with timestamp and duration) or a query-attribution marker. Two + marker families exist: ExecStart/ExecEnd + bracket every executor invocation, and + QueryStart/QueryEnd mark + top-level query-id transitions and the transition to idle. See + for the gating rules of + each marker family. Requires to be set to trace. The ring buffer is sized by (default 4 MB = @@ -4496,8 +4502,16 @@ ORDER BY b.bucket_idx; The ring buffer is designed as a lock-free transport mechanism for external consumption. At high wait event rates (e.g., 220K events/sec), - the ring wraps in roughly 0.5–1 seconds. Consumers should poll - the ring buffer before it wraps. + the ring wraps in roughly 0.5–1 seconds. External consumers + (background workers, extensions) can attribute events to queries by + scanning for ExecStart markers (or, when the + executor markers are unavailable, QueryStart); if + both have been overwritten, events before the next visible marker are + unattributed. Consumers should poll the ring buffer before it wraps + and can use the query_id column of + + pg_stat_activity as a fallback for the + current query context. @@ -4517,6 +4531,28 @@ ORDER BY b.bucket_idx; alone. + + QueryStart/QueryEnd markers are + emitted as matched pairs around each protocol phase that touches a + query_id. In simple protocol that is one + pair per statement. In extended protocol there is one pair around + each of Parse, Bind, and + Execute for the same + query_id — so a single parameterized + statement produces three nested pairs, plus the surrounding + ExecStart/ExecEnd pair from the + executor. This per-phase pairing lets consumers measure how much + time a query spent in each protocol phase (parse vs. bind vs. + execute) by computing the duration between each pair, and lets a + total-time-per-query rollup be expressed as the sum of pair + durations rather than a single subtraction. Consumers that just want + "how long did this query take in the executor" should use the + ExecStart/ExecEnd pair, which + brackets each executor invocation regardless of protocol. (Statements + that invoke the executor recursively — for example via + PL/pgSQL or other server-side code — + produce one pair per invocation.) + <structname>pg_backend_wait_event_trace</structname> View @@ -4556,7 +4592,7 @@ ORDER BY b.bucket_idx; wait_event_typetext - Wait event type + Wait event type, or Query for query markers @@ -4565,7 +4601,9 @@ ORDER BY b.bucket_idx; wait_eventtext - Wait event name + Wait event name, or one of ExecStart, + ExecEnd, QueryStart, + QueryEnd for query-attribution markers. @@ -4574,7 +4612,7 @@ ORDER BY b.bucket_idx; duration_usdouble precision - Wait duration in microseconds + Wait duration in microseconds (0 for query markers) @@ -4583,7 +4621,7 @@ ORDER BY b.bucket_idx; query_idbigint - Reserved; always 0 for wait event records + Query identifier for query markers (0 for wait events) diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c index fde502efd3869..6aa1b9eefad02 100644 --- a/src/backend/executor/execMain.c +++ b/src/backend/executor/execMain.c @@ -64,6 +64,7 @@ #include "utils/partcache.h" #include "utils/rls.h" #include "utils/snapmgr.h" +#include "utils/wait_event_timing.h" /* Hooks for plugins to get control in ExecutorStart/Run/Finish/End */ @@ -133,6 +134,8 @@ ExecutorStart(QueryDesc *queryDesc, int eflags) */ pgstat_report_query_id(queryDesc->plannedstmt->queryId, false); + wait_event_trace_exec_start(queryDesc->plannedstmt->queryId); + if (ExecutorStart_hook) (*ExecutorStart_hook) (queryDesc, eflags); else @@ -476,6 +479,8 @@ standard_ExecutorFinish(QueryDesc *queryDesc) void ExecutorEnd(QueryDesc *queryDesc) { + wait_event_trace_exec_end(queryDesc->plannedstmt->queryId); + if (ExecutorEnd_hook) (*ExecutorEnd_hook) (queryDesc); else diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c index b6bdfe213feec..cadc6630070ff 100644 --- a/src/backend/tcop/postgres.c +++ b/src/backend/tcop/postgres.c @@ -86,6 +86,7 @@ #include "utils/timeout.h" #include "utils/timestamp.h" #include "utils/varlena.h" +#include "utils/wait_event_timing.h" /* ---------------- * global variables @@ -1438,6 +1439,17 @@ exec_parse_message(const char *query_string, /* string to execute */ */ debug_query_string = query_string; + /* + * In pipelined extended protocol, a Parse can arrive while the previous + * query's st_query_id is still set and st_state is still RUNNING (no + * Sync->idle between queries). Flush the prior id with force=true so the + * QUERY_END marker fires before pgstat_report_activity below zeros + * st_query_id. Skip when st_state != RUNNING: coming from idle means + * send_ready_for_query already emitted the QUERY_END. + */ + if (wait_event_capture == WAIT_EVENT_CAPTURE_TRACE && + MyBEEntry != NULL && MyBEEntry->st_state == STATE_RUNNING) + pgstat_report_query_id(0, true); pgstat_report_activity(STATE_RUNNING, query_string); set_ps_display("PARSE"); @@ -1714,6 +1726,14 @@ exec_bind_message(StringInfo input_message) */ debug_query_string = psrc->query_string; + /* + * See exec_parse_message: flush the prior query_id's QUERY_END before + * pgstat_report_activity zeros it; the state gate avoids a duplicate + * QUERY_END right after a Sync->idle transition. + */ + if (wait_event_capture == WAIT_EVENT_CAPTURE_TRACE && + MyBEEntry != NULL && MyBEEntry->st_state == STATE_RUNNING) + pgstat_report_query_id(0, true); pgstat_report_activity(STATE_RUNNING, psrc->query_string); foreach(lc, psrc->query_list) @@ -2212,6 +2232,14 @@ exec_execute_message(const char *portal_name, long max_rows) */ debug_query_string = sourceText; + /* + * See exec_parse_message: flush the prior query_id's QUERY_END before + * pgstat_report_activity zeros it; the state gate avoids a duplicate + * QUERY_END right after a Sync->idle transition. + */ + if (wait_event_capture == WAIT_EVENT_CAPTURE_TRACE && + MyBEEntry != NULL && MyBEEntry->st_state == STATE_RUNNING) + pgstat_report_query_id(0, true); pgstat_report_activity(STATE_RUNNING, sourceText); foreach(lc, portal->stmts) @@ -4744,6 +4772,17 @@ PostgresMain(const char *dbname, const char *username) */ if (send_ready_for_query) { + /* + * Emit QUERY_END before going idle so idle waits (ClientRead + * etc.) are not attributed to the finished query. + */ + { + volatile PgBackendStatus *beentry = MyBEEntry; + + if (beentry != NULL && beentry->st_query_id != 0) + wait_event_trace_query_end(beentry->st_query_id); + } + if (IsAbortedTransactionBlockState()) { set_ps_display("idle in transaction (aborted)"); diff --git a/src/backend/utils/activity/backend_status.c b/src/backend/utils/activity/backend_status.c index d685fc5cd87c0..19a6dd48aef9e 100644 --- a/src/backend/utils/activity/backend_status.c +++ b/src/backend/utils/activity/backend_status.c @@ -24,6 +24,7 @@ #include "utils/ascii.h" #include "utils/guc.h" /* for application_name */ #include "utils/memutils.h" +#include "utils/wait_event_timing.h" /* ---------- @@ -670,6 +671,18 @@ pgstat_report_query_id(int64 query_id, bool force) if (beentry->st_query_id != INT64CONST(0) && !force) return; + /* + * Emit trace markers for query-to-query transitions. QUERY_END fires + * here when st_query_id transitions from one non-zero value to another + * (multi-statement simple protocol, pipelined extended protocol). The + * last-query-to-idle QUERY_END is emitted in PostgresMain() at + * send_ready_for_query. + */ + if (beentry->st_query_id != 0 && beentry->st_query_id != query_id) + wait_event_trace_query_end(beentry->st_query_id); + if (query_id != 0 && query_id != beentry->st_query_id) + wait_event_trace_query_start(query_id); + /* * Update my status entry, following the protocol of bumping * st_changecount before and after. We use a volatile pointer here to diff --git a/src/backend/utils/activity/wait_event_timing.c b/src/backend/utils/activity/wait_event_timing.c index 5346d4f8cf99c..c1b7e5298b0fc 100644 --- a/src/backend/utils/activity/wait_event_timing.c +++ b/src/backend/utils/activity/wait_event_timing.c @@ -244,6 +244,7 @@ wait_event_trace_exec_end(int64 query_id) #include "catalog/pg_type_d.h" #include "funcapi.h" #include "miscadmin.h" +#include "nodes/queryjumble.h" #include "port/pg_bitutils.h" #include "storage/ipc.h" #include "storage/latch.h" @@ -1025,6 +1026,23 @@ assign_wait_event_capture(int newval, void *extra) */ if (newval != WAIT_EVENT_CAPTURE_TRACE && my_wait_event_trace != NULL) wait_event_trace_release_slot(my_trace_proc_number); + + /* + * Trace-level query attribution needs a non-zero query_id (from + * compute_query_id) and the activity reporting that drives the markers + * (track_activities). Warn -- but never error -- if either is missing; + * assign hooks must not ereport(ERROR). Trace still records wait events; + * only the query markers are affected. + */ + if (newval == WAIT_EVENT_CAPTURE_TRACE && !pgstat_track_activities) + ereport(WARNING, + (errmsg("query attribution at \"wait_event_capture\" = \"trace\" requires \"track_activities\" to be enabled"))); + + if (newval == WAIT_EVENT_CAPTURE_TRACE && + compute_query_id == COMPUTE_QUERY_ID_OFF) + ereport(WARNING, + (errmsg("query attribution at \"wait_event_capture\" = \"trace\" requires \"compute_query_id\" to be enabled"), + errhint("Set \"compute_query_id\" to \"on\" or \"auto\", or load a module that enables it."))); } /* ================= Trace-level ring machinery ================= */ diff --git a/src/test/regress/expected/wait_event_timing.out b/src/test/regress/expected/wait_event_timing.out index 9fc9b115e791c..8e94a4ce6c5de 100644 --- a/src/test/regress/expected/wait_event_timing.out +++ b/src/test/regress/expected/wait_event_timing.out @@ -4,7 +4,7 @@ -- Exercises the wait_event_capture instrumentation: the GUC, the stats -- surface (pg_stat_get_wait_event_timing(), the pg_stat_wait_event_timing -- and histogram-buckets views, overflow counters, resets), and the trace --- surface (the per-session ring and its readers). +-- surface (the per-session ring, its readers, and the query markers). -- -- Two expected outputs are maintained: -- wait_event_timing.out -- --enable-wait-event-timing builds @@ -139,6 +139,7 @@ RESET wait_event_capture; -- (In a stub build SET trace errors and the trace readers stay empty; -- that is the documented difference between the two expected files.) -- +SET compute_query_id = on; SET wait_event_capture = trace; SELECT pg_sleep(0.1); pg_sleep @@ -165,6 +166,17 @@ WHERE wait_event = 'PgSleep'; t (1 row) +-- Executor markers bracket the work and carry a non-zero query_id. +SELECT count(*) >= 1 AS has_exec_markers, + coalesce(bool_and(query_id <> 0), true) AS markers_have_query_id +FROM pg_get_backend_wait_event_trace() +WHERE wait_event_type = 'Query' + AND wait_event IN ('ExecStart', 'ExecEnd'); + has_exec_markers | markers_have_query_id +------------------+----------------------- + t | t +(1 row) + -- Clearing orphaned rings is a no-op here (no orphans) but must succeed. SELECT pg_stat_clear_orphaned_wait_event_rings() >= 0 AS clear_orphans_ok; clear_orphans_ok @@ -172,4 +184,5 @@ SELECT pg_stat_clear_orphaned_wait_event_rings() >= 0 AS clear_orphans_ok; t (1 row) +RESET compute_query_id; RESET wait_event_capture; diff --git a/src/test/regress/expected/wait_event_timing_1.out b/src/test/regress/expected/wait_event_timing_1.out index f8b3beec02f62..31ccd005ff7b6 100644 --- a/src/test/regress/expected/wait_event_timing_1.out +++ b/src/test/regress/expected/wait_event_timing_1.out @@ -4,7 +4,7 @@ -- Exercises the wait_event_capture instrumentation: the GUC, the stats -- surface (pg_stat_get_wait_event_timing(), the pg_stat_wait_event_timing -- and histogram-buckets views, overflow counters, resets), and the trace --- surface (the per-session ring and its readers). +-- surface (the per-session ring, its readers, and the query markers). -- -- Two expected outputs are maintained: -- wait_event_timing.out -- --enable-wait-event-timing builds @@ -129,6 +129,7 @@ RESET wait_event_capture; -- (In a stub build SET trace errors and the trace readers stay empty; -- that is the documented difference between the two expected files.) -- +SET compute_query_id = on; SET wait_event_capture = trace; ERROR: invalid value for parameter "wait_event_capture": "trace" DETAIL: This build does not support wait event capture. @@ -158,6 +159,17 @@ WHERE wait_event = 'PgSleep'; f (1 row) +-- Executor markers bracket the work and carry a non-zero query_id. +SELECT count(*) >= 1 AS has_exec_markers, + coalesce(bool_and(query_id <> 0), true) AS markers_have_query_id +FROM pg_get_backend_wait_event_trace() +WHERE wait_event_type = 'Query' + AND wait_event IN ('ExecStart', 'ExecEnd'); + has_exec_markers | markers_have_query_id +------------------+----------------------- + f | t +(1 row) + -- Clearing orphaned rings is a no-op here (no orphans) but must succeed. SELECT pg_stat_clear_orphaned_wait_event_rings() >= 0 AS clear_orphans_ok; clear_orphans_ok @@ -165,4 +177,5 @@ SELECT pg_stat_clear_orphaned_wait_event_rings() >= 0 AS clear_orphans_ok; t (1 row) +RESET compute_query_id; RESET wait_event_capture; diff --git a/src/test/regress/sql/wait_event_timing.sql b/src/test/regress/sql/wait_event_timing.sql index 49206afef66b0..259ffb5f2d920 100644 --- a/src/test/regress/sql/wait_event_timing.sql +++ b/src/test/regress/sql/wait_event_timing.sql @@ -4,7 +4,7 @@ -- Exercises the wait_event_capture instrumentation: the GUC, the stats -- surface (pg_stat_get_wait_event_timing(), the pg_stat_wait_event_timing -- and histogram-buckets views, overflow counters, resets), and the trace --- surface (the per-session ring and its readers). +-- surface (the per-session ring, its readers, and the query markers). -- -- Two expected outputs are maintained: -- wait_event_timing.out -- --enable-wait-event-timing builds @@ -84,6 +84,7 @@ RESET wait_event_capture; -- (In a stub build SET trace errors and the trace readers stay empty; -- that is the documented difference between the two expected files.) -- +SET compute_query_id = on; SET wait_event_capture = trace; SELECT pg_sleep(0.1); @@ -98,7 +99,15 @@ SELECT count(*) >= 1 AS view_has_pgsleep FROM pg_backend_wait_event_trace WHERE wait_event = 'PgSleep'; +-- Executor markers bracket the work and carry a non-zero query_id. +SELECT count(*) >= 1 AS has_exec_markers, + coalesce(bool_and(query_id <> 0), true) AS markers_have_query_id +FROM pg_get_backend_wait_event_trace() +WHERE wait_event_type = 'Query' + AND wait_event IN ('ExecStart', 'ExecEnd'); + -- Clearing orphaned rings is a no-op here (no orphans) but must succeed. SELECT pg_stat_clear_orphaned_wait_event_rings() >= 0 AS clear_orphans_ok; +RESET compute_query_id; RESET wait_event_capture;