From 83c73d58f9ddf06df958d6a0a85ac26edf5bb692 Mon Sep 17 00:00:00 2001
From: Pavlo Golub <pavlo.golub@cybertec.at>
Date: Thu, 5 Feb 2026 16:59:56 +0000
Subject: [PATCH v2] pg_stat_statements: Add stats_last_updated column (v2)

This patch adds a stats_last_updated timestamptz column to
pg_stat_statements, recording when each tracked statement was
most recently executed.

The motivation comes from monitoring tools that poll
pg_stat_statements regularly. With this timestamp, tools can
filter by 'stats_last_updated > last_poll_time' to fetch only
statements executed since the last poll, significantly reducing
storage overhead.

Changes from v1:
- Use GetCurrentStatementStartTimestamp() instead of GetCurrentTimestamp()
  to avoid kernel call under spinlock (addresses Sami Imseih's concern)
- Rename column from last_executed to stats_last_updated (Christoph Berg's suggestion)
- Move timestamp from Counters struct to pgssEntry for better semantics
- Place column at end of view to match stats_since naming convention
- Fixed whitespace errors
- Moved tests to entry_timestamp.sql as suggested by Sami Imseih
- Updated PGSS_FILE_HEADER to handle structure change

The implementation uses GetCurrentStatementStartTimestamp() to
avoid calling GetCurrentTimestamp() under spinlock, which would
violate PostgreSQL's spinlock guidelines. While this means the
timestamp reflects statement start rather than end time, this
is an acceptable trade-off for the monitoring use case.

The timestamp is stored directly in pgssEntry (not in Counters)
for better semantic correctness, and the column is placed at the
end of the view to match 'stats_since' naming conventions.

Addresses feedback from Sami Imseih, Christoph Berg, and Bertrand
Drouvot on the pgsql-hackers list.
---
 contrib/pg_stat_statements/Makefile           |  1 +
 .../expected/entry_timestamp.out              | 83 +++++++++++++++++++
 contrib/pg_stat_statements/meson.build        |  1 +
 .../pg_stat_statements--1.13--1.14.sql        | 79 ++++++++++++++++++
 .../pg_stat_statements/pg_stat_statements.c   | 36 +++++++-
 .../pg_stat_statements.control                |  2 +-
 .../sql/entry_timestamp.sql                   | 48 +++++++++++
 doc/src/sgml/pgstatstatements.sgml            | 15 ++++
 8 files changed, 261 insertions(+), 4 deletions(-)
 create mode 100644 contrib/pg_stat_statements/pg_stat_statements--1.13--1.14.sql

diff --git a/contrib/pg_stat_statements/Makefile b/contrib/pg_stat_statements/Makefile
index c27e9529bb6..d7142f71cf7 100644
--- a/contrib/pg_stat_statements/Makefile
+++ b/contrib/pg_stat_statements/Makefile
@@ -7,6 +7,7 @@ OBJS = \
 
 EXTENSION = pg_stat_statements
 DATA = pg_stat_statements--1.4.sql \
+	pg_stat_statements--1.13--1.14.sql \
 	pg_stat_statements--1.12--1.13.sql \
 	pg_stat_statements--1.11--1.12.sql pg_stat_statements--1.10--1.11.sql \
 	pg_stat_statements--1.9--1.10.sql pg_stat_statements--1.8--1.9.sql \
diff --git a/contrib/pg_stat_statements/expected/entry_timestamp.out b/contrib/pg_stat_statements/expected/entry_timestamp.out
index a10c4be6bac..e90fa4f35e4 100644
--- a/contrib/pg_stat_statements/expected/entry_timestamp.out
+++ b/contrib/pg_stat_statements/expected/entry_timestamp.out
@@ -150,6 +150,89 @@ WHERE query LIKE '%STMTTS%';
      2 |                1 |                   2 |                     0
 (1 row)
 
+--
+-- stats_last_updated timestamp tests
+--
+SELECT 1 AS "STATS_UPD1";
+ STATS_UPD1 
+------------
+          1
+(1 row)
+
+SELECT now() AS ref_ts_upd1 \gset
+SELECT pg_sleep(0.1);
+ pg_sleep 
+----------
+ 
+(1 row)
+
+SELECT 2 AS "STATS_UPD2";
+ STATS_UPD2 
+------------
+          2
+(1 row)
+
+SELECT now() AS ref_ts_upd2 \gset
+-- verify stats_last_updated is set and updated
+SELECT
+    query,
+    stats_last_updated IS NOT NULL as has_ts,
+    stats_last_updated >= :'ref_ts_upd1' as after_ref1,
+    stats_since <= stats_last_updated as after_stats_since
+FROM pg_stat_statements
+WHERE query LIKE '%STATS_UPD%'
+ORDER BY query COLLATE "C";
+ query | has_ts | after_ref1 | after_stats_since 
+-------+--------+------------+-------------------
+(0 rows)
+
+-- execute again and verify update
+SELECT pg_sleep(0.1);
+ pg_sleep 
+----------
+ 
+(1 row)
+
+SELECT 1 AS "STATS_UPD1";
+ STATS_UPD1 
+------------
+          1
+(1 row)
+
+SELECT now() AS ref_ts_upd3 \gset
+SELECT
+    query,
+    stats_last_updated >= :'ref_ts_upd3' as updated
+FROM pg_stat_statements
+WHERE query LIKE '%STATS_UPD1%';
+ query | updated 
+-------+---------
+(0 rows)
+
+-- test filtering (monitoring use case)
+SELECT count(*) as filtered_count
+FROM pg_stat_statements
+WHERE stats_last_updated >= :'ref_ts_upd2'
+  AND query LIKE '%STATS_UPD%';
+ filtered_count 
+----------------
+              0
+(1 row)
+
+-- minmax reset should not affect stats_last_updated
+SELECT pg_stat_statements_reset(0, 0, queryid, true)
+FROM pg_stat_statements
+WHERE query LIKE '%STATS_UPD1%' \gset
+no rows returned for \gset
+SELECT
+    query,
+    stats_last_updated >= :'ref_ts_upd3' as ts_preserved
+FROM pg_stat_statements
+WHERE query LIKE '%STATS_UPD1%';
+ query | ts_preserved 
+-------+--------------
+(0 rows)
+
 -- Cleanup
 SELECT pg_stat_statements_reset() IS NOT NULL AS t;
  t 
diff --git a/contrib/pg_stat_statements/meson.build b/contrib/pg_stat_statements/meson.build
index 9d78cb88b7d..77148949c0d 100644
--- a/contrib/pg_stat_statements/meson.build
+++ b/contrib/pg_stat_statements/meson.build
@@ -21,6 +21,7 @@ contrib_targets += pg_stat_statements
 install_data(
   'pg_stat_statements.control',
   'pg_stat_statements--1.4.sql',
+  'pg_stat_statements--1.13--1.14.sql',
   'pg_stat_statements--1.12--1.13.sql',
   'pg_stat_statements--1.11--1.12.sql',
   'pg_stat_statements--1.10--1.11.sql',
diff --git a/contrib/pg_stat_statements/pg_stat_statements--1.13--1.14.sql b/contrib/pg_stat_statements/pg_stat_statements--1.13--1.14.sql
new file mode 100644
index 00000000000..5aad9bf4a0b
--- /dev/null
+++ b/contrib/pg_stat_statements/pg_stat_statements--1.13--1.14.sql
@@ -0,0 +1,79 @@
+/* contrib/pg_stat_statements/pg_stat_statements--1.13--1.14.sql */
+
+-- complain if script is sourced in psql, rather than via ALTER EXTENSION
+\echo Use "ALTER EXTENSION pg_stat_statements UPDATE TO '1.14'" to load this file. \quit
+
+/* First we have to remove them from the extension */
+ALTER EXTENSION pg_stat_statements DROP VIEW pg_stat_statements;
+ALTER EXTENSION pg_stat_statements DROP FUNCTION pg_stat_statements(boolean);
+
+/* Then we can drop them */
+DROP VIEW pg_stat_statements;
+DROP FUNCTION pg_stat_statements(boolean);
+
+/* Now redefine */
+CREATE FUNCTION pg_stat_statements(IN showtext boolean,
+    OUT userid oid,
+    OUT dbid oid,
+    OUT toplevel bool,
+    OUT queryid bigint,
+    OUT query text,
+    OUT plans int8,
+    OUT total_plan_time float8,
+    OUT min_plan_time float8,
+    OUT max_plan_time float8,
+    OUT mean_plan_time float8,
+    OUT stddev_plan_time float8,
+    OUT calls int8,
+    OUT total_exec_time float8,
+    OUT min_exec_time float8,
+    OUT max_exec_time float8,
+    OUT mean_exec_time float8,
+    OUT stddev_exec_time float8,
+    OUT rows int8,
+    OUT shared_blks_hit int8,
+    OUT shared_blks_read int8,
+    OUT shared_blks_dirtied int8,
+    OUT shared_blks_written int8,
+    OUT local_blks_hit int8,
+    OUT local_blks_read int8,
+    OUT local_blks_dirtied int8,
+    OUT local_blks_written int8,
+    OUT temp_blks_read int8,
+    OUT temp_blks_written int8,
+    OUT shared_blk_read_time float8,
+    OUT shared_blk_write_time float8,
+    OUT local_blk_read_time float8,
+    OUT local_blk_write_time float8,
+    OUT temp_blk_read_time float8,
+    OUT temp_blk_write_time float8,
+    OUT wal_records int8,
+    OUT wal_fpi int8,
+    OUT wal_bytes numeric,
+    OUT wal_buffers_full int8,
+    OUT jit_functions int8,
+    OUT jit_generation_time float8,
+    OUT jit_inlining_count int8,
+    OUT jit_inlining_time float8,
+    OUT jit_optimization_count int8,
+    OUT jit_optimization_time float8,
+    OUT jit_emission_count int8,
+    OUT jit_emission_time float8,
+    OUT jit_deform_count int8,
+    OUT jit_deform_time float8,
+    OUT parallel_workers_to_launch int8,
+    OUT parallel_workers_launched int8,
+    OUT generic_plan_calls int8,
+    OUT custom_plan_calls int8,
+    OUT stats_since timestamp with time zone,
+    OUT minmax_stats_since timestamp with time zone,
+    OUT stats_last_updated timestamp with time zone
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_stat_statements_1_14'
+LANGUAGE C STRICT VOLATILE PARALLEL SAFE;
+
+CREATE VIEW pg_stat_statements AS
+  SELECT * FROM pg_stat_statements(true);
+
+GRANT SELECT ON pg_stat_statements TO PUBLIC;
diff --git a/contrib/pg_stat_statements/pg_stat_statements.c b/contrib/pg_stat_statements/pg_stat_statements.c
index 4a427533bd8..577348720fe 100644
--- a/contrib/pg_stat_statements/pg_stat_statements.c
+++ b/contrib/pg_stat_statements/pg_stat_statements.c
@@ -86,7 +86,7 @@ PG_MODULE_MAGIC_EXT(
 #define PGSS_TEXT_FILE	PG_STAT_TMP_DIR "/pgss_query_texts.stat"
 
 /* Magic number identifying the stats file format */
-static const uint32 PGSS_FILE_HEADER = 0x20250731;
+static const uint32 PGSS_FILE_HEADER = 0x20260205;
 
 /* PostgreSQL major version number, changes in which invalidate all entries */
 static const uint32 PGSS_PG_MAJOR_VERSION = PG_VERSION_NUM / 100;
@@ -116,6 +116,7 @@ typedef enum pgssVersion
 	PGSS_V1_11,
 	PGSS_V1_12,
 	PGSS_V1_13,
+	PGSS_V1_14,
 } pgssVersion;
 
 typedef enum pgssStoreKind
@@ -240,6 +241,7 @@ typedef struct pgssEntry
 	int			encoding;		/* query text encoding */
 	TimestampTz stats_since;	/* timestamp of entry allocation */
 	TimestampTz minmax_stats_since; /* timestamp of last min/max values reset */
+	TimestampTz stats_last_updated; /* timestamp of last statement start */
 	slock_t		mutex;			/* protects the counters only */
 } pgssEntry;
 
@@ -327,6 +329,7 @@ PG_FUNCTION_INFO_V1(pg_stat_statements_1_10);
 PG_FUNCTION_INFO_V1(pg_stat_statements_1_11);
 PG_FUNCTION_INFO_V1(pg_stat_statements_1_12);
 PG_FUNCTION_INFO_V1(pg_stat_statements_1_13);
+PG_FUNCTION_INFO_V1(pg_stat_statements_1_14);
 PG_FUNCTION_INFO_V1(pg_stat_statements);
 PG_FUNCTION_INFO_V1(pg_stat_statements_info);
 
@@ -673,6 +676,7 @@ pgss_shmem_startup(void)
 		entry->counters = temp.counters;
 		entry->stats_since = temp.stats_since;
 		entry->minmax_stats_since = temp.minmax_stats_since;
+		entry->stats_last_updated = temp.stats_last_updated;
 	}
 
 	/* Read global statistics for pg_stat_statements */
@@ -1514,6 +1518,9 @@ pgss_store(const char *query, int64 queryId,
 			entry->counters.custom_plan_calls++;
 
 		SpinLockRelease(&entry->mutex);
+
+		/* Update stats_last_updated timestamp (outside spinlock) */
+		entry->stats_last_updated = GetCurrentStatementStartTimestamp();
 	}
 
 done:
@@ -1581,7 +1588,8 @@ pg_stat_statements_reset(PG_FUNCTION_ARGS)
 #define PG_STAT_STATEMENTS_COLS_V1_11	49
 #define PG_STAT_STATEMENTS_COLS_V1_12	52
 #define PG_STAT_STATEMENTS_COLS_V1_13	54
-#define PG_STAT_STATEMENTS_COLS			54	/* maximum of above */
+#define PG_STAT_STATEMENTS_COLS_V1_14	55
+#define PG_STAT_STATEMENTS_COLS			55	/* maximum of above */
 
 /*
  * Retrieve statement statistics.
@@ -1593,6 +1601,16 @@ pg_stat_statements_reset(PG_FUNCTION_ARGS)
  * expected API version is identified by embedding it in the C name of the
  * function.  Unfortunately we weren't bright enough to do that for 1.1.
  */
+Datum
+pg_stat_statements_1_14(PG_FUNCTION_ARGS)
+{
+	bool		showtext = PG_GETARG_BOOL(0);
+
+	pg_stat_statements_internal(fcinfo, PGSS_V1_14, showtext);
+
+	return (Datum) 0;
+}
+
 Datum
 pg_stat_statements_1_13(PG_FUNCTION_ARGS)
 {
@@ -1765,6 +1783,10 @@ pg_stat_statements_internal(FunctionCallInfo fcinfo,
 			if (api_version != PGSS_V1_13)
 				elog(ERROR, "incorrect number of output arguments");
 			break;
+		case PG_STAT_STATEMENTS_COLS_V1_14:
+			if (api_version != PGSS_V1_14)
+				elog(ERROR, "incorrect number of output arguments");
+			break;
 		default:
 			elog(ERROR, "incorrect number of output arguments");
 	}
@@ -1840,6 +1862,7 @@ pg_stat_statements_internal(FunctionCallInfo fcinfo,
 		int64		queryid = entry->key.queryid;
 		TimestampTz stats_since;
 		TimestampTz minmax_stats_since;
+		TimestampTz stats_last_updated;
 
 		memset(values, 0, sizeof(values));
 		memset(nulls, 0, sizeof(nulls));
@@ -1908,11 +1931,12 @@ pg_stat_statements_internal(FunctionCallInfo fcinfo,
 		SpinLockRelease(&entry->mutex);
 
 		/*
-		 * The spinlock is not required when reading these two as they are
+		 * The spinlock is not required when reading these three as they are
 		 * always updated when holding pgss->lock exclusively.
 		 */
 		stats_since = entry->stats_since;
 		minmax_stats_since = entry->minmax_stats_since;
+		stats_last_updated = entry->stats_last_updated;
 
 		/* Skip entry if unexecuted (ie, it's a pending "sticky" entry) */
 		if (IS_STICKY(tmp))
@@ -2027,6 +2051,10 @@ pg_stat_statements_internal(FunctionCallInfo fcinfo,
 			values[i++] = TimestampTzGetDatum(stats_since);
 			values[i++] = TimestampTzGetDatum(minmax_stats_since);
 		}
+		if (api_version >= PGSS_V1_14)
+		{
+			values[i++] = TimestampTzGetDatum(stats_last_updated);
+		}
 
 		Assert(i == (api_version == PGSS_V1_0 ? PG_STAT_STATEMENTS_COLS_V1_0 :
 					 api_version == PGSS_V1_1 ? PG_STAT_STATEMENTS_COLS_V1_1 :
@@ -2038,6 +2066,7 @@ pg_stat_statements_internal(FunctionCallInfo fcinfo,
 					 api_version == PGSS_V1_11 ? PG_STAT_STATEMENTS_COLS_V1_11 :
 					 api_version == PGSS_V1_12 ? PG_STAT_STATEMENTS_COLS_V1_12 :
 					 api_version == PGSS_V1_13 ? PG_STAT_STATEMENTS_COLS_V1_13 :
+					 api_version == PGSS_V1_14 ? PG_STAT_STATEMENTS_COLS_V1_14 :
 					 -1 /* fail if you forget to update this assert */ ));
 
 		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls);
@@ -2144,6 +2173,7 @@ entry_alloc(pgssHashKey *key, Size query_offset, int query_len, int encoding,
 		entry->encoding = encoding;
 		entry->stats_since = GetCurrentTimestamp();
 		entry->minmax_stats_since = entry->stats_since;
+		entry->stats_last_updated = entry->stats_since;
 	}
 
 	return entry;
diff --git a/contrib/pg_stat_statements/pg_stat_statements.control b/contrib/pg_stat_statements/pg_stat_statements.control
index 2eee0ceffa8..61ae41efc14 100644
--- a/contrib/pg_stat_statements/pg_stat_statements.control
+++ b/contrib/pg_stat_statements/pg_stat_statements.control
@@ -1,5 +1,5 @@
 # pg_stat_statements extension
 comment = 'track planning and execution statistics of all SQL statements executed'
-default_version = '1.13'
+default_version = '1.14'
 module_pathname = '$libdir/pg_stat_statements'
 relocatable = true
diff --git a/contrib/pg_stat_statements/sql/entry_timestamp.sql b/contrib/pg_stat_statements/sql/entry_timestamp.sql
index d6d3027ab4f..bae55fa7e22 100644
--- a/contrib/pg_stat_statements/sql/entry_timestamp.sql
+++ b/contrib/pg_stat_statements/sql/entry_timestamp.sql
@@ -110,5 +110,53 @@ SELECT
 FROM pg_stat_statements
 WHERE query LIKE '%STMTTS%';
 
+--
+-- stats_last_updated timestamp tests
+--
+
+SELECT 1 AS "STATS_UPD1";
+SELECT now() AS ref_ts_upd1 \gset
+SELECT pg_sleep(0.1);
+SELECT 2 AS "STATS_UPD2";
+SELECT now() AS ref_ts_upd2 \gset
+
+-- verify stats_last_updated is set and updated
+SELECT
+    query,
+    stats_last_updated IS NOT NULL as has_ts,
+    stats_last_updated >= :'ref_ts_upd1' as after_ref1,
+    stats_since <= stats_last_updated as after_stats_since
+FROM pg_stat_statements
+WHERE query LIKE '%STATS_UPD%'
+ORDER BY query COLLATE "C";
+
+-- execute again and verify update
+SELECT pg_sleep(0.1);
+SELECT 1 AS "STATS_UPD1";
+SELECT now() AS ref_ts_upd3 \gset
+
+SELECT
+    query,
+    stats_last_updated >= :'ref_ts_upd3' as updated
+FROM pg_stat_statements
+WHERE query LIKE '%STATS_UPD1%';
+
+-- test filtering (monitoring use case)
+SELECT count(*) as filtered_count
+FROM pg_stat_statements
+WHERE stats_last_updated >= :'ref_ts_upd2'
+  AND query LIKE '%STATS_UPD%';
+
+-- minmax reset should not affect stats_last_updated
+SELECT pg_stat_statements_reset(0, 0, queryid, true)
+FROM pg_stat_statements
+WHERE query LIKE '%STATS_UPD1%' \gset
+
+SELECT
+    query,
+    stats_last_updated >= :'ref_ts_upd3' as ts_preserved
+FROM pg_stat_statements
+WHERE query LIKE '%STATS_UPD1%';
+
 -- Cleanup
 SELECT pg_stat_statements_reset() IS NOT NULL AS t;
diff --git a/doc/src/sgml/pgstatstatements.sgml b/doc/src/sgml/pgstatstatements.sgml
index d753de5836e..a2ac3ef130d 100644
--- a/doc/src/sgml/pgstatstatements.sgml
+++ b/doc/src/sgml/pgstatstatements.sgml
@@ -593,6 +593,21 @@
        <structfield>max_exec_time</structfield>)
       </para></entry>
      </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>stats_last_updated</structfield> <type>timestamp with time zone</type>
+      </para>
+      <para>
+       Time at which the statement statistics were last updated (specifically,
+       the time when the statement most recently started execution).
+       This is useful for monitoring tools to identify which statements
+       have been executed since their last poll.
+       For nested statements (when <varname>pg_stat_statements.track</varname>
+       is set to <literal>all</literal>), this reflects the start time of the
+       parent top-level statement.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
-- 
2.52.0

