v1-0001-pgstat-Introduce-pg_stat_report_anytime-for-mid-t.patch
application/octet-stream
Filename: v1-0001-pgstat-Introduce-pg_stat_report_anytime-for-mid-t.patch
Type: application/octet-stream
Part: 0
From de04e151abff07dab378d5a3d006e76ea56359f6 Mon Sep 17 00:00:00 2001
From: Sami Imseih <samimseih@gmail.com>
Date: Sun, 10 May 2026 07:06:04 -0500
Subject: [PATCH v1] pgstat: Introduce pg_stat_report_anytime() for
mid-transaction stats flush
Add an API to flush pending stats that are safe to report inside a
transaction without waiting for transaction end. Relation write
counters (tuples inserted, updated, deleted) for tables modified in
the current transaction are excluded, since their final values depend
on commit/abort outcome.
The SQL function pg_stat_report_anytime(pid) flushes the target
backend's pending stats: if the PID matches the caller's own backend
it flushes immediately, otherwise it signals the target to flush at
its next CHECK_FOR_INTERRUPTS (for regular backends) or main-loop
iteration (for auxiliary processes). The C function
pgstat_report_anytime_stat() flushes pending stats in the calling
backend only.
---
doc/src/sgml/monitoring.sgml | 26 ++++
src/backend/postmaster/autovacuum.c | 3 +
src/backend/postmaster/checkpointer.c | 3 +
src/backend/postmaster/interrupt.c | 4 +
src/backend/postmaster/pgarch.c | 3 +
src/backend/postmaster/startup.c | 4 +
src/backend/postmaster/walsummarizer.c | 3 +
src/backend/storage/ipc/procsignal.c | 3 +
src/backend/tcop/postgres.c | 3 +
src/backend/utils/activity/pgstat.c | 61 +++++++++-
src/backend/utils/activity/pgstat_relation.c | 97 +++++++++------
src/backend/utils/adt/pgstatfuncs.c | 40 ++++++
src/backend/utils/init/globals.c | 1 +
src/include/catalog/pg_proc.dat | 6 +
src/include/miscadmin.h | 1 +
src/include/pgstat.h | 3 +
src/include/storage/procsignal.h | 2 +
src/test/regress/expected/stats.out | 122 +++++++++++++++++++
src/test/regress/sql/stats.sql | 81 ++++++++++++
19 files changed, 431 insertions(+), 35 deletions(-)
diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml
index 08d5b824552..bb6c928e3e7 100644
--- a/doc/src/sgml/monitoring.sgml
+++ b/doc/src/sgml/monitoring.sgml
@@ -5607,6 +5607,32 @@ description | Waiting for a newly initialized WAL file to reach durable storage
</para></entry>
</row>
+ <row>
+ <entry role="func_table_entry"><para role="func_signature">
+ <indexterm>
+ <primary>pg_stat_report_anytime</primary>
+ </indexterm>
+ <function>pg_stat_report_anytime</function> ( <type>integer</type> )
+ <returnvalue>boolean</returnvalue>
+ </para>
+ <para>
+ Force flush of pending statistics to shared memory for the backend
+ with the specified process ID. Unlike normal statistics reporting,
+ this can be called from within a transaction. For relations modified
+ by <command>INSERT</command>, <command>UPDATE</command>, or
+ <command>DELETE</command> in the current transaction, only read
+ counters (scans, tuples fetched, blocks hit) are flushed
+ immediately; write counters (tuples inserted, updated, deleted)
+ are deferred until the transaction ends.
+ Returns <literal>true</literal> if the flush was successfully
+ triggered, <literal>false</literal> otherwise.
+ </para>
+ <para>
+ This function is restricted to superusers by default, but other users
+ can be granted EXECUTE to run the function.
+ </para></entry>
+ </row>
+
<row>
<entry role="func_table_entry"><para role="func_signature">
<indexterm>
diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c
index a5a8db2ff88..c5fddf75dab 100644
--- a/src/backend/postmaster/autovacuum.c
+++ b/src/backend/postmaster/autovacuum.c
@@ -825,6 +825,9 @@ ProcessAutoVacLauncherInterrupts(void)
if (LogMemoryContextPending)
ProcessLogMemoryContextInterrupt();
+ if (ReportAnytimeStatsPending)
+ ProcessReportAnytimeStatsInterrupt();
+
/* Process sinval catchup interrupts that happened while sleeping */
ProcessCatchupInterrupt();
}
diff --git a/src/backend/postmaster/checkpointer.c b/src/backend/postmaster/checkpointer.c
index 087120db090..874cceb3970 100644
--- a/src/backend/postmaster/checkpointer.c
+++ b/src/backend/postmaster/checkpointer.c
@@ -694,6 +694,9 @@ ProcessCheckpointerInterrupts(void)
/* Perform logging of memory contexts of this process */
if (LogMemoryContextPending)
ProcessLogMemoryContextInterrupt();
+
+ if (ReportAnytimeStatsPending)
+ ProcessReportAnytimeStatsInterrupt();
}
/*
diff --git a/src/backend/postmaster/interrupt.c b/src/backend/postmaster/interrupt.c
index a2c0ff012c5..4e09e93f8da 100644
--- a/src/backend/postmaster/interrupt.c
+++ b/src/backend/postmaster/interrupt.c
@@ -17,6 +17,7 @@
#include <unistd.h>
#include "miscadmin.h"
+#include "pgstat.h"
#include "postmaster/interrupt.h"
#include "storage/ipc.h"
#include "storage/latch.h"
@@ -48,6 +49,9 @@ ProcessMainLoopInterrupts(void)
/* Perform logging of memory contexts of this process */
if (LogMemoryContextPending)
ProcessLogMemoryContextInterrupt();
+
+ if (ReportAnytimeStatsPending)
+ ProcessReportAnytimeStatsInterrupt();
}
/*
diff --git a/src/backend/postmaster/pgarch.c b/src/backend/postmaster/pgarch.c
index 0f207ac0356..d83a5fda862 100644
--- a/src/backend/postmaster/pgarch.c
+++ b/src/backend/postmaster/pgarch.c
@@ -870,6 +870,9 @@ ProcessPgArchInterrupts(void)
if (LogMemoryContextPending)
ProcessLogMemoryContextInterrupt();
+ if (ReportAnytimeStatsPending)
+ ProcessReportAnytimeStatsInterrupt();
+
if (ConfigReloadPending)
{
char *archiveLib = pstrdup(XLogArchiveLibrary);
diff --git a/src/backend/postmaster/startup.c b/src/backend/postmaster/startup.c
index b46bac681fe..4a5534a8f9b 100644
--- a/src/backend/postmaster/startup.c
+++ b/src/backend/postmaster/startup.c
@@ -24,6 +24,7 @@
#include "access/xlogutils.h"
#include "libpq/pqsignal.h"
#include "miscadmin.h"
+#include "pgstat.h"
#include "postmaster/auxprocess.h"
#include "postmaster/startup.h"
#include "storage/ipc.h"
@@ -192,6 +193,9 @@ ProcessStartupProcInterrupts(void)
/* Perform logging of memory contexts of this process */
if (LogMemoryContextPending)
ProcessLogMemoryContextInterrupt();
+
+ if (ReportAnytimeStatsPending)
+ ProcessReportAnytimeStatsInterrupt();
}
diff --git a/src/backend/postmaster/walsummarizer.c b/src/backend/postmaster/walsummarizer.c
index 4f12eaf2c85..b1239cbb07f 100644
--- a/src/backend/postmaster/walsummarizer.c
+++ b/src/backend/postmaster/walsummarizer.c
@@ -876,6 +876,9 @@ ProcessWalSummarizerInterrupts(void)
/* Perform logging of memory contexts of this process */
if (LogMemoryContextPending)
ProcessLogMemoryContextInterrupt();
+
+ if (ReportAnytimeStatsPending)
+ ProcessReportAnytimeStatsInterrupt();
}
/*
diff --git a/src/backend/storage/ipc/procsignal.c b/src/backend/storage/ipc/procsignal.c
index 264e4c22ca6..40023ac9888 100644
--- a/src/backend/storage/ipc/procsignal.c
+++ b/src/backend/storage/ipc/procsignal.c
@@ -711,6 +711,9 @@ procsignal_sigusr1_handler(SIGNAL_ARGS)
if (CheckProcSignal(PROCSIG_REPACK_MESSAGE))
HandleRepackMessageInterrupt();
+ if (CheckProcSignal(PROCSIG_REPORT_ANYTIME_STATS))
+ HandleReportAnytimeStatsInterrupt();
+
if (CheckProcSignal(PROCSIG_SLOTSYNC_MESSAGE))
HandleSlotSyncMessageInterrupt();
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index dbef734a93f..dbca372a3f1 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -3609,6 +3609,9 @@ ProcessInterrupts(void)
if (LogMemoryContextPending)
ProcessLogMemoryContextInterrupt();
+ if (ReportAnytimeStatsPending)
+ ProcessReportAnytimeStatsInterrupt();
+
if (ParallelApplyMessagePending)
ProcessParallelApplyMessages();
diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index b67da88c7dc..9b5d9bf09cb 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -106,6 +106,7 @@
#include "access/xact.h"
#include "lib/dshash.h"
+#include "miscadmin.h"
#include "pgstat.h"
#include "storage/fd.h"
#include "storage/ipc.h"
@@ -845,6 +846,57 @@ pgstat_force_next_flush(void)
pgStatForceNextFlush = true;
}
+/*
+ * Immediately flush all pending statistics entries to shared memory.
+ *
+ * Unlike pgstat_report_stat(), this can be called anytime, including
+ * within a transaction.
+ */
+void
+pgstat_report_anytime_stat(void)
+{
+ pgstat_flush_pending_entries(false);
+
+ for (PgStat_Kind kind = PGSTAT_KIND_MIN; kind <= PGSTAT_KIND_MAX; kind++)
+ {
+ const PgStat_KindInfo *kind_info = pgstat_get_kind_info(kind);
+
+ if (!kind_info || !kind_info->flush_static_cb)
+ continue;
+
+ kind_info->flush_static_cb(false);
+ }
+}
+
+/*
+ * HandleReportAnytimeStatsInterrupt
+ * Handle receipt of an interrupt requesting an anytime stats report.
+ *
+ * All the actual work is deferred to ProcessReportAnytimeStatsInterrupt(),
+ * because we cannot safely acquire locks inside the signal handler.
+ */
+void
+HandleReportAnytimeStatsInterrupt(void)
+{
+ InterruptPending = true;
+ ReportAnytimeStatsPending = true;
+ /* latch will be set by procsignal_sigusr1_handler */
+}
+
+/*
+ * ProcessReportAnytimeStatsInterrupt
+ * Report all pending statistics to shared memory.
+ *
+ * Called from ProcessInterrupts() when ReportAnytimeStatsPending is set.
+ */
+void
+ProcessReportAnytimeStatsInterrupt(void)
+{
+ ReportAnytimeStatsPending = false;
+
+ pgstat_report_anytime_stat();
+}
+
/*
* Only for use by pgstat_reset_counters()
*/
@@ -1414,7 +1466,14 @@ pgstat_flush_pending_entries(bool nowait)
/* flush the stats, if possible */
did_flush = kind_info->flush_pending_cb(entry_ref, nowait);
- Assert(did_flush || nowait);
+ /*
+ * When nowait is false we block for the lock, so the only reason a
+ * flush_pending_cb can legitimately return false is that the entry
+ * has active transaction state that must not be freed yet (e.g.
+ * relation stats with trans != NULL). That situation only arises
+ * mid-transaction, hence the IsTransactionOrTransactionBlock() check.
+ */
+ Assert(did_flush || nowait || IsTransactionOrTransactionBlock());
/* determine next entry, before deleting the pending entry */
if (dlist_has_next(&pgStatPending, cur))
diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c
index b2ca28f83ba..848687a9f7e 100644
--- a/src/backend/utils/activity/pgstat_relation.c
+++ b/src/backend/utils/activity/pgstat_relation.c
@@ -828,64 +828,76 @@ pgstat_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
/*
* Ignore entries that didn't accumulate any actual counts, such as
- * indexes that were opened by the planner but not used.
+ * indexes that were opened by the planner but not used. The entry cannot
+ * be freed if there is active transaction state, since
+ * AtEOXact_PgStat_Relations will still merge counters into it.
*/
if (pg_memory_is_all_zeros(&lstats->counts,
sizeof(struct PgStat_TableCounts)))
- return true;
+ return (lstats->trans == NULL);
if (!pgstat_lock_entry(entry_ref, nowait))
return false;
- /* add the values to the shared entry. */
+ /* Update counters that are always safe to flush. */
tabentry = &shtabstats->stats;
tabentry->numscans += lstats->counts.numscans;
if (lstats->counts.numscans)
{
- TimestampTz t = GetCurrentTransactionStopTimestamp();
+ TimestampTz t = IsTransactionOrTransactionBlock() ?
+ GetCurrentStatementStartTimestamp() :
+ GetCurrentTransactionStopTimestamp();
if (t > tabentry->lastscan)
tabentry->lastscan = t;
}
tabentry->tuples_returned += lstats->counts.tuples_returned;
tabentry->tuples_fetched += lstats->counts.tuples_fetched;
- tabentry->tuples_inserted += lstats->counts.tuples_inserted;
- tabentry->tuples_updated += lstats->counts.tuples_updated;
- tabentry->tuples_deleted += lstats->counts.tuples_deleted;
tabentry->tuples_hot_updated += lstats->counts.tuples_hot_updated;
tabentry->tuples_newpage_updated += lstats->counts.tuples_newpage_updated;
+ tabentry->blocks_fetched += lstats->counts.blocks_fetched;
+ tabentry->blocks_hit += lstats->counts.blocks_hit;
/*
- * If table was truncated/dropped, first reset the live/dead counters.
+ * Update counters that are only safe to flush outside of a transaction
+ * that has modified this relation.
*/
- if (lstats->counts.truncdropped)
+ if (lstats->trans == NULL)
{
- tabentry->live_tuples = 0;
- tabentry->dead_tuples = 0;
- tabentry->ins_since_vacuum = 0;
- }
+ tabentry->tuples_inserted += lstats->counts.tuples_inserted;
+ tabentry->tuples_updated += lstats->counts.tuples_updated;
+ tabentry->tuples_deleted += lstats->counts.tuples_deleted;
- tabentry->live_tuples += lstats->counts.delta_live_tuples;
- tabentry->dead_tuples += lstats->counts.delta_dead_tuples;
- tabentry->mod_since_analyze += lstats->counts.changed_tuples;
+ /*
+ * If table was truncated/dropped, first reset the live/dead counters.
+ */
+ if (lstats->counts.truncdropped)
+ {
+ tabentry->live_tuples = 0;
+ tabentry->dead_tuples = 0;
+ tabentry->ins_since_vacuum = 0;
+ }
- /*
- * Using tuples_inserted to update ins_since_vacuum does mean that we'll
- * track aborted inserts too. This isn't ideal, but otherwise probably
- * not worth adding an extra field for. It may just amount to autovacuums
- * triggering for inserts more often than they maybe should, which is
- * probably not going to be common enough to be too concerned about here.
- */
- tabentry->ins_since_vacuum += lstats->counts.tuples_inserted;
+ tabentry->live_tuples += lstats->counts.delta_live_tuples;
+ tabentry->dead_tuples += lstats->counts.delta_dead_tuples;
+ tabentry->mod_since_analyze += lstats->counts.changed_tuples;
- tabentry->blocks_fetched += lstats->counts.blocks_fetched;
- tabentry->blocks_hit += lstats->counts.blocks_hit;
+ /*
+ * Using tuples_inserted to update ins_since_vacuum does mean that
+ * we'll track aborted inserts too. This isn't ideal, but otherwise
+ * probably not worth adding an extra field for. It may just amount
+ * to autovacuums triggering for inserts more often than they maybe
+ * should, which is probably not going to be common enough to be too
+ * concerned about here.
+ */
+ tabentry->ins_since_vacuum += lstats->counts.tuples_inserted;
- /* Clamp live_tuples in case of negative delta_live_tuples */
- tabentry->live_tuples = Max(tabentry->live_tuples, 0);
- /* Likewise for dead_tuples */
- tabentry->dead_tuples = Max(tabentry->dead_tuples, 0);
+ /* Clamp live_tuples in case of negative delta_live_tuples */
+ tabentry->live_tuples = Max(tabentry->live_tuples, 0);
+ /* Likewise for dead_tuples */
+ tabentry->dead_tuples = Max(tabentry->dead_tuples, 0);
+ }
pgstat_unlock_entry(entry_ref);
@@ -893,13 +905,30 @@ pgstat_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
dbentry = pgstat_prep_database_pending(dboid);
dbentry->tuples_returned += lstats->counts.tuples_returned;
dbentry->tuples_fetched += lstats->counts.tuples_fetched;
- dbentry->tuples_inserted += lstats->counts.tuples_inserted;
- dbentry->tuples_updated += lstats->counts.tuples_updated;
- dbentry->tuples_deleted += lstats->counts.tuples_deleted;
dbentry->blocks_fetched += lstats->counts.blocks_fetched;
dbentry->blocks_hit += lstats->counts.blocks_hit;
- return true;
+ if (lstats->trans == NULL)
+ {
+ dbentry->tuples_inserted += lstats->counts.tuples_inserted;
+ dbentry->tuples_updated += lstats->counts.tuples_updated;
+ dbentry->tuples_deleted += lstats->counts.tuples_deleted;
+ return true;
+ }
+
+ /*
+ * This is a partial, in-transaction flush. Zero out the counters we
+ * already flushed so they aren't double-counted on the next flush.
+ */
+ lstats->counts.numscans = 0;
+ lstats->counts.tuples_returned = 0;
+ lstats->counts.tuples_fetched = 0;
+ lstats->counts.tuples_hot_updated = 0;
+ lstats->counts.tuples_newpage_updated = 0;
+ lstats->counts.blocks_fetched = 0;
+ lstats->counts.blocks_hit = 0;
+
+ return false;
}
void
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 6f9c9c72de5..eb22490dc2c 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -28,6 +28,7 @@
#include "replication/logicallauncher.h"
#include "storage/proc.h"
#include "storage/procarray.h"
+#include "storage/procsignal.h"
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/timestamp.h"
@@ -1929,6 +1930,45 @@ pg_stat_force_next_flush(PG_FUNCTION_ARGS)
PG_RETURN_VOID();
}
+/*
+ * Signal a backend to report all its pending statistics to shared memory.
+ * If the target is the current backend, the report happens immediately.
+ */
+Datum
+pg_stat_report_anytime(PG_FUNCTION_ARGS)
+{
+ int pid = PG_GETARG_INT32(0);
+ PGPROC *proc;
+ ProcNumber procNumber = INVALID_PROC_NUMBER;
+
+ if (pid == MyProcPid)
+ {
+ pgstat_report_anytime_stat();
+ PG_RETURN_BOOL(true);
+ }
+
+ proc = BackendPidGetProc(pid);
+ if (proc == NULL)
+ proc = AuxiliaryPidGetProc(pid);
+
+ if (proc == NULL)
+ {
+ ereport(WARNING,
+ (errmsg("PID %d is not a PostgreSQL server process", pid)));
+ PG_RETURN_BOOL(false);
+ }
+
+ procNumber = GetNumberFromPGProc(proc);
+ if (SendProcSignal(pid, PROCSIG_REPORT_ANYTIME_STATS, procNumber) < 0)
+ {
+ ereport(WARNING,
+ (errmsg("could not send signal to process %d: %m", pid)));
+ PG_RETURN_BOOL(false);
+ }
+
+ PG_RETURN_BOOL(true);
+}
+
/* Reset all counters for the current database */
Datum
diff --git a/src/backend/utils/init/globals.c b/src/backend/utils/init/globals.c
index bbd28d14d99..1b5b3d59c3c 100644
--- a/src/backend/utils/init/globals.c
+++ b/src/backend/utils/init/globals.c
@@ -39,6 +39,7 @@ volatile sig_atomic_t TransactionTimeoutPending = false;
volatile sig_atomic_t IdleSessionTimeoutPending = false;
volatile sig_atomic_t ProcSignalBarrierPending = false;
volatile sig_atomic_t LogMemoryContextPending = false;
+volatile sig_atomic_t ReportAnytimeStatsPending = false;
volatile sig_atomic_t IdleStatsUpdateTimeoutPending = false;
volatile uint32 InterruptHoldoffCount = 0;
volatile uint32 QueryCancelHoldoffCount = 0;
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index be157a5fbe9..406628025b1 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -6209,6 +6209,12 @@
proname => 'pg_stat_force_next_flush', proisstrict => 'f', provolatile => 'v',
proparallel => 'r', prorettype => 'void', proargtypes => '',
prosrc => 'pg_stat_force_next_flush' },
+{ oid => '9953',
+ descr => 'statistics: force flush of pending stats to shared memory, including from within a transaction',
+ proname => 'pg_stat_report_anytime', provolatile => 'v',
+ prorettype => 'bool', proargtypes => 'int4',
+ prosrc => 'pg_stat_report_anytime',
+ proacl => '{POSTGRES=X}' },
{ oid => '2274',
descr => 'statistics: reset collected statistics for current database',
proname => 'pg_stat_reset', proisstrict => 'f', provolatile => 'v',
diff --git a/src/include/miscadmin.h b/src/include/miscadmin.h
index 8ccdf61246b..7f8b38cb9d7 100644
--- a/src/include/miscadmin.h
+++ b/src/include/miscadmin.h
@@ -97,6 +97,7 @@ extern PGDLLIMPORT volatile sig_atomic_t TransactionTimeoutPending;
extern PGDLLIMPORT volatile sig_atomic_t IdleSessionTimeoutPending;
extern PGDLLIMPORT volatile sig_atomic_t ProcSignalBarrierPending;
extern PGDLLIMPORT volatile sig_atomic_t LogMemoryContextPending;
+extern PGDLLIMPORT volatile sig_atomic_t ReportAnytimeStatsPending;
extern PGDLLIMPORT volatile sig_atomic_t IdleStatsUpdateTimeoutPending;
extern PGDLLIMPORT volatile sig_atomic_t CheckClientConnectionPending;
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index dfa2e837638..87def3b08e2 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -552,6 +552,9 @@ extern void pgstat_initialize(void);
/* Functions called from backends */
extern long pgstat_report_stat(bool force);
extern void pgstat_force_next_flush(void);
+extern void pgstat_report_anytime_stat(void);
+extern void HandleReportAnytimeStatsInterrupt(void);
+extern void ProcessReportAnytimeStatsInterrupt(void);
extern void pgstat_reset_counters(void);
extern void pgstat_reset(PgStat_Kind kind, Oid dboid, uint64 objid);
diff --git a/src/include/storage/procsignal.h b/src/include/storage/procsignal.h
index aaa158bfd66..a184d449eba 100644
--- a/src/include/storage/procsignal.h
+++ b/src/include/storage/procsignal.h
@@ -38,6 +38,8 @@ typedef enum
PROCSIG_PARALLEL_APPLY_MESSAGE, /* Message from parallel apply workers */
PROCSIG_SLOTSYNC_MESSAGE, /* ask slot synchronization to stop */
PROCSIG_REPACK_MESSAGE, /* Message from repack worker */
+ PROCSIG_REPORT_ANYTIME_STATS, /* ask backend to report anytime
+ * statistics */
PROCSIG_RECOVERY_CONFLICT, /* backend is blocking recovery, check
* PGPROC->pendingRecoveryConflicts for the
* reason */
diff --git a/src/test/regress/expected/stats.out b/src/test/regress/expected/stats.out
index c551abb1178..66b683965a6 100644
--- a/src/test/regress/expected/stats.out
+++ b/src/test/regress/expected/stats.out
@@ -2040,4 +2040,126 @@ SELECT fastpath_exceeded > :fastpath_exceeded_before FROM pg_stat_lock WHERE loc
(1 row)
DROP TABLE part_test;
+--
+-- Test pg_stat_report_anytime
+--
+CREATE TABLE partial_flush(id int);
+INSERT INTO partial_flush VALUES (1), (2), (3);
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush
+--------------------------
+
+(1 row)
+
+-- Record counters before the explicit transaction
+SELECT seq_scan AS seq_scan_before,
+ seq_tup_read AS seq_tup_read_before,
+ n_tup_ins AS n_tup_ins_before,
+ n_tup_upd AS n_tup_upd_before
+ FROM pg_stat_user_tables WHERE relname = 'partial_flush' \gset
+BEGIN;
+SET LOCAL stats_fetch_consistency = none;
+-- Generate both transaction-safe and transaction-unsafe counters.
+SELECT count(*) FROM partial_flush;
+ count
+-------
+ 3
+(1 row)
+
+INSERT INTO partial_flush VALUES (4), (5);
+UPDATE partial_flush SET id = id WHERE id = 1;
+-- Flush mid-transaction
+SELECT pg_stat_report_anytime(pg_backend_pid());
+ pg_stat_report_anytime
+------------------------
+ t
+(1 row)
+
+-- Transaction-safe counters should be visible mid-transaction.
+-- Transaction-unsafe counters (ins, upd) should NOT be flushed yet,
+-- since their final values depend on whether the transaction commits.
+SELECT seq_scan - :seq_scan_before AS seq_scan_delta,
+ seq_tup_read - :seq_tup_read_before AS seq_tup_read_delta,
+ n_tup_ins - :n_tup_ins_before AS n_tup_ins_delta,
+ n_tup_upd - :n_tup_upd_before AS n_tup_upd_delta
+ FROM pg_stat_user_tables WHERE relname = 'partial_flush';
+ seq_scan_delta | seq_tup_read_delta | n_tup_ins_delta | n_tup_upd_delta
+----------------+--------------------+-----------------+-----------------
+ 2 | 8 | 0 | 0
+(1 row)
+
+-- Generate more transaction-safe activity to verify no double counting.
+SELECT count(*) FROM partial_flush;
+ count
+-------
+ 5
+(1 row)
+
+-- Flush again mid-transaction
+SELECT pg_stat_report_anytime(pg_backend_pid());
+ pg_stat_report_anytime
+------------------------
+ t
+(1 row)
+
+-- Should show cumulative totals, not double-counted.
+SELECT seq_scan - :seq_scan_before AS seq_scan_delta,
+ seq_tup_read - :seq_tup_read_before AS seq_tup_read_delta,
+ n_tup_ins - :n_tup_ins_before AS n_tup_ins_delta,
+ n_tup_upd - :n_tup_upd_before AS n_tup_upd_delta
+ FROM pg_stat_user_tables WHERE relname = 'partial_flush';
+ seq_scan_delta | seq_tup_read_delta | n_tup_ins_delta | n_tup_upd_delta
+----------------+--------------------+-----------------+-----------------
+ 3 | 13 | 0 | 0
+(1 row)
+
+COMMIT;
+-- After commit, all counters should be flushed.
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush
+--------------------------
+
+(1 row)
+
+SELECT seq_scan - :seq_scan_before AS seq_scan_delta,
+ seq_tup_read - :seq_tup_read_before AS seq_tup_read_delta,
+ n_tup_ins - :n_tup_ins_before AS n_tup_ins_delta,
+ n_tup_upd - :n_tup_upd_before AS n_tup_upd_delta
+ FROM pg_stat_user_tables WHERE relname = 'partial_flush';
+ seq_scan_delta | seq_tup_read_delta | n_tup_ins_delta | n_tup_upd_delta
+----------------+--------------------+-----------------+-----------------
+ 3 | 13 | 2 | 1
+(1 row)
+
+DROP TABLE partial_flush;
+-- Test that pg_stat_report_anytime also flushes non-relation stats.
+CREATE TABLE wal_flush_test(id int);
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush
+--------------------------
+
+(1 row)
+
+SELECT wal_records AS wal_records_before
+ FROM pg_stat_get_backend_wal(pg_backend_pid()) \gset
+BEGIN;
+SET LOCAL stats_fetch_consistency = none;
+-- Generate WAL inside the transaction.
+INSERT INTO wal_flush_test SELECT generate_series(1, 10);
+-- Flush mid-transaction; WAL stats should become visible immediately.
+SELECT pg_stat_report_anytime(pg_backend_pid());
+ pg_stat_report_anytime
+------------------------
+ t
+(1 row)
+
+SELECT wal_records > :wal_records_before AS wal_flushed
+ FROM pg_stat_get_backend_wal(pg_backend_pid());
+ wal_flushed
+-------------
+ t
+(1 row)
+
+COMMIT;
+DROP TABLE wal_flush_test;
-- End of Stats Test
diff --git a/src/test/regress/sql/stats.sql b/src/test/regress/sql/stats.sql
index 610fd21fae4..c8bc0f22f27 100644
--- a/src/test/regress/sql/stats.sql
+++ b/src/test/regress/sql/stats.sql
@@ -1008,4 +1008,85 @@ SELECT fastpath_exceeded > :fastpath_exceeded_before FROM pg_stat_lock WHERE loc
DROP TABLE part_test;
+--
+-- Test pg_stat_report_anytime
+--
+CREATE TABLE partial_flush(id int);
+INSERT INTO partial_flush VALUES (1), (2), (3);
+SELECT pg_stat_force_next_flush();
+
+-- Record counters before the explicit transaction
+SELECT seq_scan AS seq_scan_before,
+ seq_tup_read AS seq_tup_read_before,
+ n_tup_ins AS n_tup_ins_before,
+ n_tup_upd AS n_tup_upd_before
+ FROM pg_stat_user_tables WHERE relname = 'partial_flush' \gset
+
+BEGIN;
+SET LOCAL stats_fetch_consistency = none;
+
+-- Generate both transaction-safe and transaction-unsafe counters.
+SELECT count(*) FROM partial_flush;
+INSERT INTO partial_flush VALUES (4), (5);
+UPDATE partial_flush SET id = id WHERE id = 1;
+
+-- Flush mid-transaction
+SELECT pg_stat_report_anytime(pg_backend_pid());
+
+-- Transaction-safe counters should be visible mid-transaction.
+-- Transaction-unsafe counters (ins, upd) should NOT be flushed yet,
+-- since their final values depend on whether the transaction commits.
+SELECT seq_scan - :seq_scan_before AS seq_scan_delta,
+ seq_tup_read - :seq_tup_read_before AS seq_tup_read_delta,
+ n_tup_ins - :n_tup_ins_before AS n_tup_ins_delta,
+ n_tup_upd - :n_tup_upd_before AS n_tup_upd_delta
+ FROM pg_stat_user_tables WHERE relname = 'partial_flush';
+
+-- Generate more transaction-safe activity to verify no double counting.
+SELECT count(*) FROM partial_flush;
+
+-- Flush again mid-transaction
+SELECT pg_stat_report_anytime(pg_backend_pid());
+
+-- Should show cumulative totals, not double-counted.
+SELECT seq_scan - :seq_scan_before AS seq_scan_delta,
+ seq_tup_read - :seq_tup_read_before AS seq_tup_read_delta,
+ n_tup_ins - :n_tup_ins_before AS n_tup_ins_delta,
+ n_tup_upd - :n_tup_upd_before AS n_tup_upd_delta
+ FROM pg_stat_user_tables WHERE relname = 'partial_flush';
+
+COMMIT;
+
+-- After commit, all counters should be flushed.
+SELECT pg_stat_force_next_flush();
+
+SELECT seq_scan - :seq_scan_before AS seq_scan_delta,
+ seq_tup_read - :seq_tup_read_before AS seq_tup_read_delta,
+ n_tup_ins - :n_tup_ins_before AS n_tup_ins_delta,
+ n_tup_upd - :n_tup_upd_before AS n_tup_upd_delta
+ FROM pg_stat_user_tables WHERE relname = 'partial_flush';
+
+DROP TABLE partial_flush;
+
+-- Test that pg_stat_report_anytime also flushes non-relation stats.
+CREATE TABLE wal_flush_test(id int);
+SELECT pg_stat_force_next_flush();
+SELECT wal_records AS wal_records_before
+ FROM pg_stat_get_backend_wal(pg_backend_pid()) \gset
+
+BEGIN;
+SET LOCAL stats_fetch_consistency = none;
+
+-- Generate WAL inside the transaction.
+INSERT INTO wal_flush_test SELECT generate_series(1, 10);
+
+-- Flush mid-transaction; WAL stats should become visible immediately.
+SELECT pg_stat_report_anytime(pg_backend_pid());
+
+SELECT wal_records > :wal_records_before AS wal_flushed
+ FROM pg_stat_get_backend_wal(pg_backend_pid());
+
+COMMIT;
+DROP TABLE wal_flush_test;
+
-- End of Stats Test
--
2.50.1 (Apple Git-155)