[Proposal] Adding callback support for custom statistics kinds

Started by Sami Imseih3 months ago46 messages
#1Sami Imseih
samimseih@gmail.com
2 attachment(s)

Hi,

I'd like to propose $SUBJECT to serialize additional per-entry data beyond
the standard statistics entries. Currently, custom statistics kinds can store
their standard entry data in the main "pgstat.stat" file, but there is no
mechanism for extensions to persist extra data stored in the entry. A common
use case is extensions that register a custom kind and, besides
standard counters,
need to track variable-length data stored in a dsa_pointer.

This proposal adds optional "to_serialized_extra" and
"from_serialized_extra" callbacks to "PgStat_KindInfo" that allow custom kinds
to write and read from extra data in a separate files
(pgstat.<kind>.stat). The callbacks
give extensions direct access to the file pointer so they can read and write
data in any format, while the core "pgstat" infrastructure manages
opening, closing, renaming, and cleanup, just as it does with "pgstat.stat".

A concrete use case is pg_stat_statements. If it were to use custom
stats kinds to track statement counters, it could also track query text
stored in DSA. The callbacks allow saving the query text referenced by the
dsa_pointer and restoring it after a clean shutdown. Since DSA
(and more specifically DSM) cannot be attached by the postmaster, an
extension cannot use "on_shmem_exit" or "shmem_startup_hook"
to serialize or restore this data. This is why pgstat handles
serialization during checkpointer shutdown and startup, allowing a single
backend to manage it safely.

I considered adding hooks to the existing pgstat code paths
(pgstat_before_server_shutdown, pgstat_discard_stats, and
pgstat_restore_stats), but that felt too unrestricted. Using per-kind
callbacks provides more control.

There are already "to_serialized_name" and "from_serialized_name"
callbacks used to store and read entries by "name" instead of
"PgStat_HashKey", currently used by replication slot stats. Those
remain unchanged, as they serve a separate purpose.

Other design points:

1. Filenames use "pgstat.<kind>.stat" based on the numeric kind ID.
This avoids requiring extensions to provide names and prevents issues
with spaces or special characters.

2. Both callbacks must be registered together. Serializing without
deserializing would leave orphaned files behind, and I cannot think of a
reason to allow this.

3. "write_chunk", "read_chunk", "write_chunk_s", and
"read_chunk_s" are renamed to "pgstat_write_chunk", etc., and
moved to "pgstat_internal.h" so extensions can use them without
re-implementing these functions.

4. These callbacks are valid only for custom, variable-numbered statistics
kinds. Custom fixed kinds may not benefit, but could be considered in the
future.

Attached 0001 is the proposed change, still in POC form. The second patch
contains tests in "injection_points" to demonstrate this proposal, and is not
necessarily intended for commit.

Looking forward to your feedback!

--

Sami Imseih
Amazon Web Services (AWS)

Attachments:

0002-test-custom-stats-callbacks.patchapplication/octet-stream; name=0002-test-custom-stats-callbacks.patchDownload
From c4a26749dc69ba546c5c5a942afbcf5684189b01 Mon Sep 17 00:00:00 2001
From: Ubuntu <ubuntu@ip-172-31-46-230.ec2.internal>
Date: Wed, 22 Oct 2025 18:58:18 +0000
Subject: [PATCH 2/2] test custom stats callbacks

---
 .../injection_points--1.0.sql                 |  14 +-
 .../injection_points/injection_points.c       |   8 +-
 .../injection_points/injection_stats.c        | 177 +++++++++++++++++-
 .../injection_points/injection_stats.h        |   3 +-
 .../modules/injection_points/t/001_stats.pl   |  86 +++++++++
 5 files changed, 283 insertions(+), 5 deletions(-)

diff --git a/src/test/modules/injection_points/injection_points--1.0.sql b/src/test/modules/injection_points/injection_points--1.0.sql
index a7b61fbdfe6..0486eea4aee 100644
--- a/src/test/modules/injection_points/injection_points--1.0.sql
+++ b/src/test/modules/injection_points/injection_points--1.0.sql
@@ -9,10 +9,20 @@
 -- Attaches the action to the given injection point.
 --
 CREATE FUNCTION injection_points_attach(IN point_name TEXT,
-    IN action text)
+    IN action text, IN description text DEFAULT NULL)
 RETURNS void
 AS 'MODULE_PATHNAME', 'injection_points_attach'
-LANGUAGE C STRICT PARALLEL UNSAFE;
+LANGUAGE C PARALLEL UNSAFE;
+
+--
+-- injection_points_stats_description()
+--
+-- Reports statistics, if any, related to the given injection point.
+--
+CREATE FUNCTION injection_points_stats_description(IN point_name TEXT)
+RETURNS TEXT
+AS 'MODULE_PATHNAME', 'injection_points_stats_description'
+LANGUAGE C STRICT;
 
 --
 -- injection_points_load()
diff --git a/src/test/modules/injection_points/injection_points.c b/src/test/modules/injection_points/injection_points.c
index 31138301117..a2356836f7f 100644
--- a/src/test/modules/injection_points/injection_points.c
+++ b/src/test/modules/injection_points/injection_points.c
@@ -353,9 +353,15 @@ injection_points_attach(PG_FUNCTION_ARGS)
 {
 	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
 	char	   *action = text_to_cstring(PG_GETARG_TEXT_PP(1));
+	char	   *description = NULL; /* optional description */
 	char	   *function;
 	InjectionPointCondition condition = {0};
 
+	if (PG_NARGS() > 2 && !PG_ARGISNULL(2))
+	{
+		description = text_to_cstring(PG_GETARG_TEXT_PP(2));
+	}
+
 	if (strcmp(action, "error") == 0)
 		function = "injection_error";
 	else if (strcmp(action, "notice") == 0)
@@ -386,7 +392,7 @@ injection_points_attach(PG_FUNCTION_ARGS)
 	}
 
 	/* Add entry for stats */
-	pgstat_create_inj(name);
+	pgstat_create_inj(name, description);
 
 	PG_RETURN_VOID();
 }
diff --git a/src/test/modules/injection_points/injection_stats.c b/src/test/modules/injection_points/injection_stats.c
index 158e1631af9..d15ef65fb39 100644
--- a/src/test/modules/injection_points/injection_stats.c
+++ b/src/test/modules/injection_points/injection_stats.c
@@ -19,6 +19,7 @@
 #include "common/hashfn.h"
 #include "injection_stats.h"
 #include "pgstat.h"
+#include "storage/dsm_registry.h"
 #include "utils/builtins.h"
 #include "utils/pgstat_internal.h"
 
@@ -26,6 +27,7 @@
 typedef struct PgStat_StatInjEntry
 {
 	PgStat_Counter numcalls;	/* number of times point has been run */
+	dsa_pointer description;	/* injection point description */
 } PgStat_StatInjEntry;
 
 typedef struct PgStatShared_InjectionPoint
@@ -35,6 +37,12 @@ typedef struct PgStatShared_InjectionPoint
 } PgStatShared_InjectionPoint;
 
 static bool injection_stats_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
+void		injection_stats_serialize_extra(const PgStat_HashKey *key,
+											const PgStatShared_Common *header,
+											FILE *fd);
+bool		injection_stats_deserialize_extra(PgStat_HashKey *key,
+											  const PgStatShared_Common *header,
+											  FILE *fd);
 
 static const PgStat_KindInfo injection_stats = {
 	.name = "injection_points",
@@ -50,6 +58,8 @@ static const PgStat_KindInfo injection_stats = {
 	.shared_data_len = sizeof(((PgStatShared_InjectionPoint *) 0)->stats),
 	.pending_size = sizeof(PgStat_StatInjEntry),
 	.flush_pending_cb = injection_stats_flush_cb,
+	.to_serialized_extra = injection_stats_serialize_extra,
+	.from_serialized_extra = injection_stats_deserialize_extra,
 };
 
 /*
@@ -65,6 +75,9 @@ static const PgStat_KindInfo injection_stats = {
 /* Track if stats are loaded */
 static bool inj_stats_loaded = false;
 
+/* DSA area to store an injection points description */
+dsa_area   *inj_description_dsa = NULL;
+
 /*
  * Callback for stats handling
  */
@@ -122,10 +135,13 @@ pgstat_register_inj(void)
  * Report injection point creation.
  */
 void
-pgstat_create_inj(const char *name)
+pgstat_create_inj(const char *name, const char *description)
 {
 	PgStat_EntryRef *entry_ref;
 	PgStatShared_InjectionPoint *shstatent;
+	size_t		len;
+	char	   *desc_copy;
+	bool		found;
 
 	/* leave if disabled */
 	if (!inj_stats_loaded || !inj_stats_enabled)
@@ -138,6 +154,25 @@ pgstat_create_inj(const char *name)
 
 	/* initialize shared memory data */
 	memset(&shstatent->stats, 0, sizeof(shstatent->stats));
+
+	if (!description)
+		return;
+
+	len = strlen(description) + 1;
+
+	if (!inj_description_dsa)
+		inj_description_dsa = GetNamedDSA("injection_points_description", &found);
+
+	if (inj_description_dsa)
+	{
+		shstatent->stats.description = dsa_allocate(inj_description_dsa, len);
+
+		desc_copy = dsa_get_address(inj_description_dsa,
+									shstatent->stats.description);
+		desc_copy[len - 1] = '\0';
+
+		memcpy(desc_copy, description, len);
+	}
 }
 
 /*
@@ -180,6 +215,146 @@ pgstat_report_inj(const char *name)
 	pending->numcalls++;
 }
 
+void
+injection_stats_serialize_extra(const PgStat_HashKey *key,
+								const PgStatShared_Common *header,
+								FILE *fd)
+{
+	char	   *description;
+	size_t		qlen;
+	PgStatShared_InjectionPoint *entry = (PgStatShared_InjectionPoint *) header;
+
+	/* Exit early if stats aren't available or enabled */
+	if (!inj_stats_loaded || !inj_stats_enabled || !key)
+		return;
+
+	/* Write hash key */
+	pgstat_write_chunk(fd, (void *) key, sizeof(PgStat_HashKey));
+
+	/* Handle missing description */
+	if (!DsaPointerIsValid(entry->stats.description))
+	{
+		fputc('\0', fd);
+		return;
+	}
+
+	/* Ensure DSA area is loaded */
+	if (!inj_description_dsa)
+	{
+		bool		found;
+
+		inj_description_dsa = GetNamedDSA("injection_points_description", &found);
+	}
+
+	if (!inj_description_dsa)
+	{
+		fputc('\0', fd);
+		return;
+	}
+
+	/* Get description and write it */
+	description = dsa_get_address(inj_description_dsa, entry->stats.description);
+	qlen = strlen(description) + 1; /* include null terminator */
+
+	pgstat_write_chunk(fd, description, qlen);
+}
+
+bool
+injection_stats_deserialize_extra(PgStat_HashKey *key,
+								  const PgStatShared_Common *header,
+								  FILE *fd)
+{
+	PgStatShared_InjectionPoint *entry;
+	dsa_pointer dp;
+	size_t		bufcap;
+	size_t		len;
+	char	   *buffer;
+	int			c;
+
+	if (!inj_stats_loaded || !inj_stats_enabled)
+		return true;
+
+	/* Read the key */
+	if (!pgstat_read_chunk(fd, (void *) key, sizeof(PgStat_HashKey)))
+		return feof(fd) ? true : false;
+
+	/* Ensure DSA is ready */
+	if (!inj_description_dsa)
+	{
+		bool		found;
+
+		inj_description_dsa = GetNamedDSA("injection_points_description", &found);
+	}
+
+	entry =
+		(PgStatShared_InjectionPoint *) header;
+
+	/* Read null-terminated description */
+	bufcap = 128;
+	len = 0;
+	buffer = palloc(bufcap);
+
+	while ((c = fgetc(fd)) != EOF)
+	{
+		if (len + 1 >= bufcap)
+		{
+			bufcap *= 2;
+			buffer = repalloc(buffer, bufcap);
+		}
+
+		buffer[len++] = (char) c;
+
+		if (c == '\0')
+			break;
+	}
+
+	/* EOF reached unexpectedly */
+	if (c == EOF)
+	{
+		pfree(buffer);
+		return false;
+	}
+
+	/* Copy into DSA */
+	dp = dsa_allocate(inj_description_dsa, len);
+
+	memcpy(dsa_get_address(inj_description_dsa, dp), buffer, len);
+	entry->stats.description = dp;
+
+	pfree(buffer);
+	return true;
+}
+
+PG_FUNCTION_INFO_V1(injection_points_stats_description);
+Datum
+injection_points_stats_description(PG_FUNCTION_ARGS)
+{
+	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	PgStat_StatInjEntry *entry = pgstat_fetch_stat_injentry(name);
+
+	if (entry == NULL)
+		PG_RETURN_NULL();
+
+	if (DsaPointerIsValid(entry->description))
+	{
+		char	   *description = NULL;
+		bool		found;
+
+		if (!inj_description_dsa)
+			inj_description_dsa = GetNamedDSA("injection_points_description", &found);
+
+		if (inj_description_dsa)
+			description = dsa_get_address(inj_description_dsa, entry->description);
+
+		if (!description)
+			PG_RETURN_NULL();
+
+		PG_RETURN_TEXT_P(cstring_to_text(description));
+	}
+	else
+		PG_RETURN_NULL();
+}
+
 /*
  * SQL function returning the number of times an injection point
  * has been called.
diff --git a/src/test/modules/injection_points/injection_stats.h b/src/test/modules/injection_points/injection_stats.h
index ba310c52c7f..e50a256d91e 100644
--- a/src/test/modules/injection_points/injection_stats.h
+++ b/src/test/modules/injection_points/injection_stats.h
@@ -20,7 +20,8 @@ extern bool inj_stats_enabled;
 
 /* injection_stats.c */
 extern void pgstat_register_inj(void);
-extern void pgstat_create_inj(const char *name);
+extern void pgstat_create_inj(const char *name,
+							  const char *description);
 extern void pgstat_drop_inj(const char *name);
 extern void pgstat_report_inj(const char *name);
 
diff --git a/src/test/modules/injection_points/t/001_stats.pl b/src/test/modules/injection_points/t/001_stats.pl
index 47ab58d0e9b..ac3961320a1 100644
--- a/src/test/modules/injection_points/t/001_stats.pl
+++ b/src/test/modules/injection_points/t/001_stats.pl
@@ -94,6 +94,92 @@ $entrycount =
   $node->safe_psql('postgres', "SELECT injection_points_stats_count();");
 is($entrycount, '0', 'number of entries after drop via SQL function');
 
+# Crash to reset stats
+$node->stop('immediate');
+$node->start;
+
+# Test custom stats serialization/de-serialization callbacks
+$node->safe_psql('postgres',
+	"SELECT injection_points_attach('stats-notice1', 'notice',
+	'this description is for stats-notice1 injection point');");
+$node->safe_psql('postgres',
+	"SELECT injection_points_attach('stats-notice2', 'notice',
+	'this description is for stats-notice2 injection point');");
+$node->safe_psql('postgres',
+	"SELECT injection_points_attach('stats-notice3', 'notice');");
+$node->safe_psql('postgres',
+	"SELECT injection_points_attach('stats-notice4', 'notice',
+	'this description is for stats-notice4 injection point');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice1');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice1');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice2');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice2');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice3');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice3');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice4');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice4');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice4');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice4');");
+$numcalls = $node->safe_psql('postgres',
+	"SELECT injection_points_stats_numcalls('stats-notice1');");
+is($numcalls, '2', 'number of stats calls for stats-notice1');
+$numcalls = $node->safe_psql('postgres',
+	"SELECT injection_points_stats_numcalls('stats-notice2');");
+is($numcalls, '2', 'number of stats calls for stats-notice2');
+$numcalls = $node->safe_psql('postgres',
+	"SELECT injection_points_stats_numcalls('stats-notice3');");
+is($numcalls, '2', 'number of stats calls for stats-notice3');
+$numcalls = $node->safe_psql('postgres',
+	"SELECT injection_points_stats_numcalls('stats-notice4');");
+is($numcalls, '4', 'number of stats calls for stats-notice4');
+my $description = $node->safe_psql('postgres',
+	"SELECT injection_points_stats_description('stats-notice1');");
+is($description, 'this description is for stats-notice1 injection point',
+	'description of stats-notice1 perserved before clean restart');
+$description = $node->safe_psql('postgres',
+	"SELECT injection_points_stats_description('stats-notice2');");
+is($description, 'this description is for stats-notice2 injection point',
+		 'description of stats-notice2 perserved before clean restart');
+$description = $node->safe_psql('postgres',
+				"SELECT injection_points_stats_description('stats-notice3');");
+is($description, '',
+                 'description of stats-notice3 perserved before clean restart');
+$description = $node->safe_psql('postgres',
+								"SELECT injection_points_stats_description('stats-notice4');");
+is($description, 'this description is for stats-notice4 injection point',
+                 'description of stats-notice4 perserved before clean restart');
+# clean restart
+$node->restart;
+
+$numcalls = $node->safe_psql('postgres',
+	"SELECT injection_points_stats_numcalls('stats-notice1');");
+is($numcalls, '2', 'number of stats calls for stats-notice1');
+$numcalls = $node->safe_psql('postgres',
+	"SELECT injection_points_stats_numcalls('stats-notice2');");
+is($numcalls, '2', 'number of stats calls for stats-notice2');
+$numcalls = $node->safe_psql('postgres',
+	"SELECT injection_points_stats_numcalls('stats-notice3');");
+is($numcalls, '2', 'number of stats calls for stats-notice3');
+$numcalls = $node->safe_psql('postgres',
+	"SELECT injection_points_stats_numcalls('stats-notice4');");
+is($numcalls, '4', 'number of stats calls for stats-notice4');
+$description = $node->safe_psql('postgres',
+								"SELECT injection_points_stats_description('stats-notice1');");
+is($description, 'this description is for stats-notice1 injection point',
+				 'description of stats-notice1 perserved after clean restart');
+$description = $node->safe_psql('postgres',
+								"SELECT injection_points_stats_description('stats-notice2');");
+is($description, 'this description is for stats-notice2 injection point',
+                 'description of stats-notice2 perserved after clean restart');
+$description = $node->safe_psql('postgres',
+								"SELECT injection_points_stats_description('stats-notice3');");
+is($description, '',
+				 'description of stats-notice3 perserved after clean restart');
+$description = $node->safe_psql('postgres',
+								"SELECT injection_points_stats_description('stats-notice4');");
+is($description, 'this description is for stats-notice4 injection point',
+				 'description of stats-notice4 perserved after clean restart');
+
 # Stop the server, disable the module, then restart.  The server
 # should be able to come up.
 $node->stop;
-- 
2.43.0

0001-Add-callback-support-for-custom-statistics-extra-dat.patchapplication/octet-stream; name=0001-Add-callback-support-for-custom-statistics-extra-dat.patchDownload
From 5cd825f048c4104231d2ce94b87d3e7d5a9a1506 Mon Sep 17 00:00:00 2001
From: Ubuntu <ubuntu@ip-172-31-46-230.ec2.internal>
Date: Wed, 22 Oct 2025 18:45:43 +0000
Subject: [PATCH 1/2] Add callback support for custom statistics extra data
 serialization

Allow custom statistics kinds to serialize additional per-entry data
beyond the standard statistics entries. Custom kinds can register
to_serialized_extra and from_serialized_extra callbacks to write
and read extra data to separate files (pgstat.<kind>.stat).

The callbacks have access to the file pointer and the extension
that registers this custom statistic can write/read the data as it
understands it. The core pgstat infrastructure manages the files as
it does with pgstat.stat.

The callbacks are optional and only valid for custom,
variable-numbered statistics kinds. Using separate files keeps the
main statistics file format stable while enabling more complex
custom statistics.

This will allow extensions like pg_stat_statements to use custom
kinds and track additional data per entry such as query text, which
can be stored in a dsa_pointer. Using these callbacks, the
dsa_pointer can be serialized and deserialized across server
restarts.
---
 src/backend/utils/activity/pgstat.c | 391 +++++++++++++++++++++-------
 src/include/utils/pgstat_internal.h |  18 ++
 2 files changed, 313 insertions(+), 96 deletions(-)

diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index 7ef06150df7..d4a724d23bd 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -146,6 +146,21 @@
 #define PGSTAT_FILE_ENTRY_HASH	'S' /* stats entry identified by
 									 * PgStat_HashKey */
 
+/* ---------
+ * Limits for on-disk stats files.
+ *
+ * We maintain separate files for:
+ * - One built-in stats file (all standard PostgreSQL statistics)
+ * - One file per custom stats kind that uses extra serialization callbacks
+ *
+ * PGSTAT_BUILTIN_FILE is the array index for the built-in stats file.
+ * Custom stats files occupy indices 1 through PGSTAT_MAX_CUSTOM_FILES.
+ * ---------
+ */
+#define PGSTAT_MAX_CUSTOM_FILES PGSTAT_KIND_CUSTOM_MAX - PGSTAT_KIND_CUSTOM_MIN + 1
+#define PGSTAT_MAX_FILES PGSTAT_MAX_CUSTOM_FILES + 1
+#define PGSTAT_BUILTIN_FILE    PGSTAT_KIND_MIN - 1
+
 /* hash table for statistics snapshots entry */
 typedef struct PgStat_SnapshotEntry
 {
@@ -174,6 +189,16 @@ typedef struct PgStat_SnapshotEntry
 #define SH_DECLARE
 #include "lib/simplehash.h"
 
+/*
+ * Macros for custom stats file paths.
+ * Each custom stats kind with extra serialization data gets its own file
+ * in the format pgstat.<kind>.{tmp,stat}
+ */
+#define CUSTOM_TMPFILE_PATH(kind) \
+	psprintf(PGSTAT_STAT_PERMANENT_DIRECTORY "/pgstat.%d.tmp", (kind))
+
+#define CUSTOM_STATFILE_PATH(kind) \
+	psprintf(PGSTAT_STAT_PERMANENT_DIRECTORY "/pgstat.%d.stat", (kind))
 
 /* ----------
  * Local function forward declarations
@@ -1525,6 +1550,31 @@ pgstat_register_kind(PgStat_Kind kind, const PgStat_KindInfo *kind_info)
 					 errdetail("Existing cumulative statistics with ID %u has the same name.", existing_kind)));
 	}
 
+	/*
+	 * Ensure that both serialization and de-serialization callbacks are
+	 * either registered together or not at all, and only for custom,
+	 * variable-numbered stats.
+	 */
+	if ((kind_info->to_serialized_extra && !kind_info->from_serialized_extra) ||
+		(!kind_info->to_serialized_extra && kind_info->from_serialized_extra))
+	{
+		ereport(ERROR,
+				(errmsg("invalid custom statistics callbacks for \"%s\" (ID %u)",
+						kind_info->name, kind),
+				 errdetail("Both to_serialized_extra and from_serialized_extra "
+						   "must either be set or unset.")));
+	}
+
+	if (kind_info->to_serialized_extra != NULL &&
+		(!pgstat_is_kind_custom(kind) || kind_info->fixed_amount))
+	{
+		ereport(ERROR,
+				(errmsg("invalid custom statistics callbacks for \"%s\" (ID %u)",
+						kind_info->name, kind),
+				 errdetail("to_serialized_extra and from_serialized_extra callbacks are only allowed for "
+						   "custom, variable-numbered statistics kinds.")));
+	}
+
 	/* Register it */
 	pgstat_kind_custom_infos[idx] = kind_info;
 	ereport(LOG,
@@ -1551,9 +1601,65 @@ pgstat_assert_is_up(void)
  * ------------------------------------------------------------
  */
 
-/* helpers for pgstat_write_statsfile() */
+/* helpers for pgstat_read|write_statsfile() */
 static void
-write_chunk(FILE *fpout, void *ptr, size_t len)
+pgstat_close_files(FILE **fp, int nfiles)
+{
+	/*
+	 * Free all the allocated files.
+	 */
+	for (int i = 0; i < nfiles; i++)
+	{
+		if (fp[i])
+		{
+			FreeFile(fp[i]);
+			fp[i] = NULL;
+		}
+	}
+}
+
+static bool
+pgstat_get_filenames_for_kind(const PgStat_Kind kind,
+							  char **tmpfile, char **statfile)
+{
+	/*
+	 * If this is a custom kind that serializes extra data, or the built-in
+	 * pgstats file, return the appropriate name.
+	 */
+	if (kind >= PGSTAT_KIND_CUSTOM_MIN)
+	{
+		const PgStat_KindInfo *kind_info = pgstat_get_kind_info(kind);
+
+		if (!kind_info || !kind_info->from_serialized_extra)
+			return false;
+
+		*tmpfile = CUSTOM_TMPFILE_PATH(kind);
+		*statfile = CUSTOM_STATFILE_PATH(kind);
+		return true;
+	}
+	else
+	{
+		*tmpfile = pstrdup(PGSTAT_STAT_PERMANENT_TMPFILE);
+		*statfile = pstrdup(PGSTAT_STAT_PERMANENT_FILENAME);
+		return true;
+	}
+}
+
+static void
+pgstat_report_statfile_error(PgStat_Kind kind, const char *statfile)
+{
+	ereport(LOG,
+			(errmsg("corrupted statistics file \"%s\"", statfile)));
+
+	if (kind == PGSTAT_BUILTIN_FILE)
+		pgstat_reset_after_failure();
+	else
+		pgstat_reset_of_kind(kind);
+}
+
+/* helpers for pgstat_write_statsfile() */
+void
+pgstat_write_chunk(FILE *fpout, void *ptr, size_t len)
 {
 	int			rc;
 
@@ -1563,7 +1669,16 @@ write_chunk(FILE *fpout, void *ptr, size_t len)
 	(void) rc;
 }
 
-#define write_chunk_s(fpout, ptr) write_chunk(fpout, ptr, sizeof(*ptr))
+/* helpers for pgstat_read_statsfile() */
+static void
+pgstat_close_statfile(FILE *fp, const char *statfile)
+{
+	if (fp)
+		FreeFile(fp);
+
+	elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
+	unlink(statfile);
+}
 
 /*
  * This function is called in the last process that is accessing the shared
@@ -1572,10 +1687,8 @@ write_chunk(FILE *fpout, void *ptr, size_t len)
 static void
 pgstat_write_statsfile(void)
 {
-	FILE	   *fpout;
+	FILE	   *fpout[PGSTAT_MAX_FILES] = {NULL};
 	int32		format_id;
-	const char *tmpfile = PGSTAT_STAT_PERMANENT_TMPFILE;
-	const char *statfile = PGSTAT_STAT_PERMANENT_FILENAME;
 	dshash_seq_status hstat;
 	PgStatShared_HashEntry *ps;
 
@@ -1587,26 +1700,44 @@ pgstat_write_statsfile(void)
 	/* we're shutting down, so it's ok to just override this */
 	pgstat_fetch_consistency = PGSTAT_FETCH_CONSISTENCY_NONE;
 
-	elog(DEBUG2, "writing stats file \"%s\"", statfile);
-
 	/*
-	 * Open the statistics temp file to write out the current values.
+	 * Open temporary statistics files: one for built-in stats (idx=0), and
+	 * one for each custom kind with extra serialization callbacks.
 	 */
-	fpout = AllocateFile(tmpfile, PG_BINARY_W);
-	if (fpout == NULL)
+	for (int idx = 0; idx <= PGSTAT_MAX_CUSTOM_FILES; idx++)
 	{
-		ereport(LOG,
-				(errcode_for_file_access(),
-				 errmsg("could not open temporary statistics file \"%s\": %m",
-						tmpfile)));
-		return;
+		char	   *tmpfile;
+		char	   *statfile;
+
+		if (!pgstat_get_filenames_for_kind(PGSTAT_KIND_CUSTOM_MIN + idx - 1,
+										   &tmpfile, &statfile))
+			continue;
+
+		elog(DEBUG2, "writing stats file \"%s\"", statfile);
+
+		fpout[idx] = AllocateFile(tmpfile, PG_BINARY_W);
+
+		if (fpout[idx] == NULL)
+		{
+			pgstat_close_files(fpout, PGSTAT_MAX_FILES);
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not open temporary statistics file \"%s\": %m",
+							tmpfile)));
+			pfree(tmpfile);
+			pfree(statfile);
+			return;
+		}
+
+		pfree(tmpfile);
+		pfree(statfile);
 	}
 
 	/*
 	 * Write the file header --- currently just a format ID.
 	 */
 	format_id = PGSTAT_FILE_FORMAT_ID;
-	write_chunk_s(fpout, &format_id);
+	pgstat_write_chunk_s(fpout[PGSTAT_BUILTIN_FILE], &format_id);
 
 	/* Write various stats structs for fixed number of objects */
 	for (PgStat_Kind kind = PGSTAT_KIND_MIN; kind <= PGSTAT_KIND_MAX; kind++)
@@ -1630,9 +1761,9 @@ pgstat_write_statsfile(void)
 		else
 			ptr = pgStatLocal.snapshot.custom_data[kind - PGSTAT_KIND_CUSTOM_MIN];
 
-		fputc(PGSTAT_FILE_ENTRY_FIXED, fpout);
-		write_chunk_s(fpout, &kind);
-		write_chunk(fpout, ptr, info->shared_data_len);
+		fputc(PGSTAT_FILE_ENTRY_FIXED, fpout[PGSTAT_BUILTIN_FILE]);
+		pgstat_write_chunk_s(fpout[PGSTAT_BUILTIN_FILE], &kind);
+		pgstat_write_chunk(fpout[PGSTAT_BUILTIN_FILE], ptr, info->shared_data_len);
 	}
 
 	/*
@@ -1685,8 +1816,8 @@ pgstat_write_statsfile(void)
 		if (!kind_info->to_serialized_name)
 		{
 			/* normal stats entry, identified by PgStat_HashKey */
-			fputc(PGSTAT_FILE_ENTRY_HASH, fpout);
-			write_chunk_s(fpout, &ps->key);
+			fputc(PGSTAT_FILE_ENTRY_HASH, fpout[PGSTAT_BUILTIN_FILE]);
+			pgstat_write_chunk_s(fpout[PGSTAT_BUILTIN_FILE], &ps->key);
 		}
 		else
 		{
@@ -1695,58 +1826,80 @@ pgstat_write_statsfile(void)
 
 			kind_info->to_serialized_name(&ps->key, shstats, &name);
 
-			fputc(PGSTAT_FILE_ENTRY_NAME, fpout);
-			write_chunk_s(fpout, &ps->key.kind);
-			write_chunk_s(fpout, &name);
+			fputc(PGSTAT_FILE_ENTRY_NAME, fpout[PGSTAT_BUILTIN_FILE]);
+			pgstat_write_chunk_s(fpout[PGSTAT_BUILTIN_FILE], &ps->key.kind);
+			pgstat_write_chunk_s(fpout[PGSTAT_BUILTIN_FILE], &name);
 		}
 
 		/* Write except the header part of the entry */
-		write_chunk(fpout,
-					pgstat_get_entry_data(ps->key.kind, shstats),
-					pgstat_get_entry_len(ps->key.kind));
+		pgstat_write_chunk(fpout[PGSTAT_BUILTIN_FILE],
+						   pgstat_get_entry_data(ps->key.kind, shstats),
+						   pgstat_get_entry_len(ps->key.kind));
+
+		/* A plug-in is saving extra data */
+		if (kind_info->to_serialized_extra)
+		{
+			FILE	   *fp = fpout[(ps->key.kind - PGSTAT_KIND_CUSTOM_MIN) + 1];
+
+			Assert(fp);
+
+			kind_info->to_serialized_extra(&ps->key, shstats, fp);
+		}
 	}
 	dshash_seq_term(&hstat);
 
 	/*
 	 * No more output to be done. Close the temp file and replace the old
-	 * pgstat.stat with it.  The ferror() check replaces testing for error
-	 * after each individual fputc or fwrite (in write_chunk()) above.
+	 * pgstat.stat or custom stats file with it. The ferror() check replaces
+	 * testing for errors after each individual fputc or fwrite (in
+	 * pgstat_write_chunk()) above.
 	 */
-	fputc(PGSTAT_FILE_ENTRY_END, fpout);
+	fputc(PGSTAT_FILE_ENTRY_END, fpout[PGSTAT_BUILTIN_FILE]);
 
-	if (ferror(fpout))
+	for (int idx = 0; idx <= PGSTAT_MAX_CUSTOM_FILES; idx++)
 	{
-		ereport(LOG,
-				(errcode_for_file_access(),
-				 errmsg("could not write temporary statistics file \"%s\": %m",
-						tmpfile)));
-		FreeFile(fpout);
-		unlink(tmpfile);
-	}
-	else if (FreeFile(fpout) < 0)
-	{
-		ereport(LOG,
-				(errcode_for_file_access(),
-				 errmsg("could not close temporary statistics file \"%s\": %m",
-						tmpfile)));
-		unlink(tmpfile);
-	}
-	else if (durable_rename(tmpfile, statfile, LOG) < 0)
-	{
-		/* durable_rename already emitted log message */
-		unlink(tmpfile);
+		char	   *tmpfile;
+		char	   *statfile;
+
+		if (!pgstat_get_filenames_for_kind(PGSTAT_KIND_CUSTOM_MIN + idx - 1,
+										   &tmpfile, &statfile))
+			continue;
+
+		if (ferror(fpout[idx]))
+		{
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not write temporary statistics file \"%s\": %m",
+							tmpfile)));
+			FreeFile(fpout[idx]);
+			unlink(tmpfile);
+		}
+		else if (FreeFile(fpout[idx]) < 0)
+		{
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not close temporary statistics file \"%s\": %m",
+							tmpfile)));
+			unlink(tmpfile);
+		}
+		else if (durable_rename(tmpfile, statfile, LOG) < 0)
+		{
+			/* durable_rename already emitted log message */
+			unlink(tmpfile);
+		}
+
+		pfree(tmpfile);
+		pfree(statfile);
 	}
 }
 
 /* helpers for pgstat_read_statsfile() */
-static bool
-read_chunk(FILE *fpin, void *ptr, size_t len)
+bool
+pgstat_read_chunk(FILE *fpin, void *ptr, size_t len)
 {
 	return fread(ptr, 1, len, fpin) == len;
 }
 
-#define read_chunk_s(fpin, ptr) read_chunk(fpin, ptr, sizeof(*ptr))
-
 /*
  * Reads in existing statistics file into memory.
  *
@@ -1756,7 +1909,7 @@ read_chunk(FILE *fpin, void *ptr, size_t len)
 static void
 pgstat_read_statsfile(void)
 {
-	FILE	   *fpin;
+	FILE	   *fpin[PGSTAT_MAX_FILES] = {NULL};
 	int32		format_id;
 	bool		found;
 	const char *statfile = PGSTAT_STAT_PERMANENT_FILENAME;
@@ -1765,32 +1918,48 @@ pgstat_read_statsfile(void)
 	/* shouldn't be called from postmaster */
 	Assert(IsUnderPostmaster || !IsPostmasterEnvironment);
 
-	elog(DEBUG2, "reading stats file \"%s\"", statfile);
-
-	/*
-	 * Try to open the stats file. If it doesn't exist, the backends simply
-	 * returns zero for anything and statistics simply starts from scratch
-	 * with empty counters.
-	 *
-	 * ENOENT is a possibility if stats collection was previously disabled or
-	 * has not yet written the stats file for the first time.  Any other
-	 * failure condition is suspicious.
-	 */
-	if ((fpin = AllocateFile(statfile, PG_BINARY_R)) == NULL)
+	for (int idx = 0; idx <= PGSTAT_MAX_CUSTOM_FILES; idx++)
 	{
-		if (errno != ENOENT)
-			ereport(LOG,
-					(errcode_for_file_access(),
-					 errmsg("could not open statistics file \"%s\": %m",
-							statfile)));
-		pgstat_reset_after_failure();
-		return;
+		char	   *tmpfile;
+		char	   *statfile;
+
+		if (!pgstat_get_filenames_for_kind(PGSTAT_KIND_CUSTOM_MIN + idx - 1,
+										   &tmpfile, &statfile))
+			continue;
+
+		elog(DEBUG2, "reading stats file \"%s\"", statfile);
+
+		/*
+		 * Try to open the stats file. If it doesn't exist, the backends
+		 * simply returns zero for anything and statistics simply starts from
+		 * scratch with empty counters.
+		 *
+		 * ENOENT is a possibility if stats collection was previously disabled
+		 * or has not yet written the stats file for the first time.  Any
+		 * other failure condition is suspicious.
+		 */
+		if ((fpin[idx] = AllocateFile(statfile, PG_BINARY_R)) == NULL)
+		{
+			pgstat_close_files(fpin, PGSTAT_MAX_FILES);
+			if (errno != ENOENT)
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not open statistics file \"%s\": %m",
+								statfile)));
+			pgstat_reset_after_failure();
+			pfree(tmpfile);
+			pfree(statfile);
+			return;
+		}
+
+		pfree(tmpfile);
+		pfree(statfile);
 	}
 
 	/*
 	 * Verify it's of the expected format.
 	 */
-	if (!read_chunk_s(fpin, &format_id))
+	if (!pgstat_read_chunk_s(fpin[PGSTAT_BUILTIN_FILE], &format_id))
 	{
 		elog(WARNING, "could not read format ID");
 		goto error;
@@ -1809,7 +1978,7 @@ pgstat_read_statsfile(void)
 	 */
 	for (;;)
 	{
-		int			t = fgetc(fpin);
+		int			t = fgetc(fpin[PGSTAT_BUILTIN_FILE]);
 
 		switch (t)
 		{
@@ -1820,7 +1989,7 @@ pgstat_read_statsfile(void)
 					char	   *ptr;
 
 					/* entry for fixed-numbered stats */
-					if (!read_chunk_s(fpin, &kind))
+					if (!pgstat_read_chunk_s(fpin[PGSTAT_BUILTIN_FILE], &kind))
 					{
 						elog(WARNING, "could not read stats kind for entry of type %c", t);
 						goto error;
@@ -1860,7 +2029,7 @@ pgstat_read_statsfile(void)
 							info->shared_data_off;
 					}
 
-					if (!read_chunk(fpin, ptr, info->shared_data_len))
+					if (!pgstat_read_chunk(fpin[PGSTAT_BUILTIN_FILE], ptr, info->shared_data_len))
 					{
 						elog(WARNING, "could not read data of stats kind %u for entry of type %c with size %u",
 							 kind, t, info->shared_data_len);
@@ -1875,13 +2044,14 @@ pgstat_read_statsfile(void)
 					PgStat_HashKey key;
 					PgStatShared_HashEntry *p;
 					PgStatShared_Common *header;
+					const PgStat_KindInfo *kind_info = NULL;
 
 					CHECK_FOR_INTERRUPTS();
 
 					if (t == PGSTAT_FILE_ENTRY_HASH)
 					{
 						/* normal stats entry, identified by PgStat_HashKey */
-						if (!read_chunk_s(fpin, &key))
+						if (!pgstat_read_chunk_s(fpin[PGSTAT_BUILTIN_FILE], &key))
 						{
 							elog(WARNING, "could not read key for entry of type %c", t);
 							goto error;
@@ -1895,7 +2065,8 @@ pgstat_read_statsfile(void)
 							goto error;
 						}
 
-						if (!pgstat_get_kind_info(key.kind))
+						kind_info = pgstat_get_kind_info(key.kind);
+						if (!kind_info)
 						{
 							elog(WARNING, "could not find information of kind for entry %u/%u/%" PRIu64 " of type %c",
 								 key.kind, key.dboid,
@@ -1906,16 +2077,15 @@ pgstat_read_statsfile(void)
 					else
 					{
 						/* stats entry identified by name on disk (e.g. slots) */
-						const PgStat_KindInfo *kind_info = NULL;
 						PgStat_Kind kind;
 						NameData	name;
 
-						if (!read_chunk_s(fpin, &kind))
+						if (!pgstat_read_chunk_s(fpin[PGSTAT_BUILTIN_FILE], &kind))
 						{
 							elog(WARNING, "could not read stats kind for entry of type %c", t);
 							goto error;
 						}
-						if (!read_chunk_s(fpin, &name))
+						if (!pgstat_read_chunk_s(fpin[PGSTAT_BUILTIN_FILE], &name))
 						{
 							elog(WARNING, "could not read name of stats kind %u for entry of type %c",
 								 kind, t);
@@ -1946,7 +2116,7 @@ pgstat_read_statsfile(void)
 						if (!kind_info->from_serialized_name(&name, &key))
 						{
 							/* skip over data for entry we don't care about */
-							if (fseek(fpin, pgstat_get_entry_len(kind), SEEK_CUR) != 0)
+							if (fseek(fpin[PGSTAT_BUILTIN_FILE], pgstat_get_entry_len(kind), SEEK_CUR) != 0)
 							{
 								elog(WARNING, "could not seek \"%s\" of stats kind %u for entry of type %c",
 									 NameStr(name), kind, t);
@@ -1990,9 +2160,9 @@ pgstat_read_statsfile(void)
 							 key.objid, t);
 					}
 
-					if (!read_chunk(fpin,
-									pgstat_get_entry_data(key.kind, header),
-									pgstat_get_entry_len(key.kind)))
+					if (!pgstat_read_chunk(fpin[PGSTAT_BUILTIN_FILE],
+										   pgstat_get_entry_data(key.kind, header),
+										   pgstat_get_entry_len(key.kind)))
 					{
 						elog(WARNING, "could not read data for entry %u/%u/%" PRIu64 " of type %c",
 							 key.kind, key.dboid,
@@ -2000,6 +2170,28 @@ pgstat_read_statsfile(void)
 						goto error;
 					}
 
+					/*
+					 * A plug-in is reading extra data. If the plug-in fails
+					 * to read the file, close the file, report the error, and
+					 * move on.
+					 */
+					if (kind_info->from_serialized_extra)
+					{
+						FILE	   *fp = fpin[(key.kind - PGSTAT_KIND_CUSTOM_MIN) + 1];
+
+						Assert(fp);
+
+						if (!kind_info->from_serialized_extra(&key, header, fp))
+						{
+							char	   *statfile = CUSTOM_STATFILE_PATH(key.kind);
+
+							pgstat_report_statfile_error(key.kind, statfile);
+
+							fpin[(key.kind - PGSTAT_KIND_CUSTOM_MIN) + 1] = NULL;
+							pfree(statfile);
+						}
+					}
+
 					break;
 				}
 			case PGSTAT_FILE_ENTRY_END:
@@ -2008,7 +2200,7 @@ pgstat_read_statsfile(void)
 				 * check that PGSTAT_FILE_ENTRY_END actually signals end of
 				 * file
 				 */
-				if (fgetc(fpin) != EOF)
+				if (fgetc(fpin[PGSTAT_BUILTIN_FILE]) != EOF)
 				{
 					elog(WARNING, "could not read end-of-file");
 					goto error;
@@ -2023,18 +2215,25 @@ pgstat_read_statsfile(void)
 	}
 
 done:
-	FreeFile(fpin);
+	for (int idx = 0; idx <= PGSTAT_MAX_CUSTOM_FILES; idx++)
+	{
+		char	   *tmpfile;
+		char	   *statfile;
 
-	elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
-	unlink(statfile);
+		if (!pgstat_get_filenames_for_kind(PGSTAT_KIND_CUSTOM_MIN + idx - 1,
+										   &tmpfile, &statfile))
+			continue;
+
+		pgstat_close_statfile(fpin[idx], statfile);
+
+		pfree(tmpfile);
+		pfree(statfile);
+	}
 
 	return;
 
 error:
-	ereport(LOG,
-			(errmsg("corrupted statistics file \"%s\"", statfile)));
-
-	pgstat_reset_after_failure();
+	pgstat_report_statfile_error(PGSTAT_BUILTIN_FILE, statfile);
 
 	goto done;
 }
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index 4d2b8aa6081..c317bf883a0 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -303,6 +303,15 @@ typedef struct PgStat_KindInfo
 									   const PgStatShared_Common *header, NameData *name);
 	bool		(*from_serialized_name) (const NameData *name, PgStat_HashKey *key);
 
+	/*
+	 * For custom, variable-numbered stats, serialize/de-serialize extra data
+	 * per entry. Optional.
+	 */
+	bool		(*from_serialized_extra) (PgStat_HashKey *key,
+										  const PgStatShared_Common *header, FILE *fd);
+	void		(*to_serialized_extra) (const PgStat_HashKey *key,
+										const PgStatShared_Common *header, FILE *fd);
+
 	/*
 	 * For fixed-numbered statistics: Initialize shared memory state.
 	 *
@@ -984,4 +993,13 @@ pgstat_get_custom_snapshot_data(PgStat_Kind kind)
 	return pgStatLocal.snapshot.custom_data[idx];
 }
 
+/*
+ * Helpers for reading/writing stats files.
+ */
+extern void pgstat_write_chunk(FILE *fpout, void *ptr, size_t len);
+extern bool pgstat_read_chunk(FILE *fpin, void *ptr, size_t len);
+
+#define pgstat_write_chunk_s(fpout, ptr) pgstat_write_chunk(fpout, ptr, sizeof(*ptr))
+#define pgstat_read_chunk_s(fpin, ptr) pgstat_read_chunk(fpin, ptr, sizeof(*ptr))
+
 #endif							/* PGSTAT_INTERNAL_H */
-- 
2.43.0

#2Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#1)
Re: [Proposal] Adding callback support for custom statistics kinds

On Wed, Oct 22, 2025 at 03:24:11PM -0500, Sami Imseih wrote:

I'd like to propose $SUBJECT to serialize additional per-entry data beyond
the standard statistics entries. Currently, custom statistics kinds can store
their standard entry data in the main "pgstat.stat" file, but there is no
mechanism for extensions to persist extra data stored in the entry. A common
use case is extensions that register a custom kind and, besides
standard counters,
need to track variable-length data stored in a dsa_pointer.

Thanks for sending a proposal in this direction.

A concrete use case is pg_stat_statements. If it were to use custom
stats kinds to track statement counters, it could also track query text
stored in DSA. The callbacks allow saving the query text referenced by the
dsa_pointer and restoring it after a clean shutdown. Since DSA
(and more specifically DSM) cannot be attached by the postmaster, an
extension cannot use "on_shmem_exit" or "shmem_startup_hook"
to serialize or restore this data. This is why pgstat handles
serialization during checkpointer shutdown and startup, allowing a single
backend to manage it safely.

Agreed that it would be better to split the query text in a file of
its own and now bloat the "main" pgstats file with this data, A real
risk is that many PGSS entries with a bunch of queries would cause the
file to be just full of the PGSS contents.

I considered adding hooks to the existing pgstat code paths
(pgstat_before_server_shutdown, pgstat_discard_stats, and
pgstat_restore_stats), but that felt too unrestricted. Using per-kind
callbacks provides more control.

Per-kind callbacks to control all that makes sense here.

There are already "to_serialized_name" and "from_serialized_name"
callbacks used to store and read entries by "name" instead of
"PgStat_HashKey", currently used by replication slot stats. Those
remain unchanged, as they serve a separate purpose.

Other design points:

1. Filenames use "pgstat.<kind>.stat" based on the numeric kind ID.
This avoids requiring extensions to provide names and prevents issues
with spaces or special characters.

Hmm. Is that really what we want here? This pretty says that one
single custom kind would never be able use multiple files, ever.

2. Both callbacks must be registered together. Serializing without
deserializing would leave orphaned files behind, and I cannot think of a
reason to allow this.

Hmm. Okay.

3. "write_chunk", "read_chunk", "write_chunk_s", and
"read_chunk_s" are renamed to "pgstat_write_chunk", etc., and
moved to "pgstat_internal.h" so extensions can use them without
re-implementing these functions.

Exposing the write and read chunk APIs and renaming them sounds good
here, designed as they are now with a FILE* defined by the caller.
It's good to share these for consistency across custom and built-in
stats kinds.

4. These callbacks are valid only for custom, variable-numbered statistics
kinds. Custom fixed kinds may not benefit, but could be considered in the
future.

Pushing custom data for fixed-sized stats may be interesting, though
like you I am not sure what a good use-case would look like. So
discarding this case for now sounds fine to me.

Attached 0001 is the proposed change, still in POC form.

Hmm. I would like to propose something a bit more flexible,
refactoring and reusing some of the existing callbacks, among the
following lines:
- Rather than introducing a second callback able to do more
serialization work, let's expand a bit the responsibility of
to_serialized_name and from_serialized_name to be able to work in a
more extended way, renaming them to "to/from_serialized_entry", which
are now limited to return a NameData with pgstat.c enforcing the data
written to the pgstats to be of NAMEDATALEN. The idea would be to let
the callbacks push some custom data where they want.
- The to_serialized_name path of pgstat_write_statsfile() would then
be changed as follows:
-- push a PGSTAT_FILE_ENTRY_NAME
-- Write the key write_chunk_s.
-- Call the callback to push some custom per-entry data.
-- Finish with the main chunk of data, of size pgstat_get_entry_len().
- The fd or FILE* of the "main" pgstats file should be added as
argument of both routines (not mandatory, but we are likely going to
need that if we want to add more "custom" data in the main pgstats
file before writing or reading a chunk). For example, for a PGSS text
file, we would likely write two fields to the main data file: an
offset and a length to be able to retrieve a query string, from a
secondary file.
- FDs where the data is written while we are in the to/from serialize
can be handled within the code paths specific to the stats kind code.
The first time a serialized callback of a stats kind is called, the
extra file(s) is(are) opened. This may come at the cost of one new
callback: at the end of the read and writes of the stats data, we
would need an extra look that's able to perform cleanup actions, which
would be here to make sure that the fds opened for the extra files are
closed when we are done. The close of each file is equivalent to the
pgstat_close_file() done in the patch, except that we'd loop over a
callback that would do the cleanup job once we are done reading or
writing a file. One step that can be customized in this new "end"
callback is if a stats kind may decide to unlink() a previous file, as
we do for the main pgstats file, or keep one or more files around.
That would be up to the extension developer. We should be able to
reuse or rework reset_all_cb() with a status given to it, depending on
if we are dealing with a failure or a success path. Currently,
reset_all_cb() is only used in a failure path, the idea would be to
extend it for the success case.

The second patch
contains tests in "injection_points" to demonstrate this proposal, and is not
necessarily intended for commit.

Having coverage for these kinds of APIs is always good, IMO. We need
coverage for extension code.
--
Michael

#3Sami Imseih
samimseih@gmail.com
In reply to: Michael Paquier (#2)
Re: [Proposal] Adding callback support for custom statistics kinds

Thanks for the feedback!

Other design points:

1. Filenames use "pgstat.<kind>.stat" based on the numeric kind ID.
This avoids requiring extensions to provide names and prevents issues
with spaces or special characters.

Hmm. Is that really what we want here? This pretty says that one
single custom kind would never be able use multiple files, ever.

Perhaps if someone wants to have separate files for each different
types of data,
we should be able to support multiple files. I think we can add an
option for the
number of files and they can then be named "pgstat.<kind>.1.stat",
pgstat.<kind>.2.stat",
etc. I rather avoid having the extension provide a set of files names.
So as arguments to the callback, besides the main file pointer ( as
you mention below),
we also provide the list of custom file pointers.

what do you think?

Hmm. I would like to propose something a bit more flexible,
refactoring and reusing some of the existing callbacks, among the
following lines:
- Rather than introducing a second callback able to do more
serialization work, let's expand a bit the responsibility of
to_serialized_name and from_serialized_name to be able to work in a
more extended way, renaming them to "to/from_serialized_entry", which

Sure, we can go that route.

- The fd or FILE* of the "main" pgstats file should be added as
argument of both routines (not mandatory, but we are likely going to
need that if we want to add more "custom" data in the main pgstats
file before writing or reading a chunk). For example, for a PGSS text
file, we would likely write two fields to the main data file: an
offset and a length to be able to retrieve a query string, from a
secondary file.

Yeah, that could be a good idea for pg_s_s, if we don't want to store the key
alongside the query text. Make more sense.

- FDs where the data is written while we are in the to/from serialize
can be handled within the code paths specific to the stats kind code.
The first time a serialized callback of a stats kind is called, the
extra file(s) is(are) opened. This may come at the cost of one new
callback: at the end of the read and writes of the stats data, we
would need an extra look that's able to perform cleanup actions, which
would be here to make sure that the fds opened for the extra files are
closed when we are done. The close of each file is equivalent to the
pgstat_close_file() done in the patch, except that we'd loop over a
callback that would do the cleanup job once we are done reading or
writing a file. One step that can be customized in this new "end"
callback is if a stats kind may decide to unlink() a previous file, as
we do for the main pgstats file, or keep one or more files around.
That would be up to the extension developer. We should be able to
reuse or rework reset_all_cb() with a status given to it, depending on
if we are dealing with a failure or a success path. Currently,
reset_all_cb() is only used in a failure path, the idea would be to
extend it for the success case.

I will provide a patch with the recommendations.

--
Sami

#4Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#3)
Re: [Proposal] Adding callback support for custom statistics kinds

On Thu, Oct 23, 2025 at 04:35:58PM -0500, Sami Imseih wrote:

Perhaps if someone wants to have separate files for each different
types of data,
we should be able to support multiple files. I think we can add an
option for the
number of files and they can then be named "pgstat.<kind>.1.stat",
pgstat.<kind>.2.stat",
etc. I rather avoid having the extension provide a set of files names.
So as arguments to the callback, besides the main file pointer ( as
you mention below),
we also provide the list of custom file pointers.

what do you think?

My worry here is the lack of flexibility regarding stats that could be
split depending on the objects whose data needs to be flushed. For
example, stats split across multiple databases (like our good-old
pre-v14 pgstats, but on a per-kind basis). So I don't think that we
can really assume that the list of file names should be fixed when we
begin the read/write process of the main pgstats file.
--
Michael

#5Sami Imseih
samimseih@gmail.com
In reply to: Michael Paquier (#4)
Re: [Proposal] Adding callback support for custom statistics kinds

On Thu, Oct 23, 2025 at 04:35:58PM -0500, Sami Imseih wrote:

Perhaps if someone wants to have separate files for each different
types of data,
we should be able to support multiple files. I think we can add an
option for the
number of files and they can then be named "pgstat.<kind>.1.stat",
pgstat.<kind>.2.stat",
etc. I rather avoid having the extension provide a set of files names.
So as arguments to the callback, besides the main file pointer ( as
you mention below),
we also provide the list of custom file pointers.

what do you think?

My worry here is the lack of flexibility regarding stats that could be
split depending on the objects whose data needs to be flushed. For
example, stats split across multiple databases (like our good-old
pre-v14 pgstats, but on a per-kind basis). So I don't think that we
can really assume that the list of file names should be fixed when we
begin the read/write process of the main pgstats file.

I was trying to avoid an extra field in PgStat_KindInfo if possible, but
it's worthwhile to provide more flexibility to an extension. I will go
with this.

--
Sami Imseih
Amazon Web Services (AWS)

#6Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#5)
Re: [Proposal] Adding callback support for custom statistics kinds

On Thu, Oct 23, 2025 at 07:57:38PM -0500, Sami Imseih wrote:

I was trying to avoid an extra field in PgStat_KindInfo if possible, but
it's worthwhile to provide more flexibility to an extension. I will go
with this.

Yes, I don't think that we will be able to avoid some refactoring of
the existing callbacks. The introduction of a new one may not be
completely necessary, though, especially if we reuse the reset
callback to be called when the stats read and write finish to close
any fds we may have opened when processing.

Maintaining the state of the files opened within each stat kind code
across multiple calls of the new "serialized" callback feels a bit
more natural and more flexible, at least it's my take on the matter.
--
Michael

#7Sami Imseih
samimseih@gmail.com
In reply to: Sami Imseih (#3)
Re: [Proposal] Adding callback support for custom statistics kinds

Hmm. I would like to propose something a bit more flexible,
refactoring and reusing some of the existing callbacks, among the
following lines:
- Rather than introducing a second callback able to do more
serialization work, let's expand a bit the responsibility of
to_serialized_name and from_serialized_name to be able to work in a
more extended way, renaming them to "to/from_serialized_entry", which

Sure, we can go that route.

I started reworking the patch, but then I realized that I don't like this
approach of using the same callback to support serializing NameData and
serializing extra data. In the existing "to_serialized_name" callback
, NameData is serialized instead of the hash key, meaning that the
"from_serialized_name" must be called before we create an entry. The
callback translates the NameData to an objid as is the case with replication
slots, and the key is then used to create the entry.

However, in the case of serializing extra data, we want to have already
created the entry by the time we call the callback. For example populating
non-key fields of an entry with a dsa_pointer after reading some serialized
data into dsa.

If we do want to support a single callback, we would need extra metadata in
the Kind registration to let the extension tell us what the callback is used
for and to either trigger the callback before or after entry creation. I am
not very thrilled about doing something like this, as I see 2 very different
use-cases here.

What do you think?

--
Sami Imseih
Amazon Web Services (AWS)

#8Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#7)
Re: [Proposal] Adding callback support for custom statistics kinds

On Mon, Nov 10, 2025 at 01:56:23PM -0600, Sami Imseih wrote:

I started reworking the patch, but then I realized that I don't like this
approach of using the same callback to support serializing NameData and
serializing extra data. In the existing "to_serialized_name" callback
, NameData is serialized instead of the hash key, meaning that the
"from_serialized_name" must be called before we create an entry. The
callback translates the NameData to an objid as is the case with replication
slots, and the key is then used to create the entry.

Thanks for looking at that.

However, in the case of serializing extra data, we want to have already
created the entry by the time we call the callback. For example populating
non-key fields of an entry with a dsa_pointer after reading some serialized
data into dsa.

If we do want to support a single callback, we would need extra metadata in
the Kind registration to let the extension tell us what the callback is used
for and to either trigger the callback before or after entry creation. I am
not very thrilled about doing something like this, as I see 2 very different
use-cases here.

Ah, I see your point. By keeping two callbacks, one to translate a
key to/from a different field (NameData currently, but it could be
something else with a different size), we would for example be able to
keep very simple the checks for duplicated entries when reading the
file. Agreed that it would be good to keep the key lookups as stable
as we can.

So, what you are suggested is a second callback once we have called
read_chunk() and write_chunk() for a PGSTAT_FILE_ENTRY_HASH or a
PGSTAT_FILE_ENTRY_NAME and let a stats kind write in the main file
and/or one or more extra files the data they want? I'd be fine with
that, yes, and that should work with the PGSS case in mind.
--
Michael

#9Sami Imseih
samimseih@gmail.com
In reply to: Michael Paquier (#8)
2 attachment(s)
Re: [Proposal] Adding callback support for custom statistics kinds

Sorry for the delay here.

v1 is the first attempt to address the feedback from the POC.

1/ A user is now able to register as many extra files as they
wish, and the files will be named pgstat.<kind_id>.<file no>.stat,
where file_no starts at 0 up to the number of files specified
by the user with .num_serialized_extra_files.

2/ The callbacks now provide both the core stat file as a FILE
pointer and an array of FILE pointers for the extra files.
IN the write callback, the extra file pointer is accessed
like extra_files[0], extra_files[1], etc., and the same for
the read callback.

3/ The patch centralizes the creation and cleanup of the files
with 2 new routines pgstat_allocate_files and pgstat_cleanup_files,
which both operate on a new local struct which tracks the file
names and descriptors in the read and write stats routines.

```
typedef struct PgStat_SerializeFiles
{
char **tmpfiles;
char **statfiles;
FILE **fd;
int num_files;
} PgStat_SerializeFiles;
```

plug-ins are not made aware of this struct because they don't need
to. The callbacks are provided the FILE pointers they need to care
about for their kind only.

4/ In terms of testing, patch 0002, I did not want to invent a new module
for custom kinds, so I piggybacked off og injection_points as I did in the
POC, but I added on the existing recovery tests, because essentially that
is what we care. Does the data persist after a clean shutdown? do the
.tmp files get removed properly? etc. So I added tests in
recovery/t/029_stats_restart.pl for this.

--
Sami Imseih
Amazon Web Services (AWS)

Attachments:

v1-0001-pgstat-support-custom-serialization-files-and-cal.patchapplication/octet-stream; name=v1-0001-pgstat-support-custom-serialization-files-and-cal.patchDownload
From 9dbb19a857862168d6168377389dd86a595962c9 Mon Sep 17 00:00:00 2001
From: Sami Imseih <simseih@amazon.com>
Date: Mon, 10 Nov 2025 00:03:41 -0600
Subject: [PATCH v1 1/2] pgstat: support custom serialization files and
 callbacks

Allow custom statistics kinds to serialize and deserialize extra
per-entry data, supporting kinds with variable auxiliary data that
cannot fit in the fixed shared-memory layout.

Add optional to_serialized_extra and from_serialized_extra callbacks
to PgStat_KindInfo, along with num_serialized_extra_files. Both
callbacks must be provided together, and num_serialized_extra_files
must be > 0.

The to_serialized_extra callback writes extra data to disk, and
from_serialized_extra reads it back into the entry. The callbacks
receive the entry key, header, core stats file descriptor, and a list
of extra file descriptors, up to the number specified by
num_serialized_extra_files.

Introduce PgStat_SerializeFiles to track temporary and permanent file
paths and descriptors. pgstat_allocate_files opens all files for read
or write, and pgstat_cleanup_files centralizes cleanup and unlinking.

Rename write_chunk/read_chunk helpers to pgstat_write_chunk and
pgstat_read_chunk, along with the *_s convenience macros, and make
these routines globally available for use by plug-ins.
---
 src/backend/utils/activity/pgstat.c | 412 +++++++++++++++++++++-------
 src/include/utils/pgstat_internal.h |  21 ++
 2 files changed, 340 insertions(+), 93 deletions(-)

diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index 7ef06150df7..a5d0a475db0 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -154,6 +154,26 @@ typedef struct PgStat_SnapshotEntry
 	void	   *data;			/* the stats data itself */
 } PgStat_SnapshotEntry;
 
+/*
+ * Struct used to track the set of serialization files managed by the
+ * pgstat infrastructure. This includes both the core statistics file and
+ * any additional files defined by the stats kind. The core file must be
+ * listed at index 0.
+ *
+ * The structure maintains three parallel lists: temporary file paths,
+ * statistics file paths, and their corresponding file descriptors.
+ *
+ * Custom callbacks are provided the list of file desciptors that
+ * belong to their stats kind.
+ */
+typedef struct PgStat_SerializeFiles
+{
+	char	  **tmpfiles;
+	char	  **statfiles;
+	FILE	  **fd;
+	int			num_files;
+}			PgStat_SerializeFiles;
+
 
 /* ----------
  * Backend-local Hash Table Definitions
@@ -1525,6 +1545,34 @@ pgstat_register_kind(PgStat_Kind kind, const PgStat_KindInfo *kind_info)
 					 errdetail("Existing cumulative statistics with ID %u has the same name.", existing_kind)));
 	}
 
+	/*
+	 * Ensure that both serialization and deserialization callbacks are
+	 * registered together or not at all, and only for custom stats with a
+	 * variable number of extra files.
+	 */
+	if ((kind_info->to_serialized_extra && !kind_info->from_serialized_extra) ||
+		(!kind_info->to_serialized_extra && kind_info->from_serialized_extra))
+	{
+		ereport(ERROR,
+				(errmsg("could not register custom cumulative statistics \"%s\" with ID %u",
+						kind_info->name, kind),
+				 errdetail("Both to_serialized_extra and from_serialized_extra must be provided.")));
+
+	}
+
+	/*
+	 * If extra serialization callbacks are set, num_serialized_extra_files
+	 * must be greater than 0.
+	 */
+	if (kind_info->to_serialized_extra &&
+		kind_info->num_serialized_extra_files < 1)
+	{
+		ereport(ERROR,
+				(errmsg("could not register custom cumulative statistics \"%s\" with ID %u",
+						kind_info->name, kind),
+				 errdetail("Extra serialization callbacks were specified, but num_serialized_extra_files is zero")));
+	}
+
 	/* Register it */
 	pgstat_kind_custom_infos[idx] = kind_info;
 	ereport(LOG,
@@ -1552,8 +1600,8 @@ pgstat_assert_is_up(void)
  */
 
 /* helpers for pgstat_write_statsfile() */
-static void
-write_chunk(FILE *fpout, void *ptr, size_t len)
+void
+pgstat_write_chunk(FILE *fpout, void *ptr, size_t len)
 {
 	int			rc;
 
@@ -1563,7 +1611,178 @@ write_chunk(FILE *fpout, void *ptr, size_t len)
 	(void) rc;
 }
 
-#define write_chunk_s(fpout, ptr) write_chunk(fpout, ptr, sizeof(*ptr))
+/* helpers for pgstat_read_statsfile() */
+bool
+pgstat_read_chunk(FILE *fpin, void *ptr, size_t len)
+{
+	return fread(ptr, 1, len, fpin) == len;
+}
+
+/*
+ * Close and remove all files recorded in a PgStat_SerializeFiles array.
+ * Depending on the is_temporary flag, this will remove either the temporary
+ * or the permanent filenames associated with each file descriptor.
+ *
+ * Errors encountered while closing files are logged, but cleanup continues
+ * for all remaining files. This function assumes the structure passed in
+ * was allocated by pgstat_allocate_files() and contains valid file descriptors
+ * for each opened stats file.
+ *
+ * NB: The array includes an extra slot at files[PGSTAT_KIND_MIN-1] for the
+ * core stats file. The loop below iterates from 0 to PGSTAT_KIND_CUSTOM_SIZE,
+ * processing all allocated slots, including the extra one.
+ */
+static void
+pgstat_cleanup_files(PgStat_SerializeFiles *files, bool is_temporary)
+{
+	for (int i = 0; i < PGSTAT_KIND_CUSTOM_SIZE + 1; i++)
+	{
+		for (int j = 0; j < files[i].num_files; j++)
+		{
+			FILE	   *fd = files[i].fd[j];
+
+			const char *filename = is_temporary ?
+				files[i].tmpfiles[j] :
+				files[i].statfiles[j];
+			const char *type_str = is_temporary ? "temporary" : "permanent";
+
+			elog(DEBUG2, "removing %s stats file \"%s\"", type_str, filename);
+
+			if (fd && FreeFile(fd) < 0)
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not close %s statistics file \"%s\": %m",
+								type_str, filename)));
+			}
+
+			unlink(filename);
+
+			/* free per-file strings */
+			pfree(files[i].tmpfiles[j]);
+			pfree(files[i].statfiles[j]);
+		}
+
+		/* free each file arrays */
+		if (files[i].fd)
+			pfree(files[i].fd);
+
+		if (files[i].tmpfiles)
+			pfree(files[i].tmpfiles);
+
+		if (files[i].statfiles)
+			pfree(files[i].statfiles);
+	}
+
+	/* finally free top-level array */
+	pfree(files);
+}
+
+/*
+ * Allocate and open all statistics serialization files for reading or writing.
+ *
+ * The returned array contains the core permanent stats file at index 0,
+ * followed by any per-kind extra files at indices PGSTAT_KIND_CUSTOM_MIN
+ * to PGSTAT_KIND_CUSTOM_MAX.
+ *
+ * In write mode, temporary files are opened. If any file cannot be opened,
+ * all partially created temporary files are removed using pgstat_cleanup_files(),
+ * and NULL is returned.
+ *
+ * In read mode, permanent files are opened. If the core file cannot be opened,
+ * all stats are reset and NULL is returned. If a per-kind extra file cannot be
+ * opened, we proceed to the next extra file.
+ *
+ * Returns an array of PgStat_SerializeFiles on success, or NULL on failure
+ * after cleanup or stats reset as described above.
+ *
+ * NB: pgstat_cleanup_files is responsible for pfree'ing the PgStat_SerializeFiles
+ * array.
+ */
+static PgStat_SerializeFiles *
+pgstat_allocate_files(bool is_read)
+{
+	const char *mode = is_read ? PG_BINARY_R : PG_BINARY_W;
+	const char *action = is_read ? "reading" : "writing";
+	const char *core_path;
+	PgStat_SerializeFiles *core;
+	PgStat_SerializeFiles *files = palloc0(sizeof(PgStat_SerializeFiles) * (PGSTAT_KIND_CUSTOM_SIZE + 1));
+
+	/* --- Core pgstat file setup --- */
+	core = &files[PGSTAT_KIND_MIN - 1];
+	core->num_files = 1;
+	core->tmpfiles = palloc(sizeof(char *));
+	core->statfiles = palloc(sizeof(char *));
+	core->fd = palloc(sizeof(FILE *));
+
+	core->tmpfiles[0] = pstrdup(PGSTAT_STAT_PERMANENT_TMPFILE);
+	core->statfiles[0] = pstrdup(PGSTAT_STAT_PERMANENT_FILENAME);
+
+	elog(DEBUG2, "%s stats file \"%s\"", action, core->statfiles[0]);
+
+	core_path = is_read ? core->statfiles[0] : core->tmpfiles[0];
+	core->fd[0] = AllocateFile(core_path, mode);
+	if (core->fd[0] == NULL)
+	{
+		if (!is_read || errno != ENOENT)
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not open %s statistics file \"%s\": %m",
+							is_read ? "permanent" : "temporary", core_path)));
+
+		if (!is_read)
+			pgstat_cleanup_files(files, true);
+		else
+			pgstat_reset_after_failure();
+
+		return NULL;
+	}
+
+	/* --- Per-kind extra pgstat files --- */
+	for (PgStat_Kind kind = PGSTAT_KIND_CUSTOM_MIN; kind <= PGSTAT_KIND_CUSTOM_MAX; kind++)
+	{
+		int			nfiles;
+		int			index = (kind - PGSTAT_KIND_CUSTOM_MIN) + 1;
+		PgStat_SerializeFiles *extra;
+		const PgStat_KindInfo *info = pgstat_get_kind_info(kind);
+
+		if (!info || info->num_serialized_extra_files < 1)
+			continue;
+
+		nfiles = info->num_serialized_extra_files;
+		extra = &files[index];
+
+		extra->num_files = nfiles;
+		extra->tmpfiles = palloc(sizeof(char *) * nfiles);
+		extra->statfiles = palloc(sizeof(char *) * nfiles);
+		extra->fd = palloc(sizeof(FILE *) * nfiles);
+
+		for (int i = 0; i < nfiles; i++)
+		{
+			const char *path;
+
+			extra->tmpfiles[i] = psprintf("%s/pgstat.%d.%d.tmp",
+										  PGSTAT_STAT_PERMANENT_DIRECTORY, kind, i);
+			extra->statfiles[i] = psprintf("%s/pgstat.%d.%d.stat",
+										   PGSTAT_STAT_PERMANENT_DIRECTORY, kind, i);
+
+			path = is_read ? extra->statfiles[i] : extra->tmpfiles[i];
+			elog(DEBUG2, "%s stats file \"%s\"", action, extra->statfiles[i]);
+
+			extra->fd[i] = AllocateFile(path, mode);
+			if (extra->fd[i] == NULL)
+			{
+				if (!is_read || errno != ENOENT)
+					ereport(LOG,
+							(errcode_for_file_access(),
+							 errmsg("could not open %s statistics file \"%s\": %m",
+									is_read ? "permanent" : "temporary ", path)));
+			}
+		}
+	}
+
+	return files;
+}
 
 /*
  * This function is called in the last process that is accessing the shared
@@ -1574,10 +1793,9 @@ pgstat_write_statsfile(void)
 {
 	FILE	   *fpout;
 	int32		format_id;
-	const char *tmpfile = PGSTAT_STAT_PERMANENT_TMPFILE;
-	const char *statfile = PGSTAT_STAT_PERMANENT_FILENAME;
 	dshash_seq_status hstat;
 	PgStatShared_HashEntry *ps;
+	PgStat_SerializeFiles *files = NULL;
 
 	pgstat_assert_is_up();
 
@@ -1587,26 +1805,17 @@ pgstat_write_statsfile(void)
 	/* we're shutting down, so it's ok to just override this */
 	pgstat_fetch_consistency = PGSTAT_FETCH_CONSISTENCY_NONE;
 
-	elog(DEBUG2, "writing stats file \"%s\"", statfile);
-
-	/*
-	 * Open the statistics temp file to write out the current values.
-	 */
-	fpout = AllocateFile(tmpfile, PG_BINARY_W);
-	if (fpout == NULL)
-	{
-		ereport(LOG,
-				(errcode_for_file_access(),
-				 errmsg("could not open temporary statistics file \"%s\": %m",
-						tmpfile)));
+	files = pgstat_allocate_files(false);
+	if (files == NULL)
 		return;
-	}
+
+	fpout = files[PGSTAT_KIND_MIN - 1].fd[0];
 
 	/*
 	 * Write the file header --- currently just a format ID.
 	 */
 	format_id = PGSTAT_FILE_FORMAT_ID;
-	write_chunk_s(fpout, &format_id);
+	pgstat_write_chunk_s(fpout, &format_id);
 
 	/* Write various stats structs for fixed number of objects */
 	for (PgStat_Kind kind = PGSTAT_KIND_MIN; kind <= PGSTAT_KIND_MAX; kind++)
@@ -1631,8 +1840,8 @@ pgstat_write_statsfile(void)
 			ptr = pgStatLocal.snapshot.custom_data[kind - PGSTAT_KIND_CUSTOM_MIN];
 
 		fputc(PGSTAT_FILE_ENTRY_FIXED, fpout);
-		write_chunk_s(fpout, &kind);
-		write_chunk(fpout, ptr, info->shared_data_len);
+		pgstat_write_chunk_s(fpout, &kind);
+		pgstat_write_chunk(fpout, ptr, info->shared_data_len);
 	}
 
 	/*
@@ -1686,7 +1895,7 @@ pgstat_write_statsfile(void)
 		{
 			/* normal stats entry, identified by PgStat_HashKey */
 			fputc(PGSTAT_FILE_ENTRY_HASH, fpout);
-			write_chunk_s(fpout, &ps->key);
+			pgstat_write_chunk_s(fpout, &ps->key);
 		}
 		else
 		{
@@ -1696,57 +1905,73 @@ pgstat_write_statsfile(void)
 			kind_info->to_serialized_name(&ps->key, shstats, &name);
 
 			fputc(PGSTAT_FILE_ENTRY_NAME, fpout);
-			write_chunk_s(fpout, &ps->key.kind);
-			write_chunk_s(fpout, &name);
+			pgstat_write_chunk_s(fpout, &ps->key.kind);
+			pgstat_write_chunk_s(fpout, &name);
+		}
+
+		/* A plug-in is saving extra data */
+		if (kind_info->to_serialized_extra)
+		{
+			int			index = (ps->key.kind - PGSTAT_KIND_CUSTOM_MIN) + 1;
+
+			Assert(files[index].fd);
+
+			kind_info->to_serialized_extra(&ps->key, shstats, fpout, files[index].fd);
 		}
 
 		/* Write except the header part of the entry */
-		write_chunk(fpout,
-					pgstat_get_entry_data(ps->key.kind, shstats),
-					pgstat_get_entry_len(ps->key.kind));
+		pgstat_write_chunk(fpout,
+						   pgstat_get_entry_data(ps->key.kind, shstats),
+						   pgstat_get_entry_len(ps->key.kind));
+
 	}
 	dshash_seq_term(&hstat);
 
 	/*
 	 * No more output to be done. Close the temp file and replace the old
 	 * pgstat.stat with it.  The ferror() check replaces testing for error
-	 * after each individual fputc or fwrite (in write_chunk()) above.
+	 * after each individual fputc or fwrite (in pgstat_write_chunk()) above.
 	 */
 	fputc(PGSTAT_FILE_ENTRY_END, fpout);
 
-	if (ferror(fpout))
-	{
-		ereport(LOG,
-				(errcode_for_file_access(),
-				 errmsg("could not write temporary statistics file \"%s\": %m",
-						tmpfile)));
-		FreeFile(fpout);
-		unlink(tmpfile);
-	}
-	else if (FreeFile(fpout) < 0)
-	{
-		ereport(LOG,
-				(errcode_for_file_access(),
-				 errmsg("could not close temporary statistics file \"%s\": %m",
-						tmpfile)));
-		unlink(tmpfile);
-	}
-	else if (durable_rename(tmpfile, statfile, LOG) < 0)
+	for (int i = 0; i < PGSTAT_KIND_CUSTOM_SIZE + 1; i++)
 	{
-		/* durable_rename already emitted log message */
-		unlink(tmpfile);
+		for (int j = 0; j < files[i].num_files; j++)
+		{
+			const char *tmpfile;
+			const char *statfile;
+			FILE	   *fd = NULL;
+
+			tmpfile = files[i].tmpfiles[j];
+			statfile = files[i].statfiles[j];
+			fd = files[i].fd[j];
+
+			if (ferror(fd))
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not write temporary statistics file \"%s\": %m",
+								tmpfile)));
+				FreeFile(fd);
+				unlink(tmpfile);
+			}
+			else if (FreeFile(fd) < 0)
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not close temporary statistics file \"%s\": %m",
+								tmpfile)));
+				unlink(tmpfile);
+			}
+			else if (durable_rename(tmpfile, statfile, LOG) < 0)
+			{
+				/* durable_rename already emitted log message */
+				unlink(tmpfile);
+			}
+		}
 	}
 }
 
-/* helpers for pgstat_read_statsfile() */
-static bool
-read_chunk(FILE *fpin, void *ptr, size_t len)
-{
-	return fread(ptr, 1, len, fpin) == len;
-}
-
-#define read_chunk_s(fpin, ptr) read_chunk(fpin, ptr, sizeof(*ptr))
-
 /*
  * Reads in existing statistics file into memory.
  *
@@ -1761,36 +1986,21 @@ pgstat_read_statsfile(void)
 	bool		found;
 	const char *statfile = PGSTAT_STAT_PERMANENT_FILENAME;
 	PgStat_ShmemControl *shmem = pgStatLocal.shmem;
+	PgStat_SerializeFiles *files = NULL;
 
 	/* shouldn't be called from postmaster */
 	Assert(IsUnderPostmaster || !IsPostmasterEnvironment);
 
-	elog(DEBUG2, "reading stats file \"%s\"", statfile);
-
-	/*
-	 * Try to open the stats file. If it doesn't exist, the backends simply
-	 * returns zero for anything and statistics simply starts from scratch
-	 * with empty counters.
-	 *
-	 * ENOENT is a possibility if stats collection was previously disabled or
-	 * has not yet written the stats file for the first time.  Any other
-	 * failure condition is suspicious.
-	 */
-	if ((fpin = AllocateFile(statfile, PG_BINARY_R)) == NULL)
-	{
-		if (errno != ENOENT)
-			ereport(LOG,
-					(errcode_for_file_access(),
-					 errmsg("could not open statistics file \"%s\": %m",
-							statfile)));
-		pgstat_reset_after_failure();
+	files = pgstat_allocate_files(true);
+	if (files == NULL)
 		return;
-	}
+
+	fpin = files[PGSTAT_KIND_MIN - 1].fd[0];
 
 	/*
 	 * Verify it's of the expected format.
 	 */
-	if (!read_chunk_s(fpin, &format_id))
+	if (!pgstat_read_chunk_s(fpin, &format_id))
 	{
 		elog(WARNING, "could not read format ID");
 		goto error;
@@ -1820,7 +2030,7 @@ pgstat_read_statsfile(void)
 					char	   *ptr;
 
 					/* entry for fixed-numbered stats */
-					if (!read_chunk_s(fpin, &kind))
+					if (!pgstat_read_chunk_s(fpin, &kind))
 					{
 						elog(WARNING, "could not read stats kind for entry of type %c", t);
 						goto error;
@@ -1860,7 +2070,7 @@ pgstat_read_statsfile(void)
 							info->shared_data_off;
 					}
 
-					if (!read_chunk(fpin, ptr, info->shared_data_len))
+					if (!pgstat_read_chunk(fpin, ptr, info->shared_data_len))
 					{
 						elog(WARNING, "could not read data of stats kind %u for entry of type %c with size %u",
 							 kind, t, info->shared_data_len);
@@ -1875,13 +2085,14 @@ pgstat_read_statsfile(void)
 					PgStat_HashKey key;
 					PgStatShared_HashEntry *p;
 					PgStatShared_Common *header;
+					const PgStat_KindInfo *kind_info = NULL;
 
 					CHECK_FOR_INTERRUPTS();
 
 					if (t == PGSTAT_FILE_ENTRY_HASH)
 					{
 						/* normal stats entry, identified by PgStat_HashKey */
-						if (!read_chunk_s(fpin, &key))
+						if (!pgstat_read_chunk_s(fpin, &key))
 						{
 							elog(WARNING, "could not read key for entry of type %c", t);
 							goto error;
@@ -1894,8 +2105,8 @@ pgstat_read_statsfile(void)
 								 key.objid, t);
 							goto error;
 						}
-
-						if (!pgstat_get_kind_info(key.kind))
+						kind_info = pgstat_get_kind_info(key.kind);
+						if (!kind_info)
 						{
 							elog(WARNING, "could not find information of kind for entry %u/%u/%" PRIu64 " of type %c",
 								 key.kind, key.dboid,
@@ -1906,16 +2117,15 @@ pgstat_read_statsfile(void)
 					else
 					{
 						/* stats entry identified by name on disk (e.g. slots) */
-						const PgStat_KindInfo *kind_info = NULL;
 						PgStat_Kind kind;
 						NameData	name;
 
-						if (!read_chunk_s(fpin, &kind))
+						if (!pgstat_read_chunk_s(fpin, &kind))
 						{
 							elog(WARNING, "could not read stats kind for entry of type %c", t);
 							goto error;
 						}
-						if (!read_chunk_s(fpin, &name))
+						if (!pgstat_read_chunk_s(fpin, &name))
 						{
 							elog(WARNING, "could not read name of stats kind %u for entry of type %c",
 								 kind, t);
@@ -1990,9 +2200,9 @@ pgstat_read_statsfile(void)
 							 key.objid, t);
 					}
 
-					if (!read_chunk(fpin,
-									pgstat_get_entry_data(key.kind, header),
-									pgstat_get_entry_len(key.kind)))
+					if (!pgstat_read_chunk(fpin,
+										   pgstat_get_entry_data(key.kind, header),
+										   pgstat_get_entry_len(key.kind)))
 					{
 						elog(WARNING, "could not read data for entry %u/%u/%" PRIu64 " of type %c",
 							 key.kind, key.dboid,
@@ -2000,6 +2210,25 @@ pgstat_read_statsfile(void)
 						goto error;
 					}
 
+					/*
+					 * A plug-in is reading extra data. If reading fails, the
+					 * corresponding file is closed, the error is logged, and
+					 * processing continues.
+					 */
+					if (kind_info->from_serialized_extra)
+					{
+						int			index = (key.kind - PGSTAT_KIND_CUSTOM_MIN) + 1;
+
+						Assert(files[index].fd);
+
+						if (!kind_info->from_serialized_extra(&key, header, fpin, files[index].fd))
+						{
+							elog(WARNING, "could not read extra data for entry %u/%u/%" PRIu64 " of type %c",
+								 key.kind, key.dboid,
+								 key.objid, t);
+						}
+					}
+
 					break;
 				}
 			case PGSTAT_FILE_ENTRY_END:
@@ -2023,10 +2252,7 @@ pgstat_read_statsfile(void)
 	}
 
 done:
-	FreeFile(fpin);
-
-	elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
-	unlink(statfile);
+	pgstat_cleanup_files(files, false);
 
 	return;
 
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index 4d2b8aa6081..be39c4518f8 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -303,6 +303,16 @@ typedef struct PgStat_KindInfo
 									   const PgStatShared_Common *header, NameData *name);
 	bool		(*from_serialized_name) (const NameData *name, PgStat_HashKey *key);
 
+	/*
+	 * For custom, variable-numbered stats, serialize/deserialize extra data
+	 * per entry. Optional.
+	 */
+	bool		(*from_serialized_extra) (PgStat_HashKey *key,
+										  const PgStatShared_Common *header, FILE *statfile, FILE **extra_files);
+	void		(*to_serialized_extra) (const PgStat_HashKey *key,
+										const PgStatShared_Common *header, FILE *statfile, FILE **extra_files);
+	int			num_serialized_extra_files;
+
 	/*
 	 * For fixed-numbered statistics: Initialize shared memory state.
 	 *
@@ -984,4 +994,15 @@ pgstat_get_custom_snapshot_data(PgStat_Kind kind)
 	return pgStatLocal.snapshot.custom_data[idx];
 }
 
+/* ------------------------------------------------------------
+ * reading and writing of on-disk stats file
+ * ------------------------------------------------------------
+ */
+
+/* helpers for pgstat_write_statsfile() */
+extern void pgstat_write_chunk(FILE *fpout, void *ptr, size_t len);
+extern bool pgstat_read_chunk(FILE *fpin, void *ptr, size_t len);
+#define pgstat_read_chunk_s(fpin, ptr) pgstat_read_chunk(fpin, ptr, sizeof(*ptr))
+#define pgstat_write_chunk_s(fpout, ptr) pgstat_write_chunk(fpout, ptr, sizeof(*ptr))
+
 #endif							/* PGSTAT_INTERNAL_H */
-- 
2.43.0

v1-0002-pgstat-Add-tests-for-custom-serialization-files-a.patchapplication/octet-stream; name=v1-0002-pgstat-Add-tests-for-custom-serialization-files-a.patchDownload
From b7b1974872ba960c1b69ecb037d098c8c2547ee0 Mon Sep 17 00:00:00 2001
From: Ubuntu <ubuntu@ip-172-31-46-230.ec2.internal>
Date: Wed, 19 Nov 2025 03:58:45 +0000
Subject: [PATCH v1 2/2] pgstat: Add tests for custom serialization files and
 callbacks

This commit adds recovery tests for custom stats data after a clean restart.

injection_points is enhanced to store an injection point description,
which is recorded as extra stats data.
---
 .../injection_points--1.0.sql                 |  14 +-
 .../injection_points/injection_points.c       |   8 +-
 .../injection_points/injection_stats.c        | 185 +++++++++++++++++-
 .../injection_points/injection_stats.h        |   3 +-
 src/test/recovery/t/029_stats_restart.pl      |  47 ++++-
 5 files changed, 251 insertions(+), 6 deletions(-)

diff --git a/src/test/modules/injection_points/injection_points--1.0.sql b/src/test/modules/injection_points/injection_points--1.0.sql
index a51ff538684..4861a477e98 100644
--- a/src/test/modules/injection_points/injection_points--1.0.sql
+++ b/src/test/modules/injection_points/injection_points--1.0.sql
@@ -9,10 +9,20 @@
 -- Attaches the action to the given injection point.
 --
 CREATE FUNCTION injection_points_attach(IN point_name TEXT,
-    IN action text)
+    IN action text, IN description text DEFAULT NULL)
 RETURNS void
 AS 'MODULE_PATHNAME', 'injection_points_attach'
-LANGUAGE C STRICT PARALLEL UNSAFE;
+LANGUAGE C PARALLEL UNSAFE;
+
+--
+-- injection_points_stats_description()
+--
+-- Reports statistics, if any, related to the given injection point.
+--
+CREATE FUNCTION injection_points_stats_description(IN point_name TEXT)
+RETURNS TEXT
+AS 'MODULE_PATHNAME', 'injection_points_stats_description'
+LANGUAGE C STRICT;
 
 --
 -- injection_points_attach()
diff --git a/src/test/modules/injection_points/injection_points.c b/src/test/modules/injection_points/injection_points.c
index b7c1c58ea56..c72fb8d4cb6 100644
--- a/src/test/modules/injection_points/injection_points.c
+++ b/src/test/modules/injection_points/injection_points.c
@@ -353,9 +353,15 @@ injection_points_attach(PG_FUNCTION_ARGS)
 {
 	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
 	char	   *action = text_to_cstring(PG_GETARG_TEXT_PP(1));
+	char	   *description = NULL; /* optional description */
 	char	   *function;
 	InjectionPointCondition condition = {0};
 
+	if (PG_NARGS() > 2 && !PG_ARGISNULL(2))
+	{
+		description = text_to_cstring(PG_GETARG_TEXT_PP(2));
+	}
+
 	if (strcmp(action, "error") == 0)
 		function = "injection_error";
 	else if (strcmp(action, "notice") == 0)
@@ -386,7 +392,7 @@ injection_points_attach(PG_FUNCTION_ARGS)
 	}
 
 	/* Add entry for stats */
-	pgstat_create_inj(name);
+	pgstat_create_inj(name, description);
 
 	PG_RETURN_VOID();
 }
diff --git a/src/test/modules/injection_points/injection_stats.c b/src/test/modules/injection_points/injection_stats.c
index 158e1631af9..c70dd957f64 100644
--- a/src/test/modules/injection_points/injection_stats.c
+++ b/src/test/modules/injection_points/injection_stats.c
@@ -19,6 +19,7 @@
 #include "common/hashfn.h"
 #include "injection_stats.h"
 #include "pgstat.h"
+#include "storage/dsm_registry.h"
 #include "utils/builtins.h"
 #include "utils/pgstat_internal.h"
 
@@ -26,6 +27,7 @@
 typedef struct PgStat_StatInjEntry
 {
 	PgStat_Counter numcalls;	/* number of times point has been run */
+	dsa_pointer description;	/* injection point description */
 } PgStat_StatInjEntry;
 
 typedef struct PgStatShared_InjectionPoint
@@ -35,6 +37,14 @@ typedef struct PgStatShared_InjectionPoint
 } PgStatShared_InjectionPoint;
 
 static bool injection_stats_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
+void		injection_stats_serialize_extra(const PgStat_HashKey *key,
+											const PgStatShared_Common *header,
+											FILE *statfile,
+											FILE **extra_files);
+bool		injection_stats_deserialize_extra(PgStat_HashKey *key,
+											  const PgStatShared_Common *header,
+											  FILE *statfile,
+											  FILE **extra_files);
 
 static const PgStat_KindInfo injection_stats = {
 	.name = "injection_points",
@@ -50,6 +60,10 @@ static const PgStat_KindInfo injection_stats = {
 	.shared_data_len = sizeof(((PgStatShared_InjectionPoint *) 0)->stats),
 	.pending_size = sizeof(PgStat_StatInjEntry),
 	.flush_pending_cb = injection_stats_flush_cb,
+	.to_serialized_extra = injection_stats_serialize_extra,
+	.from_serialized_extra = injection_stats_deserialize_extra,
+	.num_serialized_extra_files = 2,	/* We create 2 files to test recovery,
+										 * though only one is used */
 };
 
 /*
@@ -65,6 +79,9 @@ static const PgStat_KindInfo injection_stats = {
 /* Track if stats are loaded */
 static bool inj_stats_loaded = false;
 
+/* DSA area to store an injection points description */
+dsa_area   *inj_description_dsa = NULL;
+
 /*
  * Callback for stats handling
  */
@@ -122,10 +139,13 @@ pgstat_register_inj(void)
  * Report injection point creation.
  */
 void
-pgstat_create_inj(const char *name)
+pgstat_create_inj(const char *name, const char *description)
 {
 	PgStat_EntryRef *entry_ref;
 	PgStatShared_InjectionPoint *shstatent;
+	size_t		len;
+	char	   *desc_copy;
+	bool		found;
 
 	/* leave if disabled */
 	if (!inj_stats_loaded || !inj_stats_enabled)
@@ -138,6 +158,25 @@ pgstat_create_inj(const char *name)
 
 	/* initialize shared memory data */
 	memset(&shstatent->stats, 0, sizeof(shstatent->stats));
+
+	if (!description)
+		return;
+
+	len = strlen(description) + 1;
+
+	if (!inj_description_dsa)
+		inj_description_dsa = GetNamedDSA("injection_points_description", &found);
+
+	if (inj_description_dsa)
+	{
+		shstatent->stats.description = dsa_allocate(inj_description_dsa, len);
+
+		desc_copy = dsa_get_address(inj_description_dsa,
+									shstatent->stats.description);
+		desc_copy[len - 1] = '\0';
+
+		memcpy(desc_copy, description, len);
+	}
 }
 
 /*
@@ -180,6 +219,150 @@ pgstat_report_inj(const char *name)
 	pending->numcalls++;
 }
 
+void
+injection_stats_serialize_extra(const PgStat_HashKey *key,
+								const PgStatShared_Common *header,
+								FILE *statfile,
+								FILE **extra_files)
+{
+	char	   *description;
+	size_t		qlen;
+	FILE	   *fd = extra_files[0];
+	PgStatShared_InjectionPoint *entry = (PgStatShared_InjectionPoint *) header;
+
+	/* Exit early if stats aren't available or enabled */
+	if (!inj_stats_loaded || !inj_stats_enabled || !key)
+		return;
+
+	/* Write hash key */
+	pgstat_write_chunk(fd, (void *) key, sizeof(PgStat_HashKey));
+
+	/* Handle missing description */
+	if (!DsaPointerIsValid(entry->stats.description))
+	{
+		fputc('\0', fd);
+		return;
+	}
+
+	/* Ensure DSA area is loaded */
+	if (!inj_description_dsa)
+	{
+		bool		found;
+
+		inj_description_dsa = GetNamedDSA("injection_points_description", &found);
+	}
+
+	if (!inj_description_dsa)
+	{
+		fputc('\0', fd);
+		return;
+	}
+
+	/* Get description and write it */
+	description = dsa_get_address(inj_description_dsa, entry->stats.description);
+	qlen = strlen(description) + 1; /* include null terminator */
+
+	pgstat_write_chunk(fd, description, qlen);
+}
+
+bool
+injection_stats_deserialize_extra(PgStat_HashKey *key,
+								  const PgStatShared_Common *header,
+								  FILE *statfile,
+								  FILE **extra_files)
+{
+	PgStatShared_InjectionPoint *entry;
+	dsa_pointer dp;
+	size_t		bufcap;
+	size_t		len;
+	char	   *buffer;
+	int			c;
+	FILE	   *fd = extra_files[0];
+
+	if (!inj_stats_loaded || !inj_stats_enabled)
+		return true;
+
+	/* Read the key */
+	if (!pgstat_read_chunk(fd, (void *) key, sizeof(PgStat_HashKey)))
+		return feof(fd) ? true : false;
+
+	/* Ensure DSA is ready */
+	if (!inj_description_dsa)
+	{
+		bool		found;
+
+		inj_description_dsa = GetNamedDSA("injection_points_description", &found);
+	}
+
+	entry =
+		(PgStatShared_InjectionPoint *) header;
+
+	/* Read null-terminated description */
+	bufcap = 128;
+	len = 0;
+	buffer = palloc(bufcap);
+
+	while ((c = fgetc(fd)) != EOF)
+	{
+		if (len + 1 >= bufcap)
+		{
+			bufcap *= 2;
+			buffer = repalloc(buffer, bufcap);
+		}
+
+		buffer[len++] = (char) c;
+
+		if (c == '\0')
+			break;
+	}
+
+	/* EOF reached unexpectedly */
+	if (c == EOF)
+	{
+		pfree(buffer);
+		return false;
+	}
+
+	/* Copy into DSA */
+	dp = dsa_allocate(inj_description_dsa, len);
+
+	memcpy(dsa_get_address(inj_description_dsa, dp), buffer, len);
+	entry->stats.description = dp;
+
+	pfree(buffer);
+	return true;
+}
+
+PG_FUNCTION_INFO_V1(injection_points_stats_description);
+Datum
+injection_points_stats_description(PG_FUNCTION_ARGS)
+{
+	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	PgStat_StatInjEntry *entry = pgstat_fetch_stat_injentry(name);
+
+	if (entry == NULL)
+		PG_RETURN_NULL();
+
+	if (DsaPointerIsValid(entry->description))
+	{
+		char	   *description = NULL;
+		bool		found;
+
+		if (!inj_description_dsa)
+			inj_description_dsa = GetNamedDSA("injection_points_description", &found);
+
+		if (inj_description_dsa)
+			description = dsa_get_address(inj_description_dsa, entry->description);
+
+		if (!description)
+			PG_RETURN_NULL();
+
+		PG_RETURN_TEXT_P(cstring_to_text(description));
+	}
+	else
+		PG_RETURN_NULL();
+}
+
 /*
  * SQL function returning the number of times an injection point
  * has been called.
diff --git a/src/test/modules/injection_points/injection_stats.h b/src/test/modules/injection_points/injection_stats.h
index ba310c52c7f..e50a256d91e 100644
--- a/src/test/modules/injection_points/injection_stats.h
+++ b/src/test/modules/injection_points/injection_stats.h
@@ -20,7 +20,8 @@ extern bool inj_stats_enabled;
 
 /* injection_stats.c */
 extern void pgstat_register_inj(void);
-extern void pgstat_create_inj(const char *name);
+extern void pgstat_create_inj(const char *name,
+							  const char *description);
 extern void pgstat_drop_inj(const char *name);
 extern void pgstat_report_inj(const char *name);
 
diff --git a/src/test/recovery/t/029_stats_restart.pl b/src/test/recovery/t/029_stats_restart.pl
index 021e2bf361f..1a5c9b8abad 100644
--- a/src/test/recovery/t/029_stats_restart.pl
+++ b/src/test/recovery/t/029_stats_restart.pl
@@ -10,11 +10,31 @@ use PostgreSQL::Test::Utils;
 use Test::More;
 use File::Copy;
 
+##################################################
+# This test relies on an injection point to test extra serialization data
+# is persisted after a clean shutdown.
+##################################################
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
 my $node = PostgreSQL::Test::Cluster->new('primary');
 $node->init(allows_streaming => 1);
-$node->append_conf('postgresql.conf', "track_functions = 'all'");
+$node->append_conf('postgresql.conf',
+		"track_functions = 'all'
+		shared_preload_libraries='injection_points'
+		injection_points.stats = 'on'");
 $node->start;
 
+# Check if the extension injection_points is available, as it may be
+# possible that this script is run with installcheck, where the module
+# would not be installed by default.
+if (!$node->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
 my $connect_db = 'postgres';
 my $db_under_test = 'test';
 
@@ -53,6 +73,14 @@ my $tableoid = $node->safe_psql($db_under_test,
 # generate stats and flush them
 trigger_funcrel_stat();
 
+# Generate custom stats data
+$node->safe_psql($connect_db, "CREATE EXTENSION injection_points");
+$node->safe_psql('postgres',
+	"SELECT injection_points_attach('stats-notice1', 'notice',
+	'this description is for stats-notice1 injection point');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice1');");
+$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice1');");
+
 # verify stats objects exist
 $sect = "initial";
 is(have_stats('database', $dboid, 0), 't', "$sect: db stats do exist");
@@ -70,7 +98,13 @@ ok(!-f "$statsfile", "backup statsfile cannot already exist");
 
 my $datadir = $node->data_dir();
 my $og_stats = "$datadir/pg_stat/pgstat.stat";
+my $og_custom_stats0 = "$datadir/pg_stat/pgstat.25.0.stat";
+my $og_custom_stats1 = "$datadir/pg_stat/pgstat.25.1.stat";
+my @og_tmp_stats = glob("$datadir/pg_stat/*.tmp");
+ok(!@og_tmp_stats, "origin temp stats file must exist");
 ok(-f "$og_stats", "origin stats file must exist");
+ok(-f "$og_custom_stats0", "origin custom stats file 0 must exist");
+ok(-f "$og_custom_stats1", "origin custom stats file 1 must exist");
 copy($og_stats, $statsfile) or die "Copy failed: $!";
 
 
@@ -85,9 +119,20 @@ is(have_stats('function', $dboid, $funcoid),
 is(have_stats('relation', $dboid, $tableoid),
 	't', "$sect: relation stats do exist");
 
+# Check the custom stats data survived the restart
+my $numcalls = $node->safe_psql('postgres',
+	"SELECT injection_points_stats_numcalls('stats-notice1');");
+my $description = $node->safe_psql('postgres',
+								"SELECT injection_points_stats_description('stats-notice1');");
+is($numcalls, '2', 'number of stats calls for stats-notice1');
+is($description, 'this description is for stats-notice1 injection point',
+				 'description of stats-notice1 perserved after clean restart');
+
 $node->stop('immediate');
 
 ok(!-f "$og_stats", "no stats file should exist after immediate shutdown");
+ok(!-f "$og_custom_stats0", "no custom stats file 0 should exist after immediate shutdown");
+ok(!-f "$og_custom_stats1", "no custom stats file 1 should exist after immediate shutdown");
 
 # copy the old stats back to test we discard stats after crash restart
 copy($statsfile, $og_stats) or die "Copy failed: $!";
-- 
2.43.0

#10Sami Imseih
samimseih@gmail.com
In reply to: Sami Imseih (#9)
Re: [Proposal] Adding callback support for custom statistics kinds

It just occurred to me that the documentation [0]https://www.postgresql.org/docs/current/xfunc-c.html#XFUNC-ADDIN-CUSTOM-CUMULATIVE-STATISTICS should be
updated to describe the callbacks. I will do that in the next
revision.

[0]: https://www.postgresql.org/docs/current/xfunc-c.html#XFUNC-ADDIN-CUSTOM-CUMULATIVE-STATISTICS

--
Sami Imseih
Amazon Web Services (AWS)

#11Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#10)
Re: [Proposal] Adding callback support for custom statistics kinds

On Wed, Nov 19, 2025 at 08:10:43PM -0600, Sami Imseih wrote:

It just occurred to me that the documentation [0] should be
updated to describe the callbacks. I will do that in the next
revision.

[0] https://www.postgresql.org/docs/current/xfunc-c.html#XFUNC-ADDIN-CUSTOM-CUMULATIVE-STATISTICS

Hmm.  Based on what I can read from the patch, you are still enforcing
file name patterns in the backend, as of:
+          extra->statfiles[i] = psprintf("%s/pgstat.%d.%d.stat",
+                        PGSTAT_STAT_PERMANENT_DIRECTORY, kind, i);

My take (also mentioned upthread) is that this design should go the
other way around, where modules have the possibility to define their
own file names, and some of them could be generated on-the-fly when
writing the files, for example for a per-file database split, or the
object ID itself.

The important part for variable-numbered stats is that the keys of the
entries have to be in the main pgstats file. Then, the extra data is
loaded back based on the data in the entry key, based on a file name
that only a custom stats kind knows about (fd and file name). It
means that the custom stats kind needs to track the files it has to
clean up by itself in this scheme. We could pass back to the startup
process some fds that it cleans up, but it feels simpler here to let
the custom code do what they want, instead, rather than having an
array that tracks the file names and/or their fds.
--
Michael

#12Sami Imseih
samimseih@gmail.com
In reply to: Michael Paquier (#11)
Re: [Proposal] Adding callback support for custom statistics kinds

It just occurred to me that the documentation [0] should be
updated to describe the callbacks. I will do that in the next
revision.

[0] https://www.postgresql.org/docs/current/xfunc-c.html#XFUNC-ADDIN-CUSTOM-CUMULATIVE-STATISTICS

Hmm.  Based on what I can read from the patch, you are still enforcing
file name patterns in the backend, as of:
+          extra->statfiles[i] = psprintf("%s/pgstat.%d.%d.stat",
+                        PGSTAT_STAT_PERMANENT_DIRECTORY, kind, i);

My take (also mentioned upthread) is that this design should go the
other way around, where modules have the possibility to define their
own file names, and some of them could be generated on-the-fly when
writing the files, for example for a per-file database split, or the
object ID itself.

The way I thought about it is that extension developer can just provide the
number of files they need and the they are then given a list of
file pointers that they need. They can then manage what each file is
used for. They also don't need to worry about naming the files, all they
need to do is track what each file in the list does.

The important part for variable-numbered stats is that the keys of the
entries have to be in the main pgstats file. Then, the extra data is
loaded back based on the data in the entry key, based on a file name
that only a custom stats kind knows about (fd and file name). It
means that the custom stats kind needs to track the files it has to
clean up by itself in this scheme. We could pass back to the startup
process some fds that it cleans up, but it feels simpler here to let
the custom code do what they want, instead, rather than having an
array that tracks the file names and/or their fds.

yeah, I was leaning towards putting more responsibility on pgstat to
manage these extra files, but you are suggesting that we just let the
extension manage the create/cleanup of these files as well.

After re-reading your earlier suggestions, this sounds like a third
callback that is used for file cleanup, and this callback could be
the existing reset_all_cb. Also, instead of reset_all_cb being called
during pgstat_reset_after_failure, it can be called during the success
case, i.e, a new pgstat_reset_after_success. reset_all_cb also
carries a status argument so the extension knows what to do
in the case of success or failure.

This also means we need to also update all existing callbacks to
do work in the failed status.

Is that correct?

--
Sami

#13Sami Imseih
samimseih@gmail.com
In reply to: Sami Imseih (#12)
Re: [Proposal] Adding callback support for custom statistics kinds

After re-reading your earlier suggestions, this sounds like a third
callback that is used for file cleanup, and this callback could be
the existing reset_all_cb. Also, instead of reset_all_cb being called
during pgstat_reset_after_failure, it can be called during the success
case, i.e, a new pgstat_reset_after_success. reset_all_cb also
carries a status argument so the extension knows what to do
in the case of success or failure.

This also means we need to also update all existing callbacks to
do work in the failed status.

After second thought, I am not too thrilled with extending reset_all_cb
to take responsibility for file cleanup, etc. I think it should just remain
used to reset stats only.

I think the best way forward will be to introduce a callback to be used by
custom kinds only. This callback will be responsible for cleaning up files
and related resources at the end of the write stats, read stats, and discard
stats paths. The callback will provide back to the extension a status
(READ, WRITE, DISCARD) and the extension will know how to clean up the
resources it created depending on the situation.

So, unlike my original proposal, this puts more responsibility on the
extension to track and clean up its files, but this seems like the best
approach to take here.

Also, I am now leaning towards creating a separate test module rather than
trying to do too much unrelated testing in injection points. It is definitely
convenient to use injection points, but I think we can do better testing with
a separate module. This module can also serve as an example for extension
developers.

what do you think?

--
Sami Imseih
Amazon Web Services (AWS)

#14Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#13)
Re: [Proposal] Adding callback support for custom statistics kinds

On Mon, Nov 24, 2025 at 06:18:26PM -0600, Sami Imseih wrote:

After second thought, I am not too thrilled with extending reset_all_cb
to take responsibility for file cleanup, etc. I think it should just remain
used to reset stats only.

I think the best way forward will be to introduce a callback to be used by
custom kinds only. This callback will be responsible for cleaning up files
and related resources at the end of the write stats, read stats, and discard
stats paths. The callback will provide back to the extension a status
(READ, WRITE, DISCARD) and the extension will know how to clean up the
resources it created depending on the situation.

I guess that READ and WRITE are the cases that happen on success of
these respective operations. DISCARD is the failure case when one of
these fail.

So, unlike my original proposal, this puts more responsibility on the
extension to track and clean up its files, but this seems like the best
approach to take here.

That may be something we should do anyway. It means that the modules
are responsible for the tracking the file(s) they open, still they
could also decide operations different than the backend for the main
pgstats file, optionally, depending on the state of the reads and
writes (aka success or failure of these).

Also, I am now leaning towards creating a separate test module rather than
trying to do too much unrelated testing in injection points. It is definitely
convenient to use injection points, but I think we can do better testing with
a separate module. This module can also serve as an example for extension
developers.

You are right that it may be cleaner this way. Do you think that it
could make sense to move some of the existing "template" code of
injection_points there?

One part of the proposed patch that felt independent to me was the
renaming and publishing of the two write/read routines for the stats
files, so I have extracted that in your first patch to reduce the
blast, and applied that as it can also be useful on its own.
--
Michael

#15Sami Imseih
samimseih@gmail.com
In reply to: Michael Paquier (#14)
2 attachment(s)
Re: [Proposal] Adding callback support for custom statistics kinds

Also, I am now leaning towards creating a separate test module rather than
trying to do too much unrelated testing in injection points. It is definitely
convenient to use injection points, but I think we can do better testing with
a separate module. This module can also serve as an example for extension
developers.

You are right that it may be cleaner this way. Do you think that it
could make sense to move some of the existing "template" code of
injection_points there?

By "template" code, do you mean Something like?

include/utils/custom_statkinds.h
backend/utils/misc/custom_statkinds.c

Where the template code here is PgStat_kind definition, callbacks, etc. for
injection_points or the new test module that is using a custom kind.

A few benefits I see for this is we can point extension developers to
this as an example in [0]https://www.postgresql.org/docs/current/xfunc-c.html#XFUNC-ADDIN-CUSTOM-CUMULATIVE-STATISTICS and we are also maintaining the kind ids in
a single place. These may not be strong points, but may be worth while.

v2 attached is something that may be closer to what we've been discussing

v2-0001 are much simplified changes to pgstat.c that simply invoke the callbacks
and all the work is on the extension to implement what it needs to do.
This includes
a callback at the end of WRITE, READ, DISCARD with a flag passed to the caller
so they can perform the necessary clean-up actions.

v2-0002 implements a new test module that tests mainly that the recovery,
clean and crash, are working as expected.

I created a new tap test for this which performs a test similar to what is
done in recovery/029_stats_restart.pl. I could merge the new test there, but
I am reluctant to add a dependency on a new module to recovery. What
do you think?

[0]: https://www.postgresql.org/docs/current/xfunc-c.html#XFUNC-ADDIN-CUSTOM-CUMULATIVE-STATISTICS

--
Sami Imseih
Amazon Web Services (AWS)

Attachments:

v2-0002-Tests-for-custom-stat-kinds.patchapplication/octet-stream; name=v2-0002-Tests-for-custom-stat-kinds.patchDownload
From 50fb48cfce161438ad9d3f8d39ffe0d4c7d542a4 Mon Sep 17 00:00:00 2001
From: Ubuntu <ubuntu@ip-172-31-46-230.ec2.internal>
Date: Tue, 2 Dec 2025 17:00:04 +0000
Subject: [PATCH v2 2/2] Tests for custom stat kinds

Creates a new test module to test custom stat
kinds. This also updates documentation to use
this module as an example for extension
developers that use custom stat kinds.
---
 doc/src/sgml/xfunc.sgml                       |   6 +-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/meson.build                  |   1 +
 .../modules/test_custom_statkind/.gitignore   |   4 +
 .../modules/test_custom_statkind/Makefile     |  23 +
 .../modules/test_custom_statkind/meson.build  |  34 ++
 .../test_custom_statkind/t/001_custom_stat.pl |  92 +++
 .../test_custom_statkind--1.0.sql             |  19 +
 .../test_custom_statkind.c                    | 570 ++++++++++++++++++
 .../test_custom_statkind.control              |   4 +
 10 files changed, 753 insertions(+), 1 deletion(-)
 create mode 100644 src/test/modules/test_custom_statkind/.gitignore
 create mode 100644 src/test/modules/test_custom_statkind/Makefile
 create mode 100644 src/test/modules/test_custom_statkind/meson.build
 create mode 100644 src/test/modules/test_custom_statkind/t/001_custom_stat.pl
 create mode 100644 src/test/modules/test_custom_statkind/test_custom_statkind--1.0.sql
 create mode 100644 src/test/modules/test_custom_statkind/test_custom_statkind.c
 create mode 100644 src/test/modules/test_custom_statkind/test_custom_statkind.control

diff --git a/doc/src/sgml/xfunc.sgml b/doc/src/sgml/xfunc.sgml
index 537ee6fa254..d50626523f2 100644
--- a/doc/src/sgml/xfunc.sgml
+++ b/doc/src/sgml/xfunc.sgml
@@ -3967,6 +3967,9 @@ static const PgStat_KindInfo custom_stats = {
     .shared_data_off = offsetof(PgStatShared_Custom, stats),
     .shared_data_len = sizeof(((PgStatShared_Custom *) 0)->stats),
     .pending_size = sizeof(PgStat_StatCustomEntry),
+    .to_serialized_extra_stats = custom_stats_serialize,
+    .from_serialized_extra_stats = custom_stats_deserialize,
+    .end_extra_stats = custom_stats_file_cleanup,
 }
 </programlisting>
 
@@ -4005,7 +4008,8 @@ extern PgStat_Kind pgstat_register_kind(PgStat_Kind kind,
 
     <para>
      An example describing how to register and use custom statistics can be
-     found in <filename>src/test/modules/injection_points</filename>.
+     found in <filename>src/test/modules/test_custom_statkind</filename> and
+     <filename>src/test/modules/injection_points</filename>
     </para>
    </sect2>
 
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index d079b91b1a2..d35e83296e6 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -21,6 +21,7 @@ SUBDIRS = \
 		  test_bloomfilter \
 		  test_copy_callbacks \
 		  test_custom_rmgrs \
+		  test_custom_statkind \
 		  test_ddl_deparse \
 		  test_dsa \
 		  test_dsm_registry \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index f5114469b92..4f19298b6ec 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -20,6 +20,7 @@ subdir('test_bitmapset')
 subdir('test_bloomfilter')
 subdir('test_copy_callbacks')
 subdir('test_custom_rmgrs')
+subdir('test_custom_statkind')
 subdir('test_ddl_deparse')
 subdir('test_dsa')
 subdir('test_dsm_registry')
diff --git a/src/test/modules/test_custom_statkind/.gitignore b/src/test/modules/test_custom_statkind/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/test_custom_statkind/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/test_custom_statkind/Makefile b/src/test/modules/test_custom_statkind/Makefile
new file mode 100644
index 00000000000..42f48e9c8f4
--- /dev/null
+++ b/src/test/modules/test_custom_statkind/Makefile
@@ -0,0 +1,23 @@
+# src/test/modules/test_custom_statkind/Makefile
+
+MODULE_big = test_custom_statkind
+OBJS = \
+	$(WIN32RES) \
+	test_custom_statkind.o
+PGFILEDESC = "test_custom_statkind - test code for custom stat kinds"
+
+EXTENSION = test_custom_statkind
+DATA = test_custom_statkind--1.0.sql
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_custom_statkind
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_custom_statkind/meson.build b/src/test/modules/test_custom_statkind/meson.build
new file mode 100644
index 00000000000..50dc4dfd387
--- /dev/null
+++ b/src/test/modules/test_custom_statkind/meson.build
@@ -0,0 +1,34 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+test_custom_statkind_sources = files(
+  'test_custom_statkind.c',
+)
+
+if host_system == 'windows'
+  test_custom_statkind_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_custom_statkind',
+    '--FILEDESC', 'test_custom_statkind - test code for custom stat kinds',])
+endif
+
+test_custom_statkind = shared_module('test_custom_statkind',
+  test_custom_statkind_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_custom_statkind
+
+test_install_data += files(
+  'test_custom_statkind.control',
+  'test_custom_statkind--1.0.sql',
+)
+
+tests += {
+  'name': 'test_custom_statkind',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_custom_stat.pl',
+    ],
+    'runningcheck': false,
+  },
+}
diff --git a/src/test/modules/test_custom_statkind/t/001_custom_stat.pl b/src/test/modules/test_custom_statkind/t/001_custom_stat.pl
new file mode 100644
index 00000000000..00327407292
--- /dev/null
+++ b/src/test/modules/test_custom_statkind/t/001_custom_stat.pl
@@ -0,0 +1,92 @@
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+
+#
+# Test custom statistics functionality
+#
+# This test verifies that custom statistics entries can be created,
+# incremented, reported, and persisted across server restarts.
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+use File::Copy;
+
+# Create a new PostgreSQL test cluster
+my $node = PostgreSQL::Test::Cluster->new('main');
+
+# Initialize the cluster and configure it to load our test module
+$node->init;
+$node->append_conf('postgresql.conf',
+	"shared_preload_libraries = test_custom_statkind");
+$node->start;
+
+# Create the extension to enable custom statistics functions
+$node->safe_psql('postgres', q(CREATE EXTENSION test_custom_statkind));
+
+# Create two custom statistics entries with descriptions
+$node->safe_psql('postgres', q(select pgstat_create_custom_stat('entry1', 'this is the description for entry1')));
+$node->safe_psql('postgres', q(select pgstat_create_custom_stat('entry2', 'this is the description for entry2')));
+
+# Increment the statistics counters:
+# entry1: 2 calls, entry2: 3 calls
+$node->safe_psql('postgres', q(select pgstat_call_custom_stat('entry1')));
+$node->safe_psql('postgres', q(select pgstat_call_custom_stat('entry1')));
+$node->safe_psql('postgres', q(select pgstat_call_custom_stat('entry2')));
+$node->safe_psql('postgres', q(select pgstat_call_custom_stat('entry2')));
+$node->safe_psql('postgres', q(select pgstat_call_custom_stat('entry2')));
+
+# Verify that the custom statistics are correctly stored and reported
+my $result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_stat('entry1')));
+is($result, "entry1|this is the description for entry1|2", "entry1 should have 2 calls with description");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_stat('entry2')));
+is($result, "entry2|this is the description for entry2|3", "entry2 should have 3 calls with description");
+
+# Test persistence across server restart
+# Perform a clean shutdown to ensure statistics are written to disk
+$node->stop();
+
+# Create a backup of the statistics file for later testing
+my $statsfile = $PostgreSQL::Test::Utils::tmp_check . '/' . "discard_custom_stats";
+ok(!-f "$statsfile", "backup statsfile should not already exist");
+
+# Locate and backup the original statistics file
+my $datadir = $node->data_dir();
+my $og_stats = "$datadir/pg_stat/test_custom_statkind.stat";
+ok(-f "$og_stats", "original stats file should exist after shutdown");
+copy($og_stats, $statsfile) or die "Copy failed: $!";
+
+# Restart the server to test statistics persistence
+$node->start();
+
+# Verify that statistics persisted across the restart
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_stat('entry1')));
+is($result, "entry1|this is the description for entry1|2", "entry1 stats should persist after restart");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_stat('entry2')));
+is($result, "entry2|this is the description for entry2|3", "entry2 stats should persist after restart");
+
+# Test statistics reset behavior
+# Perform an immediate shutdown (simulates crash) to prevent stats writing
+$node->stop('immediate');
+
+# Restore the backed up statistics file and restart
+copy($statsfile, $og_stats) or die "Copy failed: $!";
+$node->start;
+
+# After immediate shutdown, the stats file should be cleaned up
+ok(!-f "$og_stats", "stats file should be removed after immediate shutdown recovery");
+
+# Verify that statistics are reset (no descriptions, zero counts)
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_stat('entry1')));
+is($result, "entry1||0", "entry1 should be reset after stats file cleanup");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_stat('entry2')));
+is($result, "entry2||0", "entry2 should be reset after stats file cleanup");
+
+# Test completed successfully
+done_testing();
\ No newline at end of file
diff --git a/src/test/modules/test_custom_statkind/test_custom_statkind--1.0.sql b/src/test/modules/test_custom_statkind/test_custom_statkind--1.0.sql
new file mode 100644
index 00000000000..8b085206ac8
--- /dev/null
+++ b/src/test/modules/test_custom_statkind/test_custom_statkind--1.0.sql
@@ -0,0 +1,19 @@
+/* src/test/modules/test_custom_statkind/test_custom_statkind--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_custom_statkind" to load this file. \quit
+
+CREATE FUNCTION pgstat_create_custom_stat(IN name TEXT, IN description text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_create_custom_stat'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+CREATE FUNCTION pgstat_call_custom_stat(IN name TEXT)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_call_custom_stat'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+CREATE FUNCTION pgstat_report_custom_stat(INOUT name TEXT , OUT description TEXT, OUT calls BIGINT)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pgstat_report_custom_stat'
+LANGUAGE C STRICT PARALLEL UNSAFE;
diff --git a/src/test/modules/test_custom_statkind/test_custom_statkind.c b/src/test/modules/test_custom_statkind/test_custom_statkind.c
new file mode 100644
index 00000000000..bc9077f80a6
--- /dev/null
+++ b/src/test/modules/test_custom_statkind/test_custom_statkind.c
@@ -0,0 +1,570 @@
+/*--------------------------------------------------------------------------
+ *
+ * test_custom_statkind.c
+ *		Test module for pgstats
+ *
+ * Copyright (c) 2024-2025, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		src/test/modules/test_custom_statkind/test_custom_statkind.c
+ *
+ * -------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "funcapi.h"
+#include "pgstat.h"
+#include "storage/dsm_registry.h"
+#include "utils/builtins.h"
+#include "utils/guc.h"
+#include "utils/pgstat_internal.h"
+
+PG_MODULE_MAGIC_EXT(
+					.name = "test_custom_statkind",
+					.version = PG_VERSION
+);
+
+/*--------------------------------------------------------------------------
+ * Macros and constants
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * Kind ID reserved for test_custom_statkind. This re-uses the same ID as
+ * Injection points to avoid reserving another kind id.
+ */
+#define PGSTAT_KIND_TEST_CUSTOM_STATKIND 25
+
+/*
+ * Compute statistics entry index from point name using an 8-byte hash.
+ */
+#define PGSTAT_CUSTOM_STAT_IDX(name) hash_bytes_extended((const unsigned char *) name, strlen(name), 0)
+#define PGSTAT_CUSTOM_EXTRA_DATA_DESC "pg_stat/test_custom_statkind.stat"
+
+/*--------------------------------------------------------------------------
+ * Type definitions
+ *--------------------------------------------------------------------------
+ */
+
+/* Local statistics entry for pending data */
+typedef struct PgStat_StatCustomEntry
+{
+	PgStat_Counter numcalls;	/* number of times the entry has been looked
+								 * up */
+}			PgStat_StatCustomEntry;
+
+/* Shared memory statistics entry */
+typedef struct PgStatShared_CustomEntry
+{
+	PgStatShared_Common header;
+	PgStat_StatCustomEntry stats;
+	char		name[NAMEDATALEN];
+	dsa_pointer description;
+}			PgStatShared_CustomEntry;
+
+/*--------------------------------------------------------------------------
+ * Global variables
+ *--------------------------------------------------------------------------
+ */
+
+/* Flag indicating if custom statistics kind is loaded */
+static bool pgstat_custom_kind_loaded = false;
+
+/* File handle for statistics serialization */
+static FILE *fd = NULL;
+
+/* DSA area to store custom statistics descriptions */
+dsa_area   *custom_stats_description_dsa = NULL;
+
+/*--------------------------------------------------------------------------
+ * Function prototypes
+ *--------------------------------------------------------------------------
+ */
+
+/* Statistics callback functions */
+static bool pgstat_custom_entry_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
+static void pgstat_custom_entry_extra_serialize(const PgStat_HashKey *key,
+												const PgStatShared_Common *header, FILE *statfile);
+static void pgstat_custom_entry_extra_deserialize(const PgStat_HashKey *key,
+												  const PgStatShared_Common *header, FILE *statfile);
+static void pgstat_custom_entry_end_extra(PgStat_StatsFileOp status);
+
+/*--------------------------------------------------------------------------
+ * Custom kind configuration
+ *--------------------------------------------------------------------------
+ */
+
+static const PgStat_KindInfo custom_stats = {
+	.name = "test_custom_pgstat",
+	.fixed_amount = false,		/* Bounded by the number of points */
+	.write_to_file = true,
+	.track_entry_count = true,
+	.accessed_across_databases = true,	/* System-wide statistics */
+	.shared_size = sizeof(PgStatShared_CustomEntry),
+	.shared_data_off = offsetof(PgStatShared_CustomEntry, stats),
+	.shared_data_len = sizeof(((PgStatShared_CustomEntry *) 0)->stats),
+	.pending_size = sizeof(PgStat_StatCustomEntry),
+	.flush_pending_cb = pgstat_custom_entry_flush_cb,
+	.to_serialized_extra_stats = pgstat_custom_entry_extra_serialize,
+	.from_serialized_extra_stats = pgstat_custom_entry_extra_deserialize,
+	.end_extra_stats = pgstat_custom_entry_end_extra,
+};
+
+/*--------------------------------------------------------------------------
+ * Module initialization
+ *--------------------------------------------------------------------------
+ */
+
+void
+_PG_init(void)
+{
+	if (!process_shared_preload_libraries_in_progress)
+		return;
+
+	pgstat_register_kind(PGSTAT_KIND_TEST_CUSTOM_STATKIND, &custom_stats);
+
+	pgstat_custom_kind_loaded = true;
+}
+
+/*--------------------------------------------------------------------------
+ * Statistics callback functions
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * pgstat_custom_entry_flush_cb() -
+ *
+ * Flush callback for custom statistics entries. Merges pending statistics
+ * data from local memory into shared memory.
+ *
+ * Returns true if the flush was successful, false if we couldn't acquire
+ * the necessary locks (when nowait is true).
+ */
+static bool
+pgstat_custom_entry_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
+{
+	PgStat_StatCustomEntry *localent;
+	PgStatShared_CustomEntry *shfuncent;
+
+	localent = (PgStat_StatCustomEntry *) entry_ref->pending;
+	shfuncent = (PgStatShared_CustomEntry *) entry_ref->shared_stats;
+
+	if (!pgstat_lock_entry(entry_ref, nowait))
+		return false;
+
+	shfuncent->stats.numcalls += localent->numcalls;
+
+	pgstat_unlock_entry(entry_ref);
+
+	return true;
+}
+
+/*
+ * pgstat_custom_entry_extra_serialize() -
+ *
+ * Serialize extra data (descriptions) for custom statistics entries to
+ * the statistics file. Called during statistics file writing to preserve
+ * description strings across restarts.
+ */
+static void
+pgstat_custom_entry_extra_serialize(const PgStat_HashKey *key,
+									const PgStatShared_Common *header, FILE *statfile)
+{
+	char	   *description;
+	size_t		qlen;
+	PgStatShared_CustomEntry *entry = (PgStatShared_CustomEntry *) header;
+	bool		found;
+
+	if (!pgstat_custom_kind_loaded)
+		return;
+
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("pgstat_custom_stat_dsa", &found);
+
+	/* Exit early if DSA is not available */
+	if (!custom_stats_description_dsa)
+		return;
+
+	/* Open statistics file for writing if not already open */
+	if (!fd)
+	{
+		fd = AllocateFile(PGSTAT_CUSTOM_EXTRA_DATA_DESC, PG_BINARY_W);
+		if (fd == NULL)
+		{
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not open statistics file \"%s\" for writing: %m",
+							PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+			return;
+		}
+	}
+
+	/* Write the hash key to identify this entry */
+	pgstat_write_chunk(fd, (void *) key, sizeof(PgStat_HashKey));
+
+	/* Handle entries without descriptions */
+	if (!DsaPointerIsValid(entry->description))
+	{
+		fputc('\0', fd);		/* Write null terminator for empty description */
+		return;
+	}
+
+	if (!custom_stats_description_dsa)
+	{
+		fputc('\0', fd);		/* Write null terminator if DSA unavailable */
+		return;
+	}
+
+	/* Retrieve description from DSA and write to file */
+	description = dsa_get_address(custom_stats_description_dsa, entry->description);
+	qlen = strlen(description) + 1; /* include null terminator */
+
+	pgstat_write_chunk(fd, description, qlen);
+}
+
+/*
+ * pgstat_custom_entry_extra_deserialize() -
+ *
+ * Deserialize extra data (descriptions) for custom statistics entries from
+ * the statistics file. Called during statistics file reading to restore
+ * description strings after a restart.
+ */
+static void
+pgstat_custom_entry_extra_deserialize(const PgStat_HashKey *key,
+									  const PgStatShared_Common *header, FILE *statfile)
+{
+	PgStatShared_CustomEntry *entry;
+	dsa_pointer dp;
+	size_t		bufsize;
+	size_t		len;
+	char	   *buffer;
+	int			c;
+	bool		found;
+
+	if (!pgstat_custom_kind_loaded)
+		return;
+
+	/* Initialize DSA if needed */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("pgstat_custom_stat_dsa", &found);
+
+	if (!custom_stats_description_dsa)
+		return;
+
+	/* Open statistics file for reading if not already open */
+	if (!fd)
+	{
+		fd = AllocateFile(PGSTAT_CUSTOM_EXTRA_DATA_DESC, PG_BINARY_R);
+		if (fd == NULL)
+		{
+			if (errno != ENOENT)
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not open statistics file \"%s\" for reading: %m",
+								PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+			/* Reset statistics if file is missing or unreadable */
+			pgstat_reset_of_kind(PGSTAT_KIND_TEST_CUSTOM_STATKIND);
+			return;
+		}
+	}
+
+	/* Read and verify the hash key */
+	if (!pgstat_read_chunk(fd, (void *) key, sizeof(PgStat_HashKey)))
+		return;
+
+	entry = (PgStatShared_CustomEntry *) header;
+
+	/* Read null-terminated description string from file */
+	bufsize = 128;
+	len = 0;
+	buffer = palloc(bufsize);
+
+	/* Read description character by character until null terminator */
+	while ((c = fgetc(fd)) != EOF)
+	{
+		/* Expand buffer if needed */
+		if (len + 1 >= bufsize)
+		{
+			bufsize *= 2;
+			buffer = repalloc(buffer, bufsize);
+		}
+
+		buffer[len++] = (char) c;
+
+		if (c == '\0')
+			break;
+	}
+
+	/* Handle unexpected EOF */
+	if (c == EOF)
+	{
+		pfree(buffer);
+		return;
+	}
+
+	/* Allocate space in DSA and copy the description */
+	dp = dsa_allocate(custom_stats_description_dsa, len);
+
+	memcpy(dsa_get_address(custom_stats_description_dsa, dp), buffer, len);
+	entry->description = dp;
+
+	pfree(buffer);
+}
+
+/*
+ * pgstat_custom_entry_end_extra() -
+ *
+ * Cleanup function called at the end of statistics file operations.
+ * Handles closing files and cleanup based on the operation type.
+ */
+static void
+pgstat_custom_entry_end_extra(PgStat_StatsFileOp status)
+{
+	if (!pgstat_custom_kind_loaded)
+		return;
+
+	/* Handle cleanup after writing statistics */
+	if (status == STATS_WRITE)
+	{
+		if (!fd)
+			return;
+
+		/* Check for write errors and cleanup if necessary */
+		if (ferror(fd))
+		{
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not write statistics file \"%s\": %m",
+							PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+			FreeFile(fd);
+			unlink(PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+		}
+		else if (FreeFile(fd) < 0)
+		{
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not close statistics file \"%s\": %m",
+							PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+			unlink(PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+		}
+	}
+	/* Handle cleanup after reading statistics */
+	else if (status == STATS_READ)
+	{
+		if (!fd)
+			return;
+
+		FreeFile(fd);
+
+		/* Remove the temporary statistics file after reading */
+		elog(DEBUG2, "removing statistics file \"%s\"", PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+		unlink(PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+	}
+	/* Handle other cleanup operations (reset, etc.) */
+	else
+	{
+		int			ret;
+
+		/* Attempt to remove the statistics file */
+		ret = unlink(PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+		if (ret != 0)
+		{
+			if (errno == ENOENT)
+				elog(LOG,
+					 "didn't need to unlink permanent stats file \"%s\" - didn't exist",
+					 PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+			else
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not unlink permanent statistics file \"%s\": %m",
+								PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+		}
+		else
+		{
+			ereport(LOG,
+					(errmsg_internal("unlinked permanent statistics file \"%s\"",
+									 PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+		}
+	}
+
+	fd = NULL;
+}
+
+/*--------------------------------------------------------------------------
+ * SQL-callable functions
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * pgstat_create_custom_stat() -
+ *
+ * SQL-callable function to create a new custom statistics entry.
+ * Initializes shared memory structure for tracking statistics with
+ * a name and optional description.
+ */
+PG_FUNCTION_INFO_V1(pgstat_create_custom_stat);
+Datum
+pgstat_create_custom_stat(PG_FUNCTION_ARGS)
+{
+	PgStat_EntryRef *entry_ref;
+	bool		found;
+	char	   *desc_copy;
+	size_t		len;
+	PgStatShared_CustomEntry *shstatent;
+	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	char	   *description = text_to_cstring(PG_GETARG_TEXT_PP(1));
+
+	if (!pgstat_custom_kind_loaded)
+		PG_RETURN_VOID();
+
+	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_TEST_CUSTOM_STATKIND, InvalidOid,
+											PGSTAT_CUSTOM_STAT_IDX(name), true);
+
+	if (!entry_ref)
+		PG_RETURN_VOID();
+
+	shstatent = (PgStatShared_CustomEntry *) entry_ref->shared_stats;
+
+	/* Initialize shared memory statistics data to zero */
+	memset(&shstatent->stats, 0, sizeof(shstatent->stats));
+
+	/* Validate and store the statistic name */
+	if (strlen(name) >= NAMEDATALEN)
+		ereport(ERROR,
+				(errcode(ERRCODE_NAME_TOO_LONG),
+				 errmsg("custom statistic name \"%s\" is too long", name),
+				 errdetail("Name must be less than %d characters.", NAMEDATALEN)));
+
+	strcpy(shstatent->name, name);
+
+	/* Store description in DSA if provided */
+	if (description)
+	{
+		len = strlen(description) + 1;
+
+		/* Initialize DSA for descriptions if not already done */
+		if (!custom_stats_description_dsa)
+			custom_stats_description_dsa = GetNamedDSA("pgstat_custom_stat_dsa", &found);
+
+		if (custom_stats_description_dsa)
+		{
+			/* Allocate space in DSA and copy description */
+			shstatent->description = dsa_allocate(custom_stats_description_dsa, len);
+			if (!DsaPointerIsValid(shstatent->description))
+			{
+				/* DSA allocation failed, continue without description */
+				shstatent->description = InvalidDsaPointer;
+			}
+			else
+			{
+				desc_copy = dsa_get_address(custom_stats_description_dsa,
+											shstatent->description);
+				memcpy(desc_copy, description, len);
+			}
+		}
+	}
+
+	pgstat_unlock_entry(entry_ref);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_call_custom_stat() -
+ *
+ * SQL-callable function to increment the call count for a custom statistic.
+ * This is typically called from user code to track usage or events.
+ */
+PG_FUNCTION_INFO_V1(pgstat_call_custom_stat);
+Datum
+pgstat_call_custom_stat(PG_FUNCTION_ARGS)
+{
+	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	PgStat_EntryRef *entry_ref;
+	PgStat_StatCustomEntry *pending;
+
+	if (!pgstat_custom_kind_loaded)
+		PG_RETURN_VOID();
+
+	/* Get the pending statistics entry for this custom stat */
+	entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_TEST_CUSTOM_STATKIND, InvalidOid,
+										  PGSTAT_CUSTOM_STAT_IDX(name), NULL);
+
+	pending = (PgStat_StatCustomEntry *) entry_ref->pending;
+
+	/* Increment the call counter in local pending stats */
+	pending->numcalls++;
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_report_custom_stat() -
+ *
+ * SQL-callable function to retrieve statistics for a custom statistic.
+ * Returns a composite type containing the name, description, and call count.
+ */
+PG_FUNCTION_INFO_V1(pgstat_report_custom_stat);
+Datum
+pgstat_report_custom_stat(PG_FUNCTION_ARGS)
+{
+	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	PgStat_EntryRef *entry_ref;
+	PgStatShared_CustomEntry *shfuncent;
+	Datum		values[3];
+	bool		nulls[3] = {false, false, false};
+	TupleDesc	tupdesc;
+	TupleDesc	expected;
+	bool		found;
+
+	if (!pgstat_custom_kind_loaded)
+		PG_RETURN_NULL();
+
+	/* Initialize DSA for descriptions if needed */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa =
+			GetNamedDSA("pgstat_custom_stat_dsa", &found);
+
+	/* Return NULL if DSA is not available */
+	if (!custom_stats_description_dsa)
+		PG_RETURN_NULL();
+
+	/* Get the return tuple descriptor from pg_proc */
+	if (get_call_result_type(fcinfo, NULL, &expected) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "pgstat_report_custom_stat: return type is not composite");
+
+	tupdesc = BlessTupleDesc(expected);
+
+	/* Look up the statistics entry in shared memory */
+	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_TEST_CUSTOM_STATKIND,
+											InvalidOid,
+											PGSTAT_CUSTOM_STAT_IDX(name),
+											true);
+
+	if (entry_ref)
+	{
+		char	   *desc_copy = NULL;
+
+		shfuncent = (PgStatShared_CustomEntry *) entry_ref->shared_stats;
+
+		/* Build the return tuple with name, description, and call count */
+		values[0] = CStringGetTextDatum(name);	/* name */
+		if (DsaPointerIsValid(shfuncent->description))
+		{
+			/* Get description from DSA */
+			desc_copy = dsa_get_address(custom_stats_description_dsa,
+										shfuncent->description);
+
+			values[1] = CStringGetTextDatum(desc_copy); /* description */
+		}
+		else
+			nulls[1] = true;	/* no description available */
+
+		values[2] = Int64GetDatum(shfuncent->stats.numcalls);	/* calls */
+	}
+
+	pgstat_unlock_entry(entry_ref);
+
+	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
+}
diff --git a/src/test/modules/test_custom_statkind/test_custom_statkind.control b/src/test/modules/test_custom_statkind/test_custom_statkind.control
new file mode 100644
index 00000000000..7ce22cc195f
--- /dev/null
+++ b/src/test/modules/test_custom_statkind/test_custom_statkind.control
@@ -0,0 +1,4 @@
+comment = 'Test code for custom stat kinds'
+default_version = '1.0'
+module_pathname = '$libdir/test_custom_statkind'
+relocatable = true
-- 
2.43.0

v2-0001-pgstat-support-custom-serialization-files-and-cal.patchapplication/octet-stream; name=v2-0001-pgstat-support-custom-serialization-files-and-cal.patchDownload
From b569f8b0a3a886cf759e9ac8d2376eadb45b6c29 Mon Sep 17 00:00:00 2001
From: Sami Imseih <simseih@amazon.com>
Date: Mon, 10 Nov 2025 00:03:41 -0600
Subject: [PATCH v2 1/2] pgstat: support custom serialization files and
 callbacks

Allow custom statistics kinds to serialize and deserialize extra
per-entry data, supporting kinds with variable auxiliary data that
cannot fit in shared-memory.

To allow this, 3 callbacks are provided to serialize, deserialize
and to clean-up resources when writing, reading and discarding
entries. The latter is required for crash recovery scenarios.
---
 src/backend/utils/activity/pgstat.c | 61 ++++++++++++++++++++++++++++-
 src/include/utils/pgstat_internal.h | 33 ++++++++++++++++
 2 files changed, 92 insertions(+), 2 deletions(-)

diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index 8713c7a0483..117728db016 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -523,6 +523,7 @@ pgstat_discard_stats(void)
 
 	/* NB: this needs to be done even in single user mode */
 
+	/* First, cleanup the core stats file */
 	ret = unlink(PGSTAT_STAT_PERMANENT_FILENAME);
 	if (ret != 0)
 	{
@@ -544,6 +545,15 @@ pgstat_discard_stats(void)
 								 PGSTAT_STAT_PERMANENT_FILENAME)));
 	}
 
+	/* Now, cleanup every custom kinds extra stats files */
+	for (PgStat_Kind kind = PGSTAT_KIND_CUSTOM_MIN; kind <= PGSTAT_KIND_CUSTOM_MAX; kind++)
+	{
+		const PgStat_KindInfo *kind_info = pgstat_get_kind_info(kind);
+
+		if (kind_info && kind_info->end_extra_stats)
+			kind_info->end_extra_stats(STATS_DISCARD);
+	}
+
 	/*
 	 * Reset stats contents. This will set reset timestamps of fixed-numbered
 	 * stats to the current time (no variable stats exist).
@@ -1463,6 +1473,7 @@ pgstat_get_kind_info(PgStat_Kind kind)
 void
 pgstat_register_kind(PgStat_Kind kind, const PgStat_KindInfo *kind_info)
 {
+	bool		has_extra = false;
 	uint32		idx = kind - PGSTAT_KIND_CUSTOM_MIN;
 
 	if (kind_info->name == NULL || strlen(kind_info->name) == 0)
@@ -1525,6 +1536,26 @@ pgstat_register_kind(PgStat_Kind kind, const PgStat_KindInfo *kind_info)
 					 errdetail("Existing cumulative statistics with ID %u has the same name.", existing_kind)));
 	}
 
+	/*
+	 * Ensure that to_serialized_extra_stats, from_serialized_extra_stats and
+	 * end_extra_stats are registered together or not at all.
+	 */
+	has_extra =
+		kind_info->to_serialized_extra_stats ||
+		kind_info->from_serialized_extra_stats ||
+		kind_info->end_extra_stats;
+
+	if (has_extra &&
+		(!kind_info->to_serialized_extra_stats ||
+		 !kind_info->from_serialized_extra_stats ||
+		 !kind_info->end_extra_stats))
+	{
+		ereport(ERROR,
+				(errmsg("could not register custom cumulative statistics \"%s\" with ID %u",
+						kind_info->name, kind),
+				 errdetail("callbacks to_serialized_extra, from_serialized_extra, and end_extra_stats must all be provided together.")));
+	}
+
 	/* Register it */
 	pgstat_kind_custom_infos[idx] = kind_info;
 	ereport(LOG,
@@ -1702,6 +1733,9 @@ pgstat_write_statsfile(void)
 		pgstat_write_chunk(fpout,
 						   pgstat_get_entry_data(ps->key.kind, shstats),
 						   pgstat_get_entry_len(ps->key.kind));
+
+		if (pgstat_is_kind_custom(ps->key.kind) && kind_info->to_serialized_extra_stats)
+			kind_info->to_serialized_extra_stats(&ps->key, shstats, fpout);
 	}
 	dshash_seq_term(&hstat);
 
@@ -1734,6 +1768,15 @@ pgstat_write_statsfile(void)
 		/* durable_rename already emitted log message */
 		unlink(tmpfile);
 	}
+
+	/* Now, allow the extension to finalize the writes for the extra files */
+	for (PgStat_Kind kind = PGSTAT_KIND_CUSTOM_MIN; kind <= PGSTAT_KIND_CUSTOM_MAX; kind++)
+	{
+		const PgStat_KindInfo *kind_info = pgstat_get_kind_info(kind);
+
+		if (kind_info && kind_info->end_extra_stats)
+			kind_info->end_extra_stats(STATS_WRITE);
+	}
 }
 
 /* helper for pgstat_read_statsfile() */
@@ -1871,6 +1914,7 @@ pgstat_read_statsfile(void)
 					PgStat_HashKey key;
 					PgStatShared_HashEntry *p;
 					PgStatShared_Common *header;
+					const PgStat_KindInfo *kind_info = NULL;
 
 					CHECK_FOR_INTERRUPTS();
 
@@ -1891,7 +1935,8 @@ pgstat_read_statsfile(void)
 							goto error;
 						}
 
-						if (!pgstat_get_kind_info(key.kind))
+						kind_info = pgstat_get_kind_info(key.kind);
+						if (!kind_info)
 						{
 							elog(WARNING, "could not find information of kind for entry %u/%u/%" PRIu64 " of type %c",
 								 key.kind, key.dboid,
@@ -1902,7 +1947,6 @@ pgstat_read_statsfile(void)
 					else
 					{
 						/* stats entry identified by name on disk (e.g. slots) */
-						const PgStat_KindInfo *kind_info = NULL;
 						PgStat_Kind kind;
 						NameData	name;
 
@@ -1996,6 +2040,9 @@ pgstat_read_statsfile(void)
 						goto error;
 					}
 
+					if (pgstat_is_kind_custom(key.kind) && kind_info->from_serialized_extra_stats)
+						kind_info->from_serialized_extra_stats(&key, header, fpin);
+
 					break;
 				}
 			case PGSTAT_FILE_ENTRY_END:
@@ -2019,11 +2066,21 @@ pgstat_read_statsfile(void)
 	}
 
 done:
+	/* first, cleanup the core stats file */
 	FreeFile(fpin);
 
 	elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
 	unlink(statfile);
 
+	/* Now, cleanup every custom kinds extra stats files */
+	for (PgStat_Kind kind = PGSTAT_KIND_CUSTOM_MIN; kind <= PGSTAT_KIND_CUSTOM_MAX; kind++)
+	{
+		const PgStat_KindInfo *kind_info = pgstat_get_kind_info(kind);
+
+		if (kind_info && kind_info->end_extra_stats)
+			kind_info->end_extra_stats(STATS_READ);
+	}
+
 	return;
 
 error:
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index ca1ba6420ca..25bba6e98d4 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -63,6 +63,20 @@ typedef struct PgStat_HashKey
 								 * identifier. */
 } PgStat_HashKey;
 
+/*
+ * Tracks if the stats file is being read, written or discarded.
+ *
+ * These states allow plugins that create extra statistics files
+ * to determine the current operation and perform any necessary
+ * file cleanup.
+ */
+typedef enum PgStat_StatsFileOp
+{
+	STATS_WRITE,
+	STATS_READ,
+	STATS_DISCARD,
+}			PgStat_StatsFileOp;
+
 /*
  * PgStat_HashKey should not have any padding.  Checking that the structure
  * size matches with the sum of each field is a check simple enough to
@@ -303,6 +317,25 @@ typedef struct PgStat_KindInfo
 									   const PgStatShared_Common *header, NameData *name);
 	bool		(*from_serialized_name) (const NameData *name, PgStat_HashKey *key);
 
+	/*
+	 * Optional callbacks for kinds that write additional per-entry data to
+	 * the stats file.  If any of these callbacks are provided, all three must
+	 * be provided to ensure that the reader consumes exactly the data written
+	 * by the writer.
+	 *
+	 * to_serialized_extra_stats: write extra data for an entry.
+	 *
+	 * from_serialized_extra_stats: read the extra data for an entry.
+	 *
+	 * end_extra_stats: invoked once per operation (read, write, discard)
+	 * after all entries of this kind have been processed.
+	 */
+	void		(*to_serialized_extra_stats) (const PgStat_HashKey *key,
+											  const PgStatShared_Common *header, FILE *statfile);
+	void		(*from_serialized_extra_stats) (const PgStat_HashKey *key,
+												const PgStatShared_Common *header, FILE *statfile);
+	void		(*end_extra_stats) (PgStat_StatsFileOp status);
+
 	/*
 	 * For fixed-numbered statistics: Initialize shared memory state.
 	 *
-- 
2.43.0

#16Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#15)
Re: [Proposal] Adding callback support for custom statistics kinds

On Tue, Dec 02, 2025 at 02:58:32PM -0600, Sami Imseih wrote:

By "template" code, do you mean Something like?

include/utils/custom_statkinds.h
backend/utils/misc/custom_statkinds.c

Where the template code here is PgStat_kind definition, callbacks, etc. for
injection_points or the new test module that is using a custom kind.

I am not completely sure that we need a separate C file for a portion
of the code related to custom stats kinds. At least, I am not sure to
see which part of pgstat.c, pgstat_internal.h and pgstat_shmem.c could
be extracted so as a custom_statkinds.c could have value when taken
independently. A test module makes the most sense for such templates
IMO, as they can be compiled and checked directly.

v2-0001 are much simplified changes to pgstat.c that simply invoke the callbacks
and all the work is on the extension to implement what it needs to do.
This includes
a callback at the end of WRITE, READ, DISCARD with a flag passed to the caller
so they can perform the necessary clean-up actions.

+	void		(*to_serialized_extra_stats) (const PgStat_HashKey *key,
+											  const PgStatShared_Common *header, FILE *statfile);
+	void		(*from_serialized_extra_stats) (const PgStat_HashKey *key,
+												const PgStatShared_Common *header, FILE *statfile);
+	void		(*end_extra_stats) (PgStat_StatsFileOp status);
[...]
+typedef struct PgStatShared_CustomEntry
+{
+	PgStatShared_Common header;
+	PgStat_StatCustomEntry stats;
+	char		name[NAMEDATALEN];
+	dsa_pointer description;
+}			PgStatShared_CustomEntry;

I'm cool with this design, including your point about using a DSA
pointer in a stats entry, manipulating this data through the
serialization callback. Your module does not use the FILE* which
points to the main stats file for the to/from extra serialized
callbacks, it seems important to document in pgstat_internal.h that
this points to the "main" pgstats file manipulated by the startup
process when loading or by the checkpointer when flushing.

Perhaps the callback in the module for end_extra_stats should use a
switch based on PgStat_StatsFileOp. Minor point.

+/* File handle for statistics serialization */
+static FILE *fd = NULL;

Using a fd tracked directly by the module sounds good to me, yes.
That gives to the modules the flexibility to decide what should be the
extra files to know about, some file name patterns being possible to
decide based on the stats entry keys that need to be written, with
files opened when actually required.

v2-0002 implements a new test module that tests mainly that the recovery,
clean and crash, are working as expected.

That looks like a good direction to me. The only differences I can
see with the stats module in injection_points for variable-sized stats
is that this new module does not check pgstat_drop_entry() and
pgstat_fetch_entry() when working on a custom stats kind. If we had
SQL interfaces calling these two, we could just remove
injection_stats.c entirely, moving everything to this new test module.
I should have invented a new module from the start, perhaps, but well,
that was good enough to check the basic APIs when working on the
custom APIs. Removing this duplication would be my own business with
your module in the tree, no need for you to worry about that. That
would also remove the tweak you have used regarding the duplicated
kind ID.

Perhaps we should do the same for the fixed-sized kind at the end, and
instead of using one .so for both of them, we could just create a
separate .so with multiple entries in MODULES? What do you think?
What you have here is better than what's in the tree in terms of
module separation for HEAD.

I created a new tap test for this which performs a test similar to what is
done in recovery/029_stats_restart.pl. I could merge the new test there, but
I am reluctant to add a dependency on a new module to recovery. What
do you think?

Adding an extra item to recovery's EXTRA_INSTALL would be OK for me,
but it seems cleaner to me to keep the tests related to custom stats
in their own area like your patch 0002 is doing with its new test
module test_custom_statkind. And 029_stats_restart.pl is already
covering a lot of ground.

+     if (pgstat_is_kind_custom(key.kind) && kind_info->from_serialized_extra_stats)
+         kind_info->from_serialized_extra_stats(&key, header, fpin);
[...]
+     if (pgstat_is_kind_custom(ps->key.kind) && kind_info->to_serialized_extra_stats)
+         kind_info->to_serialized_extra_stats(&ps->key, shstats, fpout);

These restrictions based on custom kinds do not seem mandatory.
Why not allowing built-in kinds the same set of operations?

+	/* Read and verify the hash key */
+	if (!pgstat_read_chunk(fd, (void *) key, sizeof(PgStat_HashKey)))
+		return;
[...]
+	/* Write the hash key to identify this entry */
+	pgstat_write_chunk(fd, (void *) key, sizeof(PgStat_HashKey));

I am puzzled by this part of 0002. Why are you overwriting the key
once after loading it from the main pgstats file? Writing the key to
cross-check that the data matches with what is in the main file is OK,
and this should be ensured because of the ordering of the data. I
would have done it in a slightly different way, I guess, with the data
stored on disk in the main pgstats file including an offset to know
where to search in the secondary file. That's what we would do for
PGSS as well, I guess, with the secondary file including data
structured as a set of:
- Entry key, cross-checked with the data read from the main file,
based on the offset stored in the main file.
- Length of extra data.
- The extra data contents.

As a whole, I find this patch pretty cool, particularly the point
about extending stats entries with DSAs, something that would be
essential for PGSS and move it to use pgstats because we don't want
the query strings in the main pgstats file and bloat it. Nice.
--
Michael

#17Chao Li
li.evan.chao@gmail.com
In reply to: Sami Imseih (#15)
Re: [Proposal] Adding callback support for custom statistics kinds

On Dec 3, 2025, at 04:58, Sami Imseih <samimseih@gmail.com> wrote:

Also, I am now leaning towards creating a separate test module rather than
trying to do too much unrelated testing in injection points. It is definitely
convenient to use injection points, but I think we can do better testing with
a separate module. This module can also serve as an example for extension
developers.

You are right that it may be cleaner this way. Do you think that it
could make sense to move some of the existing "template" code of
injection_points there?

By "template" code, do you mean Something like?

include/utils/custom_statkinds.h
backend/utils/misc/custom_statkinds.c

Where the template code here is PgStat_kind definition, callbacks, etc. for
injection_points or the new test module that is using a custom kind.

A few benefits I see for this is we can point extension developers to
this as an example in [0] and we are also maintaining the kind ids in
a single place. These may not be strong points, but may be worth while.

v2 attached is something that may be closer to what we've been discussing

v2-0001 are much simplified changes to pgstat.c that simply invoke the callbacks
and all the work is on the extension to implement what it needs to do.
This includes
a callback at the end of WRITE, READ, DISCARD with a flag passed to the caller
so they can perform the necessary clean-up actions.

v2-0002 implements a new test module that tests mainly that the recovery,
clean and crash, are working as expected.

I created a new tap test for this which performs a test similar to what is
done in recovery/029_stats_restart.pl. I could merge the new test there, but
I am reluctant to add a dependency on a new module to recovery. What
do you think?

[0] https://www.postgresql.org/docs/current/xfunc-c.html#XFUNC-ADDIN-CUSTOM-CUMULATIVE-STATISTICS

--
Sami Imseih
Amazon Web Services (AWS)
<v2-0002-Tests-for-custom-stat-kinds.patch><v2-0001-pgstat-support-custom-serialization-files-and-cal.patch>

Thanks for the patch, I do think the feature will be useful. After reading the patch, I got a concern on the design:

This patch provides callbacks that requests (also allows) custom extensions to write stat files on their own behalf, which I think it’s unsafe. The problems coming out to my head includes:

* An extension can write to any where on the storage, that what about it writes to /tmp and the files are deleted by other process or by a user manually incidentally?
* pgstat has a pattern of writing files like: writing to tmp file first, then durable_rename(), how to ensure extensions to do the same pattern? Without this pattern, how to ensure reliability of stat files?
* In the current path, pgstat performs its own write, then call callbacks. What about if a callback fails? Will that leave pgstat in a stale state?
* As extensions own file creation and deletion, in some case, staled file might be left on storage, who will be responsible for cleaning up them?

Given the goal of the feature is to allow extensions to serialize custom data, the callback should just return serialized/deserialized data, maybe together with some metadata, then pgstat should be responsible for writing the data. In other words, IMO, pgstat should always own stat files.

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

#18Michael Paquier
michael@paquier.xyz
In reply to: Chao Li (#17)
Re: [Proposal] Adding callback support for custom statistics kinds

On Wed, Dec 03, 2025 at 01:41:44PM +0800, Chao Li wrote:

Thanks for the patch, I do think the feature will be useful. After reading the patch, I got a concern on the design:

This patch provides callbacks that requests (also allows) custom
extensions to write stat files on their own behalf, which I think
it’s unsafe. The problems coming out to my head includes:

* An extension can write to any where on the storage, that what
* about it writes to /tmp and the files are deleted by other process
* or by a user manually incidentally?

I mean, just don't do that. It's up to the extension developer to
decide what is safe or not, within the scope of the data folder.

* pgstat has a pattern of writing files like: writing to tmp file
* first, then durable_rename(), how to ensure extensions to do the
* same pattern? Without this pattern, how to ensure reliability of
* stat files?

Extension code would be responsible for ensuring that.

* In the current path, pgstat performs its own write, then call
* callbacks. What about if a callback fails? Will that leave pgstat
* in a stale state?

For the write state, end_extra_stats() would take care of that. It
depends on what kind of errors you would need to deal with, but as
proposed the patch would offer the same level of protection for the
writes of the stats, where we'd check for an error on the fd saved by
an extension for an extra file.

I think that you have a fair point about the stats read path though,
shouldn't we make the callback from_serialized_extra_stats() return a
state to be able to trigger a full-scale cleanup, at least?

* As extensions own file creation and deletion, in some case, staled
* file might be left on storage, who will be responsible for
* cleaning up them?

The extension should be able to handle that, I guess.

Given the goal of the feature is to allow extensions to serialize
custom data, the callback should just return serialized/deserialized
data, maybe together with some metadata, then pgstat should be
responsible for writing the data. In other words, IMO, pgstat should
always own stat files.

That's where my view of the matter differs, actually, pushing down the
responsibility into the extension code itself. A key argument,
mentioned upthread, is that the file paths could depend on the stats
entry *keys*, which may not be known in advance when beginning the
flush of the stats. Think about per-database file stats, or just
some per-object file stats, for example, which is an option that would
matter so as we do not bloat the main pgstats file.
--
Michael

#19Chao Li
li.evan.chao@gmail.com
In reply to: Michael Paquier (#18)
Re: [Proposal] Adding callback support for custom statistics kinds

On Dec 3, 2025, at 13:54, Michael Paquier <michael@paquier.xyz> wrote:

On Wed, Dec 03, 2025 at 01:41:44PM +0800, Chao Li wrote:

Thanks for the patch, I do think the feature will be useful. After reading the patch, I got a concern on the design:

This patch provides callbacks that requests (also allows) custom
extensions to write stat files on their own behalf, which I think
it’s unsafe. The problems coming out to my head includes:

* An extension can write to any where on the storage, that what
* about it writes to /tmp and the files are deleted by other process
* or by a user manually incidentally?

I mean, just don't do that. It's up to the extension developer to
decide what is safe or not, within the scope of the data folder.

* pgstat has a pattern of writing files like: writing to tmp file
* first, then durable_rename(), how to ensure extensions to do the
* same pattern? Without this pattern, how to ensure reliability of
* stat files?

Extension code would be responsible for ensuring that.

* In the current path, pgstat performs its own write, then call
* callbacks. What about if a callback fails? Will that leave pgstat
* in a stale state?

For the write state, end_extra_stats() would take care of that. It
depends on what kind of errors you would need to deal with, but as
proposed the patch would offer the same level of protection for the
writes of the stats, where we'd check for an error on the fd saved by
an extension for an extra file.

I think that you have a fair point about the stats read path though,
shouldn't we make the callback from_serialized_extra_stats() return a
state to be able to trigger a full-scale cleanup, at least?

* As extensions own file creation and deletion, in some case, staled
* file might be left on storage, who will be responsible for
* cleaning up them?

The extension should be able to handle that, I guess.

Yes, they of course can do, but that’s out of pgstat’s control. How can we ensure that?

Given the goal of the feature is to allow extensions to serialize
custom data, the callback should just return serialized/deserialized
data, maybe together with some metadata, then pgstat should be
responsible for writing the data. In other words, IMO, pgstat should
always own stat files.

That's where my view of the matter differs, actually, pushing down the
responsibility into the extension code itself. A key argument,
mentioned upthread, is that the file paths could depend on the stats
entry *keys*, which may not be known in advance when beginning the
flush of the stats. Think about per-database file stats, or just
some per-object file stats, for example, which is an option that would
matter so as we do not bloat the main pgstats file.
--
Michael

If we push down the responsibility into the extension code, then all extensions that want to enjoy the callbacks have to handle the same complexities of dealing with stat files, which sounds big duplicate efforts.

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

#20Sami Imseih
samimseih@gmail.com
In reply to: Chao Li (#19)
Re: [Proposal] Adding callback support for custom statistics kinds

If we push down the responsibility into the extension code, then all extensions
that want to enjoy the callbacks have to handle the same complexities of dealing
with stat files, which sounds big duplicate efforts.

Thanks for the input! Yes, this is a trade-off between putting
responsibility on the
extension vs core. The initial thought I had was exactly like yours, but it will
be easier to get something pushed if we make the core changes as minimal as
possible. If there are enough complaints in the future, this can be revisited.
Particularly if there is a common patterns for file cleanup, this
could be turned
into a core utility.

That looks like a good direction to me. The only differences I can
see with the stats module in injection_points for variable-sized stats
is that this new module does not check pgstat_drop_entry() and
pgstat_fetch_entry() when working on a custom stats kind. If we had
SQL interfaces calling these two, we could just remove
injection_stats.c entirely, moving everything to this new test module.

I should have invented a new module from the start, perhaps, but well,
that was good enough to check the basic APIs when working on the
custom APIs. Removing this duplication would be my own business with
your module in the tree, no need for you to worry about that. That
would also remove the tweak you have used regarding the duplicated
kind ID.

I plan on addressing the other comments.

However, as discussed off-list, I do think moving the custom kind tests from
injection points to the new test module is a prerequisite. I rather
not have us
push a new test module that is doing duplicate work as the injection
stats tests.
I worked on this refactoring today and plan to have a patch ready for review
by tomorrow.

--
Sami Imseih
Amazon Web Services (AWS)

#21Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#20)
Re: [Proposal] Adding callback support for custom statistics kinds

On Thu, Dec 04, 2025 at 05:00:10PM -0600, Sami Imseih wrote:

Thanks for the input! Yes, this is a trade-off between putting
responsibility on the
extension vs core. The initial thought I had was exactly like yours, but it will
be easier to get something pushed if we make the core changes as minimal as
possible. If there are enough complaints in the future, this can be revisited.
Particularly if there is a common patterns for file cleanup, this
could be turned
into a core utility.

Another way to shape it would be to have an in-core routine that
provides a default logic for the actions to take depending on the
write, read or discard state, with the state and a FILE* as arguments.
The main pgstats file would call that, modules may decide to use it.

However, as discussed off-list, I do think moving the custom kind tests from
injection points to the new test module is a prerequisite. I rather
not have us push a new test module that is doing duplicate work as the injection
stats tests.
I worked on this refactoring today and plan to have a patch ready for review
by tomorrow.

Cool, thanks!
--
Michael

#22Sami Imseih
samimseih@gmail.com
In reply to: Michael Paquier (#21)
1 attachment(s)
Re: [Proposal] Adding callback support for custom statistics kinds

However, as discussed off-list, I do think moving the custom kind tests from
injection points to the new test module is a prerequisite. I rather
not have us push a new test module that is doing duplicate work as the injection
stats tests.
I worked on this refactoring today and plan to have a patch ready for review
by tomorrow.

Cool, thanks!

Attached is the new test module that replaces the custom statistics
tests currently in the injection points tests. Under test_custom_stats, there
are two separate modules: one for variable-amount stats and one for
fixed-amount stats. With this, we can completely remove the
stats-related tests and supporting code under
src/test/modules/injection_points/.

A few notes on the tests:

1. Variable stats: pgstat_drop_entry() and pgstat_fetch_entry() are
exercised here, addressing an earlier point raised in the thread.

2. Fixed-amount stats: I added specific tests for reset behavior; both
during crash recovery and during manual resets.

3. In test_custom_fixed_stats.c, you will see this comment:
```
/* see explanation above PgStatShared_Archiver for the reset protocol */
LWLockAcquire(&stats_shmem->lock, LW_EXCLUSIVE);
```
This is intentional, as the reset protocol is documented at the
referenced location [0]https://github.com/postgres/postgres/blob/master/src/include/utils/pgstat_internal.h#L362-L382. I wanted to call that out for the patch review.

Once this gets pushed, it will simplify the remaining work needed
for the remaining serialization callbacks work.

[0]: https://github.com/postgres/postgres/blob/master/src/include/utils/pgstat_internal.h#L362-L382

--
Sami Imseih
Amazon Web Services (AWS)

Attachments:

v3-0001-Move-custom-stats-tests-from-injection_points-to-.patchapplication/octet-stream; name=v3-0001-Move-custom-stats-tests-from-injection_points-to-.patchDownload
From 316abf87ff67bfbbe9c43291a864f5860f5b0043 Mon Sep 17 00:00:00 2001
From: Ubuntu <ubuntu@ip-172-31-46-230.ec2.internal>
Date: Sat, 6 Dec 2025 00:54:06 +0000
Subject: [PATCH v3 1/1] Move custom stats tests from injection_points to
 dedicated module

Extract custom statistics testing code from injection_points module
into new test_custom_stats module with separate variable and fixed
statistics components. This improves test organization and removes
statistics functionality that was not core to injection points.

Discussion: https://www.postgresql.org/message-id/CAA5RZ0sJgO6GAwgFxmzg9MVP%3DrM7Us8KKcWpuqxe-f5qxmpE0g%40mail.gmail.com
---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/injection_points/Makefile    |   4 -
 .../injection_points--1.0.sql                 |  43 ---
 .../injection_points/injection_points.c       |  48 ---
 .../injection_points/injection_stats.c        | 228 -------------
 .../injection_points/injection_stats.h        |  35 --
 .../injection_points/injection_stats_fixed.c  | 214 -------------
 src/test/modules/injection_points/meson.build |  12 -
 .../modules/injection_points/t/001_stats.pl   | 103 ------
 src/test/modules/meson.build                  |   1 +
 src/test/modules/test_custom_stats/.gitignore |   4 +
 src/test/modules/test_custom_stats/Makefile   |  27 ++
 .../modules/test_custom_stats/meson.build     |  55 ++++
 .../test_custom_stats/t/001_custom_stats.pl   | 115 +++++++
 .../test_custom_fixed_stats--1.0.sql          |  20 ++
 .../test_custom_fixed_stats.c                 | 224 +++++++++++++
 .../test_custom_fixed_stats.control           |   4 +
 .../test_custom_var_stats--1.0.sql            |  25 ++
 .../test_custom_stats/test_custom_var_stats.c | 302 ++++++++++++++++++
 .../test_custom_var_stats.control             |   4 +
 20 files changed, 782 insertions(+), 687 deletions(-)
 delete mode 100644 src/test/modules/injection_points/injection_stats.c
 delete mode 100644 src/test/modules/injection_points/injection_stats.h
 delete mode 100644 src/test/modules/injection_points/injection_stats_fixed.c
 delete mode 100644 src/test/modules/injection_points/t/001_stats.pl
 create mode 100644 src/test/modules/test_custom_stats/.gitignore
 create mode 100644 src/test/modules/test_custom_stats/Makefile
 create mode 100644 src/test/modules/test_custom_stats/meson.build
 create mode 100644 src/test/modules/test_custom_stats/t/001_custom_stats.pl
 create mode 100644 src/test/modules/test_custom_stats/test_custom_fixed_stats--1.0.sql
 create mode 100644 src/test/modules/test_custom_stats/test_custom_fixed_stats.c
 create mode 100644 src/test/modules/test_custom_stats/test_custom_fixed_stats.control
 create mode 100644 src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
 create mode 100644 src/test/modules/test_custom_stats/test_custom_var_stats.c
 create mode 100644 src/test/modules/test_custom_stats/test_custom_var_stats.control

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index d079b91b1a2..4a109ccbf6c 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -21,6 +21,7 @@ SUBDIRS = \
 		  test_bloomfilter \
 		  test_copy_callbacks \
 		  test_custom_rmgrs \
+		  test_custom_stats \
 		  test_ddl_deparse \
 		  test_dsa \
 		  test_dsm_registry \
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index a618e6a9899..c85034eb8cc 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -4,8 +4,6 @@ MODULE_big = injection_points
 OBJS = \
 	$(WIN32RES) \
 	injection_points.o \
-	injection_stats.o \
-	injection_stats_fixed.o \
 	regress_injection.o
 EXTENSION = injection_points
 DATA = injection_points--1.0.sql
@@ -23,8 +21,6 @@ ISOLATION = basic \
 	    reindex-concurrently-upsert-on-constraint \
 	    reindex-concurrently-upsert-partitioned
 
-TAP_TESTS = 1
-
 # The injection points are cluster-wide, so disable installcheck
 NO_INSTALLCHECK = 1
 
diff --git a/src/test/modules/injection_points/injection_points--1.0.sql b/src/test/modules/injection_points/injection_points--1.0.sql
index a51ff538684..861c7355d4e 100644
--- a/src/test/modules/injection_points/injection_points--1.0.sql
+++ b/src/test/modules/injection_points/injection_points--1.0.sql
@@ -101,49 +101,6 @@ RETURNS SETOF record
 AS 'MODULE_PATHNAME', 'injection_points_list'
 LANGUAGE C STRICT VOLATILE PARALLEL RESTRICTED;
 
---
--- injection_points_stats_numcalls()
---
--- Reports statistics, if any, related to the given injection point.
---
-CREATE FUNCTION injection_points_stats_numcalls(IN point_name TEXT)
-RETURNS bigint
-AS 'MODULE_PATHNAME', 'injection_points_stats_numcalls'
-LANGUAGE C STRICT;
-
---
--- injection_points_stats_count()
---
--- Return the number of entries stored in the pgstats hash table.
---
-CREATE FUNCTION injection_points_stats_count()
-RETURNS bigint
-AS 'MODULE_PATHNAME', 'injection_points_stats_count'
-LANGUAGE C STRICT;
-
---
--- injection_points_stats_drop()
---
--- Drop all statistics of injection points.
---
-CREATE FUNCTION injection_points_stats_drop()
-RETURNS void
-AS 'MODULE_PATHNAME', 'injection_points_stats_drop'
-LANGUAGE C STRICT;
-
---
--- injection_points_stats_fixed()
---
--- Reports fixed-numbered statistics for injection points.
-CREATE FUNCTION injection_points_stats_fixed(OUT numattach int8,
-   OUT numdetach int8,
-   OUT numrun int8,
-   OUT numcached int8,
-   OUT numloaded int8)
-RETURNS record
-AS 'MODULE_PATHNAME', 'injection_points_stats_fixed'
-LANGUAGE C STRICT;
-
 --
 -- regress_injection.c functions
 --
diff --git a/src/test/modules/injection_points/injection_points.c b/src/test/modules/injection_points/injection_points.c
index b7c1c58ea56..417b61f31c5 100644
--- a/src/test/modules/injection_points/injection_points.c
+++ b/src/test/modules/injection_points/injection_points.c
@@ -19,7 +19,6 @@
 
 #include "fmgr.h"
 #include "funcapi.h"
-#include "injection_stats.h"
 #include "miscadmin.h"
 #include "nodes/pg_list.h"
 #include "nodes/value.h"
@@ -107,15 +106,6 @@ extern PGDLLEXPORT void injection_wait(const char *name,
 /* track if injection points attached in this process are linked to it */
 static bool injection_point_local = false;
 
-/*
- * GUC variable
- *
- * This GUC is useful to control if statistics should be enabled or not
- * during a test with injection points, like for example if a test relies
- * on a callback run in a critical section where no allocation should happen.
- */
-bool		inj_stats_enabled = false;
-
 /* Shared memory init callbacks */
 static shmem_request_hook_type prev_shmem_request_hook = NULL;
 static shmem_startup_hook_type prev_shmem_startup_hook = NULL;
@@ -235,9 +225,6 @@ injection_points_cleanup(int code, Datum arg)
 		char	   *name = strVal(lfirst(lc));
 
 		(void) InjectionPointDetach(name);
-
-		/* Remove stats entry */
-		pgstat_drop_inj(name);
 	}
 }
 
@@ -251,8 +238,6 @@ injection_error(const char *name, const void *private_data, void *arg)
 	if (!injection_point_allowed(condition))
 		return;
 
-	pgstat_report_inj(name);
-
 	if (argstr)
 		elog(ERROR, "error triggered for injection point %s (%s)",
 			 name, argstr);
@@ -269,8 +254,6 @@ injection_notice(const char *name, const void *private_data, void *arg)
 	if (!injection_point_allowed(condition))
 		return;
 
-	pgstat_report_inj(name);
-
 	if (argstr)
 		elog(NOTICE, "notice triggered for injection point %s (%s)",
 			 name, argstr);
@@ -293,8 +276,6 @@ injection_wait(const char *name, const void *private_data, void *arg)
 	if (!injection_point_allowed(condition))
 		return;
 
-	pgstat_report_inj(name);
-
 	/*
 	 * Use the injection point name for this custom wait event.  Note that
 	 * this custom wait event name is not released, but we don't care much for
@@ -371,7 +352,6 @@ injection_points_attach(PG_FUNCTION_ARGS)
 		condition.pid = MyProcPid;
 	}
 
-	pgstat_report_inj_fixed(1, 0, 0, 0, 0);
 	InjectionPointAttach(name, "injection_points", function, &condition,
 						 sizeof(InjectionPointCondition));
 
@@ -385,9 +365,6 @@ injection_points_attach(PG_FUNCTION_ARGS)
 		MemoryContextSwitchTo(oldctx);
 	}
 
-	/* Add entry for stats */
-	pgstat_create_inj(name);
-
 	PG_RETURN_VOID();
 }
 
@@ -422,7 +399,6 @@ injection_points_attach_func(PG_FUNCTION_ARGS)
 		private_data_size = VARSIZE_ANY_EXHDR(private_data);
 	}
 
-	pgstat_report_inj_fixed(1, 0, 0, 0, 0);
 	if (private_data != NULL)
 		InjectionPointAttach(name, lib_name, function, VARDATA_ANY(private_data),
 							 private_data_size);
@@ -444,7 +420,6 @@ injection_points_load(PG_FUNCTION_ARGS)
 	if (inj_state == NULL)
 		injection_init_shmem();
 
-	pgstat_report_inj_fixed(0, 0, 0, 0, 1);
 	INJECTION_POINT_LOAD(name);
 
 	PG_RETURN_VOID();
@@ -467,7 +442,6 @@ injection_points_run(PG_FUNCTION_ARGS)
 	if (!PG_ARGISNULL(1))
 		arg = text_to_cstring(PG_GETARG_TEXT_PP(1));
 
-	pgstat_report_inj_fixed(0, 0, 1, 0, 0);
 	INJECTION_POINT(name, arg);
 
 	PG_RETURN_VOID();
@@ -490,7 +464,6 @@ injection_points_cached(PG_FUNCTION_ARGS)
 	if (!PG_ARGISNULL(1))
 		arg = text_to_cstring(PG_GETARG_TEXT_PP(1));
 
-	pgstat_report_inj_fixed(0, 0, 0, 1, 0);
 	INJECTION_POINT_CACHED(name, arg);
 
 	PG_RETURN_VOID();
@@ -567,7 +540,6 @@ injection_points_detach(PG_FUNCTION_ARGS)
 {
 	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
 
-	pgstat_report_inj_fixed(0, 1, 0, 0, 0);
 	if (!InjectionPointDetach(name))
 		elog(ERROR, "could not detach injection point \"%s\"", name);
 
@@ -581,9 +553,6 @@ injection_points_detach(PG_FUNCTION_ARGS)
 		MemoryContextSwitchTo(oldctx);
 	}
 
-	/* Remove stats entry */
-	pgstat_drop_inj(name);
-
 	PG_RETURN_VOID();
 }
 
@@ -625,32 +594,15 @@ injection_points_list(PG_FUNCTION_ARGS)
 #undef NUM_INJECTION_POINTS_LIST
 }
 
-
 void
 _PG_init(void)
 {
 	if (!process_shared_preload_libraries_in_progress)
 		return;
 
-	DefineCustomBoolVariable("injection_points.stats",
-							 "Enables statistics for injection points.",
-							 NULL,
-							 &inj_stats_enabled,
-							 false,
-							 PGC_POSTMASTER,
-							 0,
-							 NULL,
-							 NULL,
-							 NULL);
-
-	MarkGUCPrefixReserved("injection_points");
-
 	/* Shared memory initialization */
 	prev_shmem_request_hook = shmem_request_hook;
 	shmem_request_hook = injection_shmem_request;
 	prev_shmem_startup_hook = shmem_startup_hook;
 	shmem_startup_hook = injection_shmem_startup;
-
-	pgstat_register_inj();
-	pgstat_register_inj_fixed();
 }
diff --git a/src/test/modules/injection_points/injection_stats.c b/src/test/modules/injection_points/injection_stats.c
deleted file mode 100644
index 158e1631af9..00000000000
--- a/src/test/modules/injection_points/injection_stats.c
+++ /dev/null
@@ -1,228 +0,0 @@
-/*--------------------------------------------------------------------------
- *
- * injection_stats.c
- *		Code for statistics of injection points.
- *
- * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
- * Portions Copyright (c) 1994, Regents of the University of California
- *
- * IDENTIFICATION
- *		src/test/modules/injection_points/injection_stats.c
- *
- * -------------------------------------------------------------------------
- */
-
-#include "postgres.h"
-
-#include "fmgr.h"
-
-#include "common/hashfn.h"
-#include "injection_stats.h"
-#include "pgstat.h"
-#include "utils/builtins.h"
-#include "utils/pgstat_internal.h"
-
-/* Structures for statistics of injection points */
-typedef struct PgStat_StatInjEntry
-{
-	PgStat_Counter numcalls;	/* number of times point has been run */
-} PgStat_StatInjEntry;
-
-typedef struct PgStatShared_InjectionPoint
-{
-	PgStatShared_Common header;
-	PgStat_StatInjEntry stats;
-} PgStatShared_InjectionPoint;
-
-static bool injection_stats_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
-
-static const PgStat_KindInfo injection_stats = {
-	.name = "injection_points",
-	.fixed_amount = false,		/* Bounded by the number of points */
-	.write_to_file = true,
-	.track_entry_count = true,
-
-	/* Injection points are system-wide */
-	.accessed_across_databases = true,
-
-	.shared_size = sizeof(PgStatShared_InjectionPoint),
-	.shared_data_off = offsetof(PgStatShared_InjectionPoint, stats),
-	.shared_data_len = sizeof(((PgStatShared_InjectionPoint *) 0)->stats),
-	.pending_size = sizeof(PgStat_StatInjEntry),
-	.flush_pending_cb = injection_stats_flush_cb,
-};
-
-/*
- * Compute stats entry idx from point name with an 8-byte hash.
- */
-#define PGSTAT_INJ_IDX(name) hash_bytes_extended((const unsigned char *) name, strlen(name), 0)
-
-/*
- * Kind ID reserved for statistics of injection points.
- */
-#define PGSTAT_KIND_INJECTION	25
-
-/* Track if stats are loaded */
-static bool inj_stats_loaded = false;
-
-/*
- * Callback for stats handling
- */
-static bool
-injection_stats_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
-{
-	PgStat_StatInjEntry *localent;
-	PgStatShared_InjectionPoint *shfuncent;
-
-	localent = (PgStat_StatInjEntry *) entry_ref->pending;
-	shfuncent = (PgStatShared_InjectionPoint *) entry_ref->shared_stats;
-
-	if (!pgstat_lock_entry(entry_ref, nowait))
-		return false;
-
-	shfuncent->stats.numcalls += localent->numcalls;
-
-	pgstat_unlock_entry(entry_ref);
-
-	return true;
-}
-
-/*
- * Support function for the SQL-callable pgstat* functions.  Returns
- * a pointer to the injection point statistics struct.
- */
-static PgStat_StatInjEntry *
-pgstat_fetch_stat_injentry(const char *name)
-{
-	PgStat_StatInjEntry *entry = NULL;
-
-	if (!inj_stats_loaded || !inj_stats_enabled)
-		return NULL;
-
-	/* Compile the lookup key as a hash of the point name */
-	entry = (PgStat_StatInjEntry *) pgstat_fetch_entry(PGSTAT_KIND_INJECTION,
-													   InvalidOid,
-													   PGSTAT_INJ_IDX(name));
-	return entry;
-}
-
-/*
- * Workhorse to do the registration work, called in _PG_init().
- */
-void
-pgstat_register_inj(void)
-{
-	pgstat_register_kind(PGSTAT_KIND_INJECTION, &injection_stats);
-
-	/* mark stats as loaded */
-	inj_stats_loaded = true;
-}
-
-/*
- * Report injection point creation.
- */
-void
-pgstat_create_inj(const char *name)
-{
-	PgStat_EntryRef *entry_ref;
-	PgStatShared_InjectionPoint *shstatent;
-
-	/* leave if disabled */
-	if (!inj_stats_loaded || !inj_stats_enabled)
-		return;
-
-	entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_INJECTION, InvalidOid,
-										  PGSTAT_INJ_IDX(name), NULL);
-
-	shstatent = (PgStatShared_InjectionPoint *) entry_ref->shared_stats;
-
-	/* initialize shared memory data */
-	memset(&shstatent->stats, 0, sizeof(shstatent->stats));
-}
-
-/*
- * Report injection point drop.
- */
-void
-pgstat_drop_inj(const char *name)
-{
-	/* leave if disabled */
-	if (!inj_stats_loaded || !inj_stats_enabled)
-		return;
-
-	if (!pgstat_drop_entry(PGSTAT_KIND_INJECTION, InvalidOid,
-						   PGSTAT_INJ_IDX(name)))
-		pgstat_request_entry_refs_gc();
-}
-
-/*
- * Report statistics for injection point.
- *
- * This is simple because the set of stats to report currently is simple:
- * track the number of times a point has been run.
- */
-void
-pgstat_report_inj(const char *name)
-{
-	PgStat_EntryRef *entry_ref;
-	PgStat_StatInjEntry *pending;
-
-	/* leave if disabled */
-	if (!inj_stats_loaded || !inj_stats_enabled)
-		return;
-
-	entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_INJECTION, InvalidOid,
-										  PGSTAT_INJ_IDX(name), NULL);
-
-	pending = (PgStat_StatInjEntry *) entry_ref->pending;
-
-	/* Update the injection point pending statistics */
-	pending->numcalls++;
-}
-
-/*
- * SQL function returning the number of times an injection point
- * has been called.
- */
-PG_FUNCTION_INFO_V1(injection_points_stats_numcalls);
-Datum
-injection_points_stats_numcalls(PG_FUNCTION_ARGS)
-{
-	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
-	PgStat_StatInjEntry *entry = pgstat_fetch_stat_injentry(name);
-
-	if (entry == NULL)
-		PG_RETURN_NULL();
-
-	PG_RETURN_INT64(entry->numcalls);
-}
-
-/*
- * SQL function returning the number of entries allocated for injection
- * points in the shared hashtable of pgstats.
- */
-PG_FUNCTION_INFO_V1(injection_points_stats_count);
-Datum
-injection_points_stats_count(PG_FUNCTION_ARGS)
-{
-	PG_RETURN_INT64(pgstat_get_entry_count(PGSTAT_KIND_INJECTION));
-}
-
-/* Only used by injection_points_stats_drop() */
-static bool
-match_inj_entries(PgStatShared_HashEntry *entry, Datum match_data)
-{
-	return entry->key.kind == PGSTAT_KIND_INJECTION;
-}
-
-/*
- * SQL function that drops all injection point statistics.
- */
-PG_FUNCTION_INFO_V1(injection_points_stats_drop);
-Datum
-injection_points_stats_drop(PG_FUNCTION_ARGS)
-{
-	pgstat_drop_matching_entries(match_inj_entries, 0);
-
-	PG_RETURN_VOID();
-}
diff --git a/src/test/modules/injection_points/injection_stats.h b/src/test/modules/injection_points/injection_stats.h
deleted file mode 100644
index ba310c52c7f..00000000000
--- a/src/test/modules/injection_points/injection_stats.h
+++ /dev/null
@@ -1,35 +0,0 @@
-/*--------------------------------------------------------------------------
- *
- * injection_stats.h
- *		Definitions for statistics of injection points.
- *
- * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
- * Portions Copyright (c) 1994, Regents of the University of California
- *
- * IDENTIFICATION
- *		src/test/modules/injection_points/injection_stats.h
- *
- * -------------------------------------------------------------------------
- */
-
-#ifndef INJECTION_STATS
-#define INJECTION_STATS
-
-/* GUC variable */
-extern bool inj_stats_enabled;
-
-/* injection_stats.c */
-extern void pgstat_register_inj(void);
-extern void pgstat_create_inj(const char *name);
-extern void pgstat_drop_inj(const char *name);
-extern void pgstat_report_inj(const char *name);
-
-/* injection_stats_fixed.c */
-extern void pgstat_register_inj_fixed(void);
-extern void pgstat_report_inj_fixed(uint32 numattach,
-									uint32 numdetach,
-									uint32 numrun,
-									uint32 numcached,
-									uint32 numloaded);
-
-#endif
diff --git a/src/test/modules/injection_points/injection_stats_fixed.c b/src/test/modules/injection_points/injection_stats_fixed.c
deleted file mode 100644
index b493e8f77a3..00000000000
--- a/src/test/modules/injection_points/injection_stats_fixed.c
+++ /dev/null
@@ -1,214 +0,0 @@
-/*--------------------------------------------------------------------------
- *
- * injection_stats_fixed.c
- *		Code for fixed-numbered statistics of injection points.
- *
- * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
- * Portions Copyright (c) 1994, Regents of the University of California
- *
- * IDENTIFICATION
- *		src/test/modules/injection_points/injection_stats_fixed.c
- *
- * -------------------------------------------------------------------------
- */
-
-#include "postgres.h"
-
-#include "fmgr.h"
-
-#include "access/htup_details.h"
-#include "common/hashfn.h"
-#include "funcapi.h"
-#include "injection_stats.h"
-#include "pgstat.h"
-#include "utils/builtins.h"
-#include "utils/pgstat_internal.h"
-
-/* Structures for statistics of injection points, fixed-size */
-typedef struct PgStat_StatInjFixedEntry
-{
-	PgStat_Counter numattach;	/* number of points attached */
-	PgStat_Counter numdetach;	/* number of points detached */
-	PgStat_Counter numrun;		/* number of points run */
-	PgStat_Counter numcached;	/* number of points cached */
-	PgStat_Counter numloaded;	/* number of points loaded */
-	TimestampTz stat_reset_timestamp;
-} PgStat_StatInjFixedEntry;
-
-typedef struct PgStatShared_InjectionPointFixed
-{
-	LWLock		lock;			/* protects all the counters */
-	uint32		changecount;
-	PgStat_StatInjFixedEntry stats;
-	PgStat_StatInjFixedEntry reset_offset;
-} PgStatShared_InjectionPointFixed;
-
-/* Callbacks for fixed-numbered stats */
-static void injection_stats_fixed_init_shmem_cb(void *stats);
-static void injection_stats_fixed_reset_all_cb(TimestampTz ts);
-static void injection_stats_fixed_snapshot_cb(void);
-
-static const PgStat_KindInfo injection_stats_fixed = {
-	.name = "injection_points_fixed",
-	.fixed_amount = true,
-	.write_to_file = true,
-
-	.shared_size = sizeof(PgStat_StatInjFixedEntry),
-	.shared_data_off = offsetof(PgStatShared_InjectionPointFixed, stats),
-	.shared_data_len = sizeof(((PgStatShared_InjectionPointFixed *) 0)->stats),
-
-	.init_shmem_cb = injection_stats_fixed_init_shmem_cb,
-	.reset_all_cb = injection_stats_fixed_reset_all_cb,
-	.snapshot_cb = injection_stats_fixed_snapshot_cb,
-};
-
-/*
- * Kind ID reserved for statistics of injection points.
- */
-#define PGSTAT_KIND_INJECTION_FIXED	26
-
-/* Track if fixed-numbered stats are loaded */
-static bool inj_fixed_loaded = false;
-
-static void
-injection_stats_fixed_init_shmem_cb(void *stats)
-{
-	PgStatShared_InjectionPointFixed *stats_shmem =
-		(PgStatShared_InjectionPointFixed *) stats;
-
-	LWLockInitialize(&stats_shmem->lock, LWTRANCHE_PGSTATS_DATA);
-}
-
-static void
-injection_stats_fixed_reset_all_cb(TimestampTz ts)
-{
-	PgStatShared_InjectionPointFixed *stats_shmem =
-		pgstat_get_custom_shmem_data(PGSTAT_KIND_INJECTION_FIXED);
-
-	LWLockAcquire(&stats_shmem->lock, LW_EXCLUSIVE);
-	pgstat_copy_changecounted_stats(&stats_shmem->reset_offset,
-									&stats_shmem->stats,
-									sizeof(stats_shmem->stats),
-									&stats_shmem->changecount);
-	stats_shmem->stats.stat_reset_timestamp = ts;
-	LWLockRelease(&stats_shmem->lock);
-}
-
-static void
-injection_stats_fixed_snapshot_cb(void)
-{
-	PgStatShared_InjectionPointFixed *stats_shmem =
-		pgstat_get_custom_shmem_data(PGSTAT_KIND_INJECTION_FIXED);
-	PgStat_StatInjFixedEntry *stat_snap =
-		pgstat_get_custom_snapshot_data(PGSTAT_KIND_INJECTION_FIXED);
-	PgStat_StatInjFixedEntry *reset_offset = &stats_shmem->reset_offset;
-	PgStat_StatInjFixedEntry reset;
-
-	pgstat_copy_changecounted_stats(stat_snap,
-									&stats_shmem->stats,
-									sizeof(stats_shmem->stats),
-									&stats_shmem->changecount);
-
-	LWLockAcquire(&stats_shmem->lock, LW_SHARED);
-	memcpy(&reset, reset_offset, sizeof(stats_shmem->stats));
-	LWLockRelease(&stats_shmem->lock);
-
-	/* compensate by reset offsets */
-#define FIXED_COMP(fld) stat_snap->fld -= reset.fld;
-	FIXED_COMP(numattach);
-	FIXED_COMP(numdetach);
-	FIXED_COMP(numrun);
-	FIXED_COMP(numcached);
-	FIXED_COMP(numloaded);
-#undef FIXED_COMP
-}
-
-/*
- * Workhorse to do the registration work, called in _PG_init().
- */
-void
-pgstat_register_inj_fixed(void)
-{
-	pgstat_register_kind(PGSTAT_KIND_INJECTION_FIXED, &injection_stats_fixed);
-
-	/* mark stats as loaded */
-	inj_fixed_loaded = true;
-}
-
-/*
- * Report fixed number of statistics for an injection point.
- */
-void
-pgstat_report_inj_fixed(uint32 numattach,
-						uint32 numdetach,
-						uint32 numrun,
-						uint32 numcached,
-						uint32 numloaded)
-{
-	PgStatShared_InjectionPointFixed *stats_shmem;
-
-	/* leave if disabled */
-	if (!inj_fixed_loaded || !inj_stats_enabled)
-		return;
-
-	stats_shmem = pgstat_get_custom_shmem_data(PGSTAT_KIND_INJECTION_FIXED);
-
-	LWLockAcquire(&stats_shmem->lock, LW_EXCLUSIVE);
-
-	pgstat_begin_changecount_write(&stats_shmem->changecount);
-	stats_shmem->stats.numattach += numattach;
-	stats_shmem->stats.numdetach += numdetach;
-	stats_shmem->stats.numrun += numrun;
-	stats_shmem->stats.numcached += numcached;
-	stats_shmem->stats.numloaded += numloaded;
-	pgstat_end_changecount_write(&stats_shmem->changecount);
-
-	LWLockRelease(&stats_shmem->lock);
-}
-
-/*
- * SQL function returning fixed-numbered statistics for injection points.
- */
-PG_FUNCTION_INFO_V1(injection_points_stats_fixed);
-Datum
-injection_points_stats_fixed(PG_FUNCTION_ARGS)
-{
-	TupleDesc	tupdesc;
-	Datum		values[5] = {0};
-	bool		nulls[5] = {0};
-	PgStat_StatInjFixedEntry *stats;
-
-	if (!inj_fixed_loaded || !inj_stats_enabled)
-		PG_RETURN_NULL();
-
-	pgstat_snapshot_fixed(PGSTAT_KIND_INJECTION_FIXED);
-	stats = pgstat_get_custom_snapshot_data(PGSTAT_KIND_INJECTION_FIXED);
-
-	/* Initialise attributes information in the tuple descriptor */
-	tupdesc = CreateTemplateTupleDesc(5);
-	TupleDescInitEntry(tupdesc, (AttrNumber) 1, "numattach",
-					   INT8OID, -1, 0);
-	TupleDescInitEntry(tupdesc, (AttrNumber) 2, "numdetach",
-					   INT8OID, -1, 0);
-	TupleDescInitEntry(tupdesc, (AttrNumber) 3, "numrun",
-					   INT8OID, -1, 0);
-	TupleDescInitEntry(tupdesc, (AttrNumber) 4, "numcached",
-					   INT8OID, -1, 0);
-	TupleDescInitEntry(tupdesc, (AttrNumber) 5, "numloaded",
-					   INT8OID, -1, 0);
-	BlessTupleDesc(tupdesc);
-
-	values[0] = Int64GetDatum(stats->numattach);
-	values[1] = Int64GetDatum(stats->numdetach);
-	values[2] = Int64GetDatum(stats->numrun);
-	values[3] = Int64GetDatum(stats->numcached);
-	values[4] = Int64GetDatum(stats->numloaded);
-	nulls[0] = false;
-	nulls[1] = false;
-	nulls[2] = false;
-	nulls[3] = false;
-	nulls[4] = false;
-
-	/* Returns the record as Datum */
-	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
-}
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 1a2af8a26c4..8d6f662040d 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -6,8 +6,6 @@ endif
 
 injection_points_sources = files(
   'injection_points.c',
-  'injection_stats.c',
-  'injection_stats_fixed.c',
   'regress_injection.c',
 )
 
@@ -58,14 +56,4 @@ tests += {
     # Some tests wait for all snapshots, so avoid parallel execution
     'runningcheck-parallel': false,
   },
-  'tap': {
-    'env': {
-      'enable_injection_points': get_option('injection_points') ? 'yes' : 'no',
-    },
-    'tests': [
-      't/001_stats.pl',
-    ],
-    # The injection points are cluster-wide, so disable installcheck
-    'runningcheck': false,
-  },
 }
diff --git a/src/test/modules/injection_points/t/001_stats.pl b/src/test/modules/injection_points/t/001_stats.pl
deleted file mode 100644
index 47ab58d0e9b..00000000000
--- a/src/test/modules/injection_points/t/001_stats.pl
+++ /dev/null
@@ -1,103 +0,0 @@
-
-# Copyright (c) 2024-2025, PostgreSQL Global Development Group
-
-# Tests for Custom Cumulative Statistics.
-
-use strict;
-use warnings FATAL => 'all';
-use locale;
-
-use PostgreSQL::Test::Cluster;
-use PostgreSQL::Test::Utils;
-use Test::More;
-
-# Test persistency of statistics generated for injection points.
-if ($ENV{enable_injection_points} ne 'yes')
-{
-	plan skip_all => 'Injection points not supported by this build';
-}
-
-# Node initialization
-my $node = PostgreSQL::Test::Cluster->new('master');
-$node->init;
-$node->append_conf(
-	'postgresql.conf', qq(
-shared_preload_libraries = 'injection_points'
-injection_points.stats = true
-));
-$node->start;
-$node->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
-
-# This should count for two calls.
-$node->safe_psql('postgres',
-	"SELECT injection_points_attach('stats-notice', 'notice');");
-$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice');");
-$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice');");
-my $numcalls = $node->safe_psql('postgres',
-	"SELECT injection_points_stats_numcalls('stats-notice');");
-is($numcalls, '2', 'number of stats calls');
-my $entrycount =
-  $node->safe_psql('postgres', "SELECT injection_points_stats_count();");
-is($entrycount, '1', 'number of entries');
-my $fixedstats = $node->safe_psql('postgres',
-	"SELECT * FROM injection_points_stats_fixed();");
-is($fixedstats, '1|0|2|0|0', 'fixed stats after some calls');
-
-# Loading and caching.
-$node->safe_psql(
-	'postgres', "
-SELECT injection_points_load('stats-notice');
-SELECT injection_points_cached('stats-notice');
-");
-$fixedstats = $node->safe_psql('postgres',
-	"SELECT * FROM injection_points_stats_fixed();");
-is($fixedstats, '1|0|2|1|1', 'fixed stats after loading and caching');
-
-# Restart the node cleanly, stats should still be around.
-$node->restart;
-$numcalls = $node->safe_psql('postgres',
-	"SELECT injection_points_stats_numcalls('stats-notice');");
-is($numcalls, '3', 'number of stats after clean restart');
-$entrycount =
-  $node->safe_psql('postgres', "SELECT injection_points_stats_count();");
-is($entrycount, '1', 'number of entries after clean restart');
-$fixedstats = $node->safe_psql('postgres',
-	"SELECT * FROM injection_points_stats_fixed();");
-is($fixedstats, '1|0|2|1|1', 'fixed stats after clean restart');
-
-# On crash the stats are gone.
-$node->stop('immediate');
-$node->start;
-$numcalls = $node->safe_psql('postgres',
-	"SELECT injection_points_stats_numcalls('stats-notice');");
-is($numcalls, '', 'number of stats after crash');
-$entrycount =
-  $node->safe_psql('postgres', "SELECT injection_points_stats_count();");
-is($entrycount, '0', 'number of entries after crash');
-$fixedstats = $node->safe_psql('postgres',
-	"SELECT * FROM injection_points_stats_fixed();");
-is($fixedstats, '0|0|0|0|0', 'fixed stats after crash');
-
-# On drop all stats are gone
-$node->safe_psql('postgres',
-	"SELECT injection_points_attach('stats-notice', 'notice');");
-$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice');");
-$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice');");
-$numcalls = $node->safe_psql('postgres',
-	"SELECT injection_points_stats_numcalls('stats-notice');");
-is($numcalls, '2', 'number of stats calls');
-$node->safe_psql('postgres', "SELECT injection_points_stats_drop();");
-$numcalls = $node->safe_psql('postgres',
-	"SELECT injection_points_stats_numcalls('stats-notice');");
-is($numcalls, '', 'no stats after drop via SQL function');
-$entrycount =
-  $node->safe_psql('postgres', "SELECT injection_points_stats_count();");
-is($entrycount, '0', 'number of entries after drop via SQL function');
-
-# Stop the server, disable the module, then restart.  The server
-# should be able to come up.
-$node->stop;
-$node->adjust_conf('postgresql.conf', 'shared_preload_libraries', "''");
-$node->start;
-
-done_testing();
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index cc57461e59a..2806db485d3 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -21,6 +21,7 @@ subdir('test_bitmapset')
 subdir('test_bloomfilter')
 subdir('test_copy_callbacks')
 subdir('test_custom_rmgrs')
+subdir('test_custom_stats')
 subdir('test_ddl_deparse')
 subdir('test_dsa')
 subdir('test_dsm_registry')
diff --git a/src/test/modules/test_custom_stats/.gitignore b/src/test/modules/test_custom_stats/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/test_custom_stats/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/test_custom_stats/Makefile b/src/test/modules/test_custom_stats/Makefile
new file mode 100644
index 00000000000..5b065a1cd42
--- /dev/null
+++ b/src/test/modules/test_custom_stats/Makefile
@@ -0,0 +1,27 @@
+# src/test/modules/test_custom_stats/Makefile
+
+MODULES = test_custom_var_stats test_custom_fixed_stats
+
+EXTENSION = test_custom_var_stats test_custom_fixed_stats
+
+OBJS = \
+	$(WIN32RES) \
+	test_custom_var_stats.o \
+	test_custom_fixed_stats.o
+PGFILEDESC = "test_custom_stats - test code for custom stat kinds"
+
+DATA = test_custom_var_stats--1.0.sql \
+       test_custom_fixed_stats--1.0.sql
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_custom_stats
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_custom_stats/meson.build b/src/test/modules/test_custom_stats/meson.build
new file mode 100644
index 00000000000..a734467e169
--- /dev/null
+++ b/src/test/modules/test_custom_stats/meson.build
@@ -0,0 +1,55 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+test_custom_var_stats_sources = files(
+  'test_custom_var_stats.c',
+)
+
+if host_system == 'windows'
+  test_custom_var_stats_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_custom_var_stats',
+    '--FILEDESC', 'test_custom_var_stats - test code for variable custom stat kinds',])
+endif
+
+test_custom_var_stats = shared_module('test_custom_var_stats',
+  test_custom_var_stats_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_custom_var_stats
+
+test_install_data += files(
+  'test_custom_var_stats.control',
+  'test_custom_var_stats--1.0.sql',
+)
+
+test_custom_fixed_stats_sources = files(
+  'test_custom_fixed_stats.c',
+)
+
+if host_system == 'windows'
+  test_custom_fixed_stats_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_custom_fixed_stats',
+    '--FILEDESC', 'test_custom_fixed_stats - test code for fixed custom stat kinds',])
+endif
+
+test_custom_fixed_stats = shared_module('test_custom_fixed_stats',
+  test_custom_fixed_stats_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_custom_fixed_stats
+
+test_install_data += files(
+  'test_custom_fixed_stats.control',
+  'test_custom_fixed_stats--1.0.sql',
+)
+
+tests += {
+  'name': 'test_custom_stats',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_custom_stats.pl',
+    ],
+    'runningcheck': false,
+  },
+}
diff --git a/src/test/modules/test_custom_stats/t/001_custom_stats.pl b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
new file mode 100644
index 00000000000..c4fceb7d267
--- /dev/null
+++ b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
@@ -0,0 +1,115 @@
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+
+# Test custom statistics functionality
+#
+# Tests both variable-amount and fixed-amount custom statistics:
+# - Creation, updates, and reporting
+# - Persistence across clean restarts
+# - Loss after crash recovery
+# - Reset functionality for fixed stats
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+use File::Copy;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->append_conf('postgresql.conf',
+	"shared_preload_libraries = 'test_custom_var_stats, test_custom_fixed_stats'");
+$node->start;
+
+$node->safe_psql('postgres', q(CREATE EXTENSION test_custom_var_stats));
+$node->safe_psql('postgres', q(CREATE EXTENSION test_custom_fixed_stats));
+
+# Create variable statistics entries
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry1')));
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry2')));
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry3')));
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry4')));
+
+# Update counters: entry1=2, entry2=3, entry3=2, entry4=3, fixed=3
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry1')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry1')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry2')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry2')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry2')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry3')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry3')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry4')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry4')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry4')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+
+# Test variable statistics reporting
+my $result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry1')));
+is($result, "entry1|2", "var stats entry1 reports correct calls");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry2')));
+is($result, "entry2|3", "var stats entry2 reports correct calls");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry3')));
+is($result, "entry3|2", "var stats entry3 reports correct calls");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry4')));
+is($result, "entry4|3", "var stats entry4 reports correct calls");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_fixed_stats()));
+is($result, "3|", "fixed stats reports correct calls");
+
+# Test variable statistics drop functionality
+$result = $node->safe_psql('postgres', q(select * from pgstat_drop_custom_var_stats('entry3')));
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry3')));
+is($result, "", "entry3 not found after drop");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_drop_custom_var_stats('entry4')));
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry4')));
+is($result, "", "entry4 not found after drop");
+
+# Test persistence across clean restart
+$node->stop();
+$node->start();
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry1')));
+is($result, "entry1|2", "var stats entry1 persists after clean restart");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry2')));
+is($result, "entry2|3", "var stats entry2 persists after clean restart");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_fixed_stats()));
+is($result, "3|", "fixed stats persists after clean restart");
+
+# Test crash recovery behavior
+$node->stop('immediate');
+$node->start;
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry1')));
+is($result, "", "var stats entry1 lost after crash recovery");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry2')));
+is($result, "", "var stats entry2 lost after crash recovery");
+
+# crash recovery sets the reset timestamp
+$result = $node->safe_psql('postgres', q(select numcalls from pgstat_report_custom_fixed_stats() where stats_reset is not null));
+is($result, "0", "fixed stats reset after crash recovery");
+
+# Test fixed statistics reset functionality
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+
+$result = $node->safe_psql('postgres', q(select numcalls from pgstat_report_custom_fixed_stats()));
+is($result, "3", "fixed stats shows calls before manual reset");
+
+$node->safe_psql('postgres', q(select pgstat_reset_custom_fixed_stats()));
+
+$result = $node->safe_psql('postgres', q(select numcalls from pgstat_report_custom_fixed_stats() where stats_reset is not null));
+is($result, "0", "fixed stats reset after manual reset");
+
+# Test completed successfully
+done_testing();
\ No newline at end of file
diff --git a/src/test/modules/test_custom_stats/test_custom_fixed_stats--1.0.sql b/src/test/modules/test_custom_stats/test_custom_fixed_stats--1.0.sql
new file mode 100644
index 00000000000..e4c39749398
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_fixed_stats--1.0.sql
@@ -0,0 +1,20 @@
+/* src/test/modules/test_custom_stats/test_custom_fixed_stats--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_custom_fixed_stats" to load this file. \quit
+
+CREATE FUNCTION pgstat_update_custom_fixed_stats()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_update_custom_fixed_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+CREATE FUNCTION pgstat_report_custom_fixed_stats(OUT numcalls bigint,
+    OUT stats_reset timestamptz)
+RETURNS record
+AS 'MODULE_PATHNAME', 'pgstat_report_custom_fixed_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+CREATE FUNCTION pgstat_reset_custom_fixed_stats()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_reset_custom_fixed_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
\ No newline at end of file
diff --git a/src/test/modules/test_custom_stats/test_custom_fixed_stats.c b/src/test/modules/test_custom_stats/test_custom_fixed_stats.c
new file mode 100644
index 00000000000..f0821b4848a
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_fixed_stats.c
@@ -0,0 +1,224 @@
+/*--------------------------------------------------------------------------
+ *
+ * test_custom_fixed_stats.c
+ *		Test module for fixed-amount custom statistics
+ *
+ * Copyright (c) 2024-2025, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		src/test/modules/test_custom_stats/test_custom_fixed_stats.c
+ *
+ * -------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "access/htup_details.h"
+#include "funcapi.h"
+#include "pgstat.h"
+#include "utils/builtins.h"
+#include "utils/pgstat_internal.h"
+
+PG_MODULE_MAGIC_EXT(
+					.name = "test_custom_fixed_stats",
+					.version = PG_VERSION
+);
+
+/* Fixed-amount custom statistics entry */
+typedef struct PgStat_StatCustomFixedEntry
+{
+	PgStat_Counter numcalls;	/* # of times update function called */
+	TimestampTz stat_reset_timestamp;
+}			PgStat_StatCustomFixedEntry;
+
+typedef struct PgStatShared_CustomFixedEntry
+{
+	LWLock		lock;			/* protects counters */
+	uint32		changecount;	/* for atomic reads */
+	PgStat_StatCustomFixedEntry stats;	/* current counters */
+	PgStat_StatCustomFixedEntry reset_offset;	/* reset baseline */
+}			PgStatShared_CustomFixedEntry;
+
+/* Callbacks for fixed-amount statistics */
+static void pgstat_custom_fixed_init_shmem_cb(void *stats);
+static void pgstat_custom_fixed_reset_all_cb(TimestampTz ts);
+static void pgstat_custom_fixed_snapshot_cb(void);
+
+static const PgStat_KindInfo custom_stats = {
+	.name = "test_custom_fixed_stats",
+	.fixed_amount = true,		/* exactly one entry */
+	.write_to_file = true,		/* persist to stats file */
+
+	.shared_size = sizeof(PgStat_StatCustomFixedEntry),
+	.shared_data_off = offsetof(PgStatShared_CustomFixedEntry, stats),
+	.shared_data_len = sizeof(((PgStatShared_CustomFixedEntry *) 0)->stats),
+
+	.init_shmem_cb = pgstat_custom_fixed_init_shmem_cb,
+	.reset_all_cb = pgstat_custom_fixed_reset_all_cb,
+	.snapshot_cb = pgstat_custom_fixed_snapshot_cb,
+};
+
+/*
+ * Kind ID for test_custom_fixed_stats.
+ */
+#define PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS 26
+
+/*--------------------------------------------------------------------------
+ * Module initialization
+ *--------------------------------------------------------------------------
+ */
+
+void
+_PG_init(void)
+{
+	/* Must be loaded via shared_preload_libraries */
+	if (!process_shared_preload_libraries_in_progress)
+		return;
+
+	/* Register custom statistics kind */
+	pgstat_register_kind(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS, &custom_stats);
+}
+
+/*
+ * pgstat_custom_fixed_init_shmem_cb
+ *		Initialize shared memory structure
+ */
+static void
+pgstat_custom_fixed_init_shmem_cb(void *stats)
+{
+	PgStatShared_CustomFixedEntry *stats_shmem =
+		(PgStatShared_CustomFixedEntry *) stats;
+
+	LWLockInitialize(&stats_shmem->lock, LWTRANCHE_PGSTATS_DATA);
+}
+
+/*
+ * pgstat_custom_fixed_reset_all_cb
+ *		Reset the fixed stats
+ */
+static void
+pgstat_custom_fixed_reset_all_cb(TimestampTz ts)
+{
+	PgStatShared_CustomFixedEntry *stats_shmem =
+		pgstat_get_custom_shmem_data(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+
+	/* see explanation above PgStatShared_Archiver for the reset protocol */
+	LWLockAcquire(&stats_shmem->lock, LW_EXCLUSIVE);
+	pgstat_copy_changecounted_stats(&stats_shmem->reset_offset,
+									&stats_shmem->stats,
+									sizeof(stats_shmem->stats),
+									&stats_shmem->changecount);
+	stats_shmem->stats.stat_reset_timestamp = ts;
+	LWLockRelease(&stats_shmem->lock);
+}
+
+/*
+ * pgstat_custom_fixed_snapshot_cb
+ *		Copy current stats to snapshot area
+ */
+static void
+pgstat_custom_fixed_snapshot_cb(void)
+{
+	PgStatShared_CustomFixedEntry *stats_shmem =
+		pgstat_get_custom_shmem_data(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+	PgStat_StatCustomFixedEntry *stat_snap =
+		pgstat_get_custom_snapshot_data(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+	PgStat_StatCustomFixedEntry *reset_offset = &stats_shmem->reset_offset;
+	PgStat_StatCustomFixedEntry reset;
+
+	pgstat_copy_changecounted_stats(stat_snap,
+									&stats_shmem->stats,
+									sizeof(stats_shmem->stats),
+									&stats_shmem->changecount);
+
+	LWLockAcquire(&stats_shmem->lock, LW_SHARED);
+	memcpy(&reset, reset_offset, sizeof(stats_shmem->stats));
+	LWLockRelease(&stats_shmem->lock);
+
+	/* Apply reset offsets */
+#define FIXED_COMP(fld) stat_snap->fld -= reset.fld;
+	FIXED_COMP(numcalls);
+#undef FIXED_COMP
+}
+
+/*--------------------------------------------------------------------------
+ * SQL-callable functions
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * pgstat_update_custom_fixed_stats
+ *		Increment call counter
+ */
+PG_FUNCTION_INFO_V1(pgstat_update_custom_fixed_stats);
+Datum
+pgstat_update_custom_fixed_stats(PG_FUNCTION_ARGS)
+{
+	PgStatShared_CustomFixedEntry *stats_shmem;
+
+	stats_shmem = pgstat_get_custom_shmem_data(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+
+	LWLockAcquire(&stats_shmem->lock, LW_EXCLUSIVE);
+
+	pgstat_begin_changecount_write(&stats_shmem->changecount);
+	stats_shmem->stats.numcalls++;
+	pgstat_end_changecount_write(&stats_shmem->changecount);
+
+	LWLockRelease(&stats_shmem->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_reset_custom_fixed_stats
+ *		Reset statistics by calling pgstat system
+ */
+PG_FUNCTION_INFO_V1(pgstat_reset_custom_fixed_stats);
+Datum
+pgstat_reset_custom_fixed_stats(PG_FUNCTION_ARGS)
+{
+	pgstat_reset_of_kind(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_report_custom_fixed_stats
+ *		Return current counter values
+ */
+PG_FUNCTION_INFO_V1(pgstat_report_custom_fixed_stats);
+Datum
+pgstat_report_custom_fixed_stats(PG_FUNCTION_ARGS)
+{
+	TupleDesc	tupdesc;
+	Datum		values[2] = {0};
+	bool		nulls[2] = {false};
+	PgStat_StatCustomFixedEntry *stats;
+
+	/* Take snapshot (applies reset offsets) */
+	pgstat_snapshot_fixed(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+	stats = pgstat_get_custom_snapshot_data(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+
+	/* Build return tuple */
+	tupdesc = CreateTemplateTupleDesc(2);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 1, "numcalls",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 2, "stats_reset",
+					   TIMESTAMPTZOID, -1, 0);
+	BlessTupleDesc(tupdesc);
+
+	values[0] = Int64GetDatum(stats->numcalls);
+
+	/* Handle uninitialized timestamp (no reset yet) */
+	if (stats->stat_reset_timestamp == 0)
+	{
+		nulls[1] = true;
+	}
+	else
+	{
+		values[1] = TimestampTzGetDatum(stats->stat_reset_timestamp);
+	}
+
+	/* Return as tuple */
+	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
+}
diff --git a/src/test/modules/test_custom_stats/test_custom_fixed_stats.control b/src/test/modules/test_custom_stats/test_custom_fixed_stats.control
new file mode 100644
index 00000000000..b96e2aa18fb
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_fixed_stats.control
@@ -0,0 +1,4 @@
+comment = 'Test code for fixed custom stat kinds'
+default_version = '1.0'
+module_pathname = '$libdir/test_custom_fixed_stats'
+relocatable = true
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
new file mode 100644
index 00000000000..84ae2bf5666
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
@@ -0,0 +1,25 @@
+/* src/test/modules/test_custom_var_stats/test_custom_var_stats--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_custom_var_stats" to load this file. \quit
+
+CREATE FUNCTION pgstat_create_custom_var_stats(IN name TEXT)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_create_custom_var_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+CREATE FUNCTION pgstat_update_custom_var_stats(IN name TEXT)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_update_custom_var_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+CREATE FUNCTION pgstat_drop_custom_var_stats(IN name TEXT)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_drop_custom_var_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+
+CREATE FUNCTION pgstat_report_custom_var_stats(INOUT name TEXT, OUT calls BIGINT)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pgstat_report_custom_var_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats.c b/src/test/modules/test_custom_stats/test_custom_var_stats.c
new file mode 100644
index 00000000000..6320eaf2cae
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats.c
@@ -0,0 +1,302 @@
+/*------------------------------------------------------------------------------------
+ *
+ * test_custom_var_stats.c
+ *		Test module for custom PostgreSQL variable-numbered custom statistic kinds
+ *
+ * Copyright (c) 2024-2025, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		src/test/modules/test_custom_var_stats/test_custom_var_stats.c
+ *
+ * ------------------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "funcapi.h"
+#include "utils/builtins.h"
+#include "utils/pgstat_internal.h"
+
+PG_MODULE_MAGIC_EXT(
+					.name = "test_custom_var_stats",
+					.version = PG_VERSION
+);
+
+/*--------------------------------------------------------------------------
+ * Macros and constants
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * Kind ID for test_custom_var_stats statistics.
+ * Reuses the same ID as injection points to avoid reserving a new kind ID.
+ */
+#define PGSTAT_KIND_TEST_CUSTOM_VAR_STATS 25
+
+/*
+ * Hash statistic name to generate entry index for pgstat lookup.
+ */
+#define PGSTAT_CUSTOM_VAR_STATS_IDX(name) hash_bytes_extended((const unsigned char *) name, strlen(name), 0)
+
+/*--------------------------------------------------------------------------
+ * Type definitions
+ *--------------------------------------------------------------------------
+ */
+
+/* Backend-local pending statistics before flush to shared memory */
+typedef struct PgStat_StatCustomEntry
+{
+	PgStat_Counter numcalls;	/* times statistic was incremented */
+}			PgStat_StatCustomEntry;
+
+/* Shared memory statistics entry visible to all backends */
+typedef struct PgStatShared_CustomEntry
+{
+	PgStatShared_Common header; /* standard pgstat entry header */
+	PgStat_StatCustomEntry stats;	/* custom statistics data */
+}			PgStatShared_CustomEntry;
+
+/*--------------------------------------------------------------------------
+ * Function prototypes
+ *--------------------------------------------------------------------------
+ */
+
+/* Flush callback: merge pending stats into shared memory */
+static bool pgstat_custom_entry_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
+
+/*--------------------------------------------------------------------------
+ * Custom kind configuration
+ *--------------------------------------------------------------------------
+ */
+
+static const PgStat_KindInfo custom_stats = {
+	.name = "test_custom_var_stats",
+	.fixed_amount = false,		/* variable number of entries */
+	.write_to_file = true,		/* persist across restarts */
+	.track_entry_count = true,	/* count active entries */
+	.accessed_across_databases = true,	/* global statistics */
+	.shared_size = sizeof(PgStatShared_CustomEntry),
+	.shared_data_off = offsetof(PgStatShared_CustomEntry, stats),
+	.shared_data_len = sizeof(((PgStatShared_CustomEntry *) 0)->stats),
+	.pending_size = sizeof(PgStat_StatCustomEntry),
+	.flush_pending_cb = pgstat_custom_entry_flush_cb,
+};
+
+/*--------------------------------------------------------------------------
+ * Module initialization
+ *--------------------------------------------------------------------------
+ */
+
+void
+_PG_init(void)
+{
+	/* Must be loaded via shared_preload_libraries */
+	if (!process_shared_preload_libraries_in_progress)
+		return;
+
+	/* Register custom statistics kind */
+	pgstat_register_kind(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, &custom_stats);
+}
+
+/*--------------------------------------------------------------------------
+ * Statistics callback functions
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * pgstat_custom_entry_flush_cb
+ *		Merge pending backend statistics into shared memory
+ *
+ * Called by pgstat collector to flush accumulated local statistics
+ * to shared memory where other backends can read them.
+ *
+ * Returns false only if nowait=true and lock acquisition fails.
+ */
+static bool
+pgstat_custom_entry_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
+{
+	PgStat_StatCustomEntry *pending_entry;
+	PgStatShared_CustomEntry *shared_entry;
+
+	pending_entry = (PgStat_StatCustomEntry *) entry_ref->pending;
+	shared_entry = (PgStatShared_CustomEntry *) entry_ref->shared_stats;
+
+	if (!pgstat_lock_entry(entry_ref, nowait))
+		return false;
+
+	/* Add pending counts to shared totals */
+	shared_entry->stats.numcalls += pending_entry->numcalls;
+
+	pgstat_unlock_entry(entry_ref);
+
+	return true;
+}
+
+/*--------------------------------------------------------------------------
+ * Helper functions
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * pgstat_fetch_custom_entry
+ *		Look up custom statistic by name
+ *
+ * Returns statistics entry from shared memory, or NULL if not found.
+ */
+static PgStat_StatCustomEntry *
+pgstat_fetch_custom_entry(const char *stat_name)
+{
+	/* Fetch entry by hashed name */
+	return (PgStat_StatCustomEntry *) pgstat_fetch_entry(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS,
+														 InvalidOid,
+														 PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name));
+}
+
+/*--------------------------------------------------------------------------
+ * SQL-callable functions
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * pgstat_create_custom_var_stats
+ *		Create new custom statistic entry
+ *
+ * Initializes a zero-valued statistics entry in shared memory.
+ * Validates name length against NAMEDATALEN limit.
+ */
+PG_FUNCTION_INFO_V1(pgstat_create_custom_var_stats);
+Datum
+pgstat_create_custom_var_stats(PG_FUNCTION_ARGS)
+{
+	PgStat_EntryRef *entry_ref;
+	PgStatShared_CustomEntry *shared_entry;
+	char	   *stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	/* Validate name length first */
+	if (strlen(stat_name) >= NAMEDATALEN)
+		ereport(ERROR,
+				(errcode(ERRCODE_NAME_TOO_LONG),
+				 errmsg("custom statistic name \"%s\" is too long", stat_name),
+				 errdetail("Name must be less than %d characters.", NAMEDATALEN)));
+
+	/* Create or get existing entry */
+	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
+											PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), true);
+
+	if (!entry_ref)
+		PG_RETURN_VOID();
+
+	shared_entry = (PgStatShared_CustomEntry *) entry_ref->shared_stats;
+
+	/* Zero-initialize statistics */
+	memset(&shared_entry->stats, 0, sizeof(shared_entry->stats));
+
+	pgstat_unlock_entry(entry_ref);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_update_custom_var_stats
+ *		Increment custom statistic counter
+ *
+ * Increments call count in backend-local memory. Changes are flushed
+ * to shared memory by the statistics collector.
+ */
+PG_FUNCTION_INFO_V1(pgstat_update_custom_var_stats);
+Datum
+pgstat_update_custom_var_stats(PG_FUNCTION_ARGS)
+{
+	char	   *stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	PgStat_EntryRef *entry_ref;
+	PgStat_StatCustomEntry *pending_entry;
+
+	/* Get pending entry in local memory */
+	entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
+										  PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), NULL);
+
+	pending_entry = (PgStat_StatCustomEntry *) entry_ref->pending;
+	pending_entry->numcalls++;
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_drop_custom_var_stats
+ *		Remove custom statistic entry
+ *
+ * Drops the named statistic from shared memory and requests
+ * garbage collection if needed.
+ */
+PG_FUNCTION_INFO_V1(pgstat_drop_custom_var_stats);
+Datum
+pgstat_drop_custom_var_stats(PG_FUNCTION_ARGS)
+{
+	char	   *stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	/* Drop entry and request GC if the entry could not be freed */
+	if (!pgstat_drop_entry(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
+						   PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name)))
+		pgstat_request_entry_refs_gc();
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_report_custom_var_stats
+ *		Retrieve custom statistic values
+ *
+ * Returns single row with statistic name and call count if the
+ * statistic exists, otherwise returns no rows.
+ */
+PG_FUNCTION_INFO_V1(pgstat_report_custom_var_stats);
+Datum
+pgstat_report_custom_var_stats(PG_FUNCTION_ARGS)
+{
+	FuncCallContext *funcctx;
+	char	   *stat_name;
+	PgStat_StatCustomEntry *stat_entry;
+
+	if (SRF_IS_FIRSTCALL())
+	{
+		TupleDesc	tupdesc;
+		MemoryContext oldcontext;
+
+		/* Initialize SRF context */
+		funcctx = SRF_FIRSTCALL_INIT();
+		oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
+
+		/* Get composite return type */
+		if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+			elog(ERROR, "pgstat_report_custom_var_stats: return type is not composite");
+
+		funcctx->tuple_desc = BlessTupleDesc(tupdesc);
+		funcctx->max_calls = 1; /* single row result */
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	funcctx = SRF_PERCALL_SETUP();
+
+	if (funcctx->call_cntr < funcctx->max_calls)
+	{
+		Datum		values[2];
+		bool		nulls[2] = {false, false};
+		HeapTuple	tuple;
+
+		stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+		stat_entry = pgstat_fetch_custom_entry(stat_name);
+
+		/* Return row only if entry exists */
+		if (stat_entry)
+		{
+			values[0] = PointerGetDatum(cstring_to_text(stat_name));
+			values[1] = Int64GetDatum(stat_entry->numcalls);
+
+			tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
+			SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
+		}
+	}
+
+	SRF_RETURN_DONE(funcctx);
+}
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats.control b/src/test/modules/test_custom_stats/test_custom_var_stats.control
new file mode 100644
index 00000000000..43a2783f965
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats.control
@@ -0,0 +1,4 @@
+comment = 'Test code for variable custom stat kinds'
+default_version = '1.0'
+module_pathname = '$libdir/test_custom_var_stats'
+relocatable = true
-- 
2.43.0

#23Sami Imseih
samimseih@gmail.com
In reply to: Sami Imseih (#22)
2 attachment(s)
Re: [Proposal] Adding callback support for custom statistics kinds

I took a look at the earlier comments.

+    /* Read and verify the hash key */
+    if (!pgstat_read_chunk(fd, (void *) key, sizeof(PgStat_HashKey)))
+        return;
[...]
+    /* Write the hash key to identify this entry */
+    pgstat_write_chunk(fd, (void *) key, sizeof(PgStat_HashKey));

I am puzzled by this part of 0002. Why are you overwriting the key
once after loading it from the main pgstats file?

Yes, this is not necessary. I removed it.

I would have done it in a slightly different way, I guess, with the data
stored on disk in the main pgstats file including an offset to know
where to search in the secondary file.

that's a much better approach and the pattern we would want to use
going forward. Since this does not require we read the entires
back in the same order as written, so it's much more flexible.
I not did this in the test module.

Perhaps the callback in the module for end_extra_stats should use a
switch based on PgStat_StatsFileOp. Minor point.

Agree. Done.

* In the current path, pgstat performs its own write, then call
* callbacks. What about if a callback fails? Will that leave pgstat
* in a stale state?

For the write state, end_extra_stats() would take care of that. It
depends on what kind of errors you would need to deal with, but as
proposed the patch would offer the same level of protection for the
writes of the stats, where we'd check for an error on the fd saved by
an extension for an extra file.

I think that you have a fair point about the stats read path though,
shouldn't we make the callback from_serialized_extra_stats() return a
state to be able to trigger a full-scale cleanup, at least?

In this case, even if the callback does not return a state, the cleanup
will eventually occur at the end of the read, see

```
done:
/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
FreeFile(fpin);

elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
unlink(statfile);

/* Let each stats kind run its cleanup callback, if it provides one */
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->end_extra_stats)
kind_info->end_extra_stats(STATS_READ);
}
```

However, this could also mean some entries could be read back correctly, and
others not, so maybe it's not such a good idea. So, I did what is suggested
and allow the callback to return a bool which will raise an error and trigger
the cleanup code.

+     if (pgstat_is_kind_custom(key.kind) && kind_info->from_serialized_extra_stats)
+         kind_info->from_serialized_extra_stats(&key, header, fpin);
[...]
+     if (pgstat_is_kind_custom(ps->key.kind) && kind_info->to_serialized_extra_stats)
+         kind_info->to_serialized_extra_stats(&ps->key, shstats, fpout);

These restrictions based on custom kinds do not seem mandatory.
Why not allowing built-in kinds the same set of operations?

No good reason not to. In fact, maybe a follow-up will be to move the
replslot to this infrastructure and remove reliance on PGSTAT_FILE_ENTRY_NAME.

attached is the v4 patch set which includes:

0001 - which is just moving the tests out of injection points into a new
test module. This is similar to v3 [0]/messages/by-id/CAA5RZ0sG2RUKg=OLY+6-e4q=X9rsLfK3pKn03d=RZQppEDR=Bg@mail.gmail.com.

0002 - Is the code changes to implement the callbacks and the necessary
tests in the new test module.

[0]: /messages/by-id/CAA5RZ0sG2RUKg=OLY+6-e4q=X9rsLfK3pKn03d=RZQppEDR=Bg@mail.gmail.com

--
Sami Imseih
Amazon Web Services (AWS)

Attachments:

v4-0002-Allow-cumulative-statistics-to-serialize-auxiliar.patchapplication/octet-stream; name=v4-0002-Allow-cumulative-statistics-to-serialize-auxiliar.patchDownload
From 87d34f2c618798efb37f7e225a753d1e3c69b11c Mon Sep 17 00:00:00 2001
From: Sami Imseih <simseih@amazon.com>
Date: Mon, 10 Nov 2025 00:03:41 -0600
Subject: [PATCH v4 2/2] Allow cumulative statistics to serialize auxiliary
 data to disk.

Cumulative Statistics kinds can now write additional per-entry data to
the statistics file that doesn't fit in shared memory. This is useful
for statistics with variable-length auxiliary data.

Three new optional callbacks are added to PgStat_KindInfo:

* to_serialized_extra_stats: writes auxiliary data for an entry
* from_serialized_extra_stats: reads auxiliary data for an entry
* end_extra_stats: performs cleanup after read/write/discard operations

All three callbacks must be provided together to ensure the reader
consumes exactly what the writer produces. The end_extra_stats callback
is invoked after processing all entries of a kind, allowing extensions
to close file handles and clean up resources.

Tests are also added to test_custom_stats.

Discussion: https://www.postgresql.org/message-id/flat/CAA5RZ0s9SDOu+Z6veoJCHWk+kDeTktAtC-KY9fQ9Z6BJdDUirQ@mail.gmail.com
---
 src/backend/utils/activity/pgstat.c           |  81 ++++-
 src/include/utils/pgstat_internal.h           |  37 ++
 .../test_custom_stats/t/001_custom_stats.pl   |  20 +-
 .../test_custom_var_stats--1.0.sql            |   6 +-
 .../test_custom_stats/test_custom_var_stats.c | 327 +++++++++++++++++-
 5 files changed, 447 insertions(+), 24 deletions(-)

diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index 8713c7a0483..8804c1688c5 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -194,6 +194,7 @@ static void pgstat_build_snapshot(void);
 static void pgstat_build_snapshot_fixed(PgStat_Kind kind);
 
 static inline bool pgstat_is_kind_valid(PgStat_Kind kind);
+static inline void pgstat_check_extra_callbacks(const PgStat_KindInfo *kind_info, int elevel);
 
 
 /* ----------
@@ -523,6 +524,7 @@ pgstat_discard_stats(void)
 
 	/* NB: this needs to be done even in single user mode */
 
+	/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
 	ret = unlink(PGSTAT_STAT_PERMANENT_FILENAME);
 	if (ret != 0)
 	{
@@ -544,6 +546,15 @@ pgstat_discard_stats(void)
 								 PGSTAT_STAT_PERMANENT_FILENAME)));
 	}
 
+	/* Let each stats kind run its cleanup callback, if it provides one */
+	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->end_extra_stats)
+			kind_info->end_extra_stats(STATS_DISCARD);
+	}
+
 	/*
 	 * Reset stats contents. This will set reset timestamps of fixed-numbered
 	 * stats to the current time (no variable stats exist).
@@ -645,6 +656,10 @@ pgstat_initialize(void)
 
 	pgstat_attach_shmem();
 
+	/* Check a kind's extra-data callback setup */
+	for (PgStat_Kind kind = PGSTAT_KIND_BUILTIN_MIN; kind <= PGSTAT_KIND_BUILTIN_MAX; kind++)
+		pgstat_check_extra_callbacks(&pgstat_kind_builtin_infos[kind], PANIC);
+
 	pgstat_init_snapshot_fixed();
 
 	/* Backend initialization callbacks */
@@ -1432,6 +1447,32 @@ pgstat_is_kind_valid(PgStat_Kind kind)
 	return pgstat_is_kind_builtin(kind) || pgstat_is_kind_custom(kind);
 }
 
+/*
+ * Validate that extra stats callbacks are all provided together or not at all.
+ * Reports error at specified level if validation fails.
+ */
+static inline void
+pgstat_check_extra_callbacks(const PgStat_KindInfo *kind_info, int elevel)
+{
+	bool		has_extra;
+
+	has_extra =
+		kind_info->to_serialized_extra_stats ||
+		kind_info->from_serialized_extra_stats ||
+		kind_info->end_extra_stats;
+
+	if (has_extra &&
+		(!kind_info->to_serialized_extra_stats ||
+		 !kind_info->from_serialized_extra_stats ||
+		 !kind_info->end_extra_stats))
+	{
+		ereport(elevel,
+				(errmsg("incomplete serialization callbacks for statistics kind \"%s\"",
+						kind_info->name),
+				 errdetail("callbacks to_serialized_extra_stats, from_serialized_extra_stats, and end_extra_stats must be provided together.")));
+	}
+}
+
 const PgStat_KindInfo *
 pgstat_get_kind_info(PgStat_Kind kind)
 {
@@ -1525,6 +1566,9 @@ pgstat_register_kind(PgStat_Kind kind, const PgStat_KindInfo *kind_info)
 					 errdetail("Existing cumulative statistics with ID %u has the same name.", existing_kind)));
 	}
 
+	/* Check a kind's extra-data callback setup */
+	pgstat_check_extra_callbacks(kind_info, ERROR);
+
 	/* Register it */
 	pgstat_kind_custom_infos[idx] = kind_info;
 	ereport(LOG,
@@ -1702,6 +1746,9 @@ pgstat_write_statsfile(void)
 		pgstat_write_chunk(fpout,
 						   pgstat_get_entry_data(ps->key.kind, shstats),
 						   pgstat_get_entry_len(ps->key.kind));
+
+		if (kind_info->to_serialized_extra_stats)
+			kind_info->to_serialized_extra_stats(&ps->key, shstats, fpout);
 	}
 	dshash_seq_term(&hstat);
 
@@ -1734,6 +1781,15 @@ pgstat_write_statsfile(void)
 		/* durable_rename already emitted log message */
 		unlink(tmpfile);
 	}
+
+	/* Now, allow kinds to finalize the writes for the extra files */
+	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->end_extra_stats)
+			kind_info->end_extra_stats(STATS_WRITE);
+	}
 }
 
 /* helper for pgstat_read_statsfile() */
@@ -1871,6 +1927,7 @@ pgstat_read_statsfile(void)
 					PgStat_HashKey key;
 					PgStatShared_HashEntry *p;
 					PgStatShared_Common *header;
+					const PgStat_KindInfo *kind_info = NULL;
 
 					CHECK_FOR_INTERRUPTS();
 
@@ -1891,7 +1948,8 @@ pgstat_read_statsfile(void)
 							goto error;
 						}
 
-						if (!pgstat_get_kind_info(key.kind))
+						kind_info = pgstat_get_kind_info(key.kind);
+						if (!kind_info)
 						{
 							elog(WARNING, "could not find information of kind for entry %u/%u/%" PRIu64 " of type %c",
 								 key.kind, key.dboid,
@@ -1902,7 +1960,6 @@ pgstat_read_statsfile(void)
 					else
 					{
 						/* stats entry identified by name on disk (e.g. slots) */
-						const PgStat_KindInfo *kind_info = NULL;
 						PgStat_Kind kind;
 						NameData	name;
 
@@ -1996,6 +2053,16 @@ pgstat_read_statsfile(void)
 						goto error;
 					}
 
+					if (kind_info->from_serialized_extra_stats)
+					{
+						if (!kind_info->from_serialized_extra_stats(&key, header, fpin))
+						{
+							elog(WARNING, "could not read extra stats for entry %u/%u/%" PRIu64,
+								 key.kind, key.dboid, key.objid);
+							goto error;
+						}
+					}
+
 					break;
 				}
 			case PGSTAT_FILE_ENTRY_END:
@@ -2019,11 +2086,21 @@ pgstat_read_statsfile(void)
 	}
 
 done:
+	/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
 	FreeFile(fpin);
 
 	elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
 	unlink(statfile);
 
+	/* Let each stats kind run its cleanup callback, if it provides one */
+	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->end_extra_stats)
+			kind_info->end_extra_stats(STATS_READ);
+	}
+
 	return;
 
 error:
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index ca1ba6420ca..48b40816570 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -63,6 +63,20 @@ typedef struct PgStat_HashKey
 								 * identifier. */
 } PgStat_HashKey;
 
+/*
+ * Tracks if the stats file is being read, written or discarded.
+ *
+ * These states allow plugins that create extra statistics files
+ * to determine the current operation and perform any necessary
+ * file cleanup.
+ */
+typedef enum PgStat_StatsFileOp
+{
+	STATS_WRITE,
+	STATS_READ,
+	STATS_DISCARD,
+}			PgStat_StatsFileOp;
+
 /*
  * PgStat_HashKey should not have any padding.  Checking that the structure
  * size matches with the sum of each field is a check simple enough to
@@ -303,6 +317,29 @@ typedef struct PgStat_KindInfo
 									   const PgStatShared_Common *header, NameData *name);
 	bool		(*from_serialized_name) (const NameData *name, PgStat_HashKey *key);
 
+	/*
+	 * Optional callbacks for kinds that write additional per-entry data to
+	 * the stats file.  If any of these callbacks are provided, all three must
+	 * be provided to ensure that the reader consumes exactly the data written
+	 * by the writer.
+	 *
+	 * to_serialized_extra_stats: write extra data for an entry.
+	 *
+	 * from_serialized_extra_stats: read the extra data for an entry. Returns
+	 * true on success, false on read error.
+	 *
+	 * end_extra_stats: invoked once per operation (read, write, discard)
+	 * after all entries of this kind have been processed.
+	 *
+	 * Note: statfile is a pointer to the main stats file,
+	 * PGSTAT_STAT_PERMANENT_FILENAME.
+	 */
+	void		(*to_serialized_extra_stats) (const PgStat_HashKey *key,
+											  const PgStatShared_Common *header, FILE *statfile);
+	bool		(*from_serialized_extra_stats) (const PgStat_HashKey *key,
+												const PgStatShared_Common *header, FILE *statfile);
+	void		(*end_extra_stats) (PgStat_StatsFileOp status);
+
 	/*
 	 * For fixed-numbered statistics: Initialize shared memory state.
 	 *
diff --git a/src/test/modules/test_custom_stats/t/001_custom_stats.pl b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
index c4fceb7d267..55a8956a0d9 100644
--- a/src/test/modules/test_custom_stats/t/001_custom_stats.pl
+++ b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
@@ -26,10 +26,10 @@ $node->safe_psql('postgres', q(CREATE EXTENSION test_custom_var_stats));
 $node->safe_psql('postgres', q(CREATE EXTENSION test_custom_fixed_stats));
 
 # Create variable statistics entries
-$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry1')));
-$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry2')));
-$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry3')));
-$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry4')));
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry1', 'Test entry 1')));
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry2', 'Test entry 2')));
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry3', 'Test entry 3')));
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry4', 'Test entry 4')));
 
 # Update counters: entry1=2, entry2=3, entry3=2, entry4=3, fixed=3
 $node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry1')));
@@ -48,16 +48,16 @@ $node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
 
 # Test variable statistics reporting
 my $result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry1')));
-is($result, "entry1|2", "var stats entry1 reports correct calls");
+is($result, "entry1|2|Test entry 1", "var stats entry1 reports correct calls");
 
 $result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry2')));
-is($result, "entry2|3", "var stats entry2 reports correct calls");
+is($result, "entry2|3|Test entry 2", "var stats entry2 reports correct calls");
 
 $result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry3')));
-is($result, "entry3|2", "var stats entry3 reports correct calls");
+is($result, "entry3|2|Test entry 3", "var stats entry3 reports correct calls");
 
 $result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry4')));
-is($result, "entry4|3", "var stats entry4 reports correct calls");
+is($result, "entry4|3|Test entry 4", "var stats entry4 reports correct calls");
 
 $result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_fixed_stats()));
 is($result, "3|", "fixed stats reports correct calls");
@@ -76,10 +76,10 @@ $node->stop();
 $node->start();
 
 $result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry1')));
-is($result, "entry1|2", "var stats entry1 persists after clean restart");
+is($result, "entry1|2|Test entry 1", "var stats entry1 persists after clean restart");
 
 $result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry2')));
-is($result, "entry2|3", "var stats entry2 persists after clean restart");
+is($result, "entry2|3|Test entry 2", "var stats entry2 persists after clean restart");
 
 $result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_fixed_stats()));
 is($result, "3|", "fixed stats persists after clean restart");
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
index 84ae2bf5666..c509567de7d 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
@@ -3,7 +3,7 @@
 -- complain if script is sourced in psql, rather than via CREATE EXTENSION
 \echo Use "CREATE EXTENSION test_custom_var_stats" to load this file. \quit
 
-CREATE FUNCTION pgstat_create_custom_var_stats(IN name TEXT)
+CREATE FUNCTION pgstat_create_custom_var_stats(IN name TEXT, in description TEXT)
 RETURNS void
 AS 'MODULE_PATHNAME', 'pgstat_create_custom_var_stats'
 LANGUAGE C STRICT PARALLEL UNSAFE;
@@ -19,7 +19,9 @@ AS 'MODULE_PATHNAME', 'pgstat_drop_custom_var_stats'
 LANGUAGE C STRICT PARALLEL UNSAFE;
 
 
-CREATE FUNCTION pgstat_report_custom_var_stats(INOUT name TEXT, OUT calls BIGINT)
+CREATE FUNCTION pgstat_report_custom_var_stats(INOUT name TEXT,
+                                               OUT calls BIGINT,
+                                               OUT description TEXT)
 RETURNS SETOF record
 AS 'MODULE_PATHNAME', 'pgstat_report_custom_var_stats'
 LANGUAGE C STRICT PARALLEL UNSAFE;
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats.c b/src/test/modules/test_custom_stats/test_custom_var_stats.c
index 6320eaf2cae..6d755904df5 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats.c
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats.c
@@ -14,6 +14,7 @@
 
 #include "common/hashfn.h"
 #include "funcapi.h"
+#include "storage/dsm_registry.h"
 #include "utils/builtins.h"
 #include "utils/pgstat_internal.h"
 
@@ -33,6 +34,9 @@ PG_MODULE_MAGIC_EXT(
  */
 #define PGSTAT_KIND_TEST_CUSTOM_VAR_STATS 25
 
+/* File paths for extra statistics data serialization */
+#define PGSTAT_CUSTOM_EXTRA_DATA_DESC "pg_stat/test_custom_var_stats_desc.stats"
+
 /*
  * Hash statistic name to generate entry index for pgstat lookup.
  */
@@ -54,15 +58,41 @@ typedef struct PgStatShared_CustomEntry
 {
 	PgStatShared_Common header; /* standard pgstat entry header */
 	PgStat_StatCustomEntry stats;	/* custom statistics data */
+	dsa_pointer description;	/* extra statistics data */
 }			PgStatShared_CustomEntry;
 
+/*--------------------------------------------------------------------------
+ * Global Variables
+ *--------------------------------------------------------------------------
+ */
+
+/* File handle for extra statistics data serialization */
+static FILE *fd_description = NULL;
+
+/* Current write offset in fd_description file */
+static long fd_description_offset = 0;
+
+/* DSA area for storing variable-length description strings */
+dsa_area   *custom_stats_description_dsa = NULL;
+
 /*--------------------------------------------------------------------------
  * Function prototypes
  *--------------------------------------------------------------------------
  */
 
 /* Flush callback: merge pending stats into shared memory */
-static bool pgstat_custom_entry_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
+static bool pgstat_custom_var_entry_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
+
+/* Serialization callback: serialize extra statistics data */
+static void pgstat_custom_var_stats_serialize(const PgStat_HashKey *key,
+											  const PgStatShared_Common *header, FILE *statfile);
+
+/* Deserialization callback: deserialize extra statistics data */
+static bool pgstat_custom_var_stats_deserialize(const PgStat_HashKey *key,
+												const PgStatShared_Common *header, FILE *statfile);
+
+/* Cleanup callback: end of statistics file operations */
+static void pgstat_custom_var_stats_file_cleanup(PgStat_StatsFileOp status);
 
 /*--------------------------------------------------------------------------
  * Custom kind configuration
@@ -79,7 +109,10 @@ static const PgStat_KindInfo custom_stats = {
 	.shared_data_off = offsetof(PgStatShared_CustomEntry, stats),
 	.shared_data_len = sizeof(((PgStatShared_CustomEntry *) 0)->stats),
 	.pending_size = sizeof(PgStat_StatCustomEntry),
-	.flush_pending_cb = pgstat_custom_entry_flush_cb,
+	.flush_pending_cb = pgstat_custom_var_entry_flush_cb,
+	.to_serialized_extra_stats = pgstat_custom_var_stats_serialize,
+	.from_serialized_extra_stats = pgstat_custom_var_stats_deserialize,
+	.end_extra_stats = pgstat_custom_var_stats_file_cleanup,
 };
 
 /*--------------------------------------------------------------------------
@@ -113,7 +146,7 @@ _PG_init(void)
  * Returns false only if nowait=true and lock acquisition fails.
  */
 static bool
-pgstat_custom_entry_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
+pgstat_custom_var_entry_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
 {
 	PgStat_StatCustomEntry *pending_entry;
 	PgStatShared_CustomEntry *shared_entry;
@@ -132,6 +165,234 @@ pgstat_custom_entry_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
 	return true;
 }
 
+/*
+ * pgstat_custom_var_stats_serialize() -
+ *
+ * Serialize extra data (descriptions) for custom statistics entries to
+ * the statistics file. Called during statistics file writing to preserve
+ * description strings across restarts.
+ */
+static void
+pgstat_custom_var_stats_serialize(const PgStat_HashKey *key,
+								  const PgStatShared_Common *header, FILE *statfile)
+{
+	char	   *description;
+	size_t		len;
+	long		offset;
+	PgStatShared_CustomEntry *entry = (PgStatShared_CustomEntry *) header;
+	bool		found;
+
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("pgstat_custom_stat_dsa", &found);
+
+	/* Open statistics file for writing if not already open */
+	if (!fd_description)
+	{
+		fd_description = AllocateFile(PGSTAT_CUSTOM_EXTRA_DATA_DESC, PG_BINARY_W);
+		if (fd_description == NULL)
+		{
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not open statistics file \"%s\" for writing: %m",
+							PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+			len = 0;
+			offset = 0;
+			fwrite(&len, sizeof(len), 1, statfile);
+			fwrite(&offset, sizeof(offset), 1, statfile);
+			return;
+		}
+		fd_description_offset = 0;
+	}
+
+	/* Handle entries without descriptions */
+	if (!DsaPointerIsValid(entry->description) || !custom_stats_description_dsa)
+	{
+		len = 0;
+		offset = 0;
+		fwrite(&len, sizeof(len), 1, statfile);
+		fwrite(&offset, sizeof(offset), 1, statfile);
+		return;
+	}
+
+	/* Get current offset in fd_description */
+	offset = fd_description_offset;
+
+	/* Retrieve description from DSA and write to fd_description */
+	description = dsa_get_address(custom_stats_description_dsa, entry->description);
+	len = strlen(description) + 1;
+	fwrite(description, 1, len, fd_description);
+	fd_description_offset += len;
+
+	/* Write length and offset to statfile */
+	fwrite(&len, sizeof(len), 1, statfile);
+	fwrite(&offset, sizeof(offset), 1, statfile);
+}
+
+/*
+ * pgstat_custom_var_stats_deserialize() -
+ *
+ * Deserialize extra data (descriptions) for custom statistics entries from
+ * the statistics file. Called during statistics file reading to restore
+ * description strings after a restart.
+ */
+static bool
+pgstat_custom_var_stats_deserialize(const PgStat_HashKey *key,
+									const PgStatShared_Common *header, FILE *statfile)
+{
+	PgStatShared_CustomEntry *entry;
+	dsa_pointer dp;
+	size_t		len;
+	long		offset;
+	char	   *buffer;
+	bool		found;
+
+	/* Read length and offset from statfile */
+	if (fread(&len, sizeof(len), 1, statfile) != 1 ||
+		fread(&offset, sizeof(offset), 1, statfile) != 1)
+	{
+		elog(WARNING, "failed to read description metadata from statistics file");
+		return false;
+	}
+
+	entry = (PgStatShared_CustomEntry *) header;
+
+	/* Handle empty descriptions */
+	if (len == 0)
+	{
+		entry->description = InvalidDsaPointer;
+		return true;
+	}
+
+	/* Initialize DSA if needed */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("pgstat_custom_stat_dsa", &found);
+
+	if (!custom_stats_description_dsa)
+	{
+		elog(WARNING, "could not access DSA for custom statistics descriptions");
+		return false;
+	}
+
+	/* Open statistics file for reading if not already open */
+	if (!fd_description)
+	{
+		fd_description = AllocateFile(PGSTAT_CUSTOM_EXTRA_DATA_DESC, PG_BINARY_R);
+		if (fd_description == NULL)
+		{
+			if (errno != ENOENT)
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not open statistics file \"%s\" for reading: %m",
+								PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+			pgstat_reset_of_kind(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS);
+			return false;
+		}
+	}
+
+	/* Seek to the offset and read description */
+	if (fseek(fd_description, offset, SEEK_SET) != 0)
+	{
+		elog(WARNING, "failed to seek to offset %ld in description file", offset);
+		return false;
+	}
+
+	buffer = palloc(len);
+	if (fread(buffer, 1, len, fd_description) != len)
+	{
+		pfree(buffer);
+		elog(WARNING, "failed to read description from file");
+		return false;
+	}
+
+	/* Allocate space in DSA and copy the description */
+	dp = dsa_allocate(custom_stats_description_dsa, len);
+	memcpy(dsa_get_address(custom_stats_description_dsa, dp), buffer, len);
+	entry->description = dp;
+
+	pfree(buffer);
+
+	return true;
+}
+
+/*
+ * pgstat_custom_var_stats_file_cleanup() -
+ *
+ * Cleanup function called at the end of statistics file operations.
+ * Handles closing files and cleanup based on the operation type.
+ */
+static void
+pgstat_custom_var_stats_file_cleanup(PgStat_StatsFileOp status)
+{
+	switch (status)
+	{
+		case STATS_WRITE:
+			if (!fd_description)
+				return;
+
+			fd_description_offset = 0;
+
+			/* Check for write errors and cleanup if necessary */
+			if (ferror(fd_description))
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not write statistics file \"%s\": %m",
+								PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+				FreeFile(fd_description);
+				unlink(PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+			}
+			else if (FreeFile(fd_description) < 0)
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not close statistics file \"%s\": %m",
+								PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+				unlink(PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+			}
+			break;
+
+		case STATS_READ:
+			if (!fd_description)
+				return;
+
+			FreeFile(fd_description);
+
+			/* Remove the temporary statistics file after reading */
+			elog(DEBUG2, "removing statistics file \"%s\"", PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+			unlink(PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+			break;
+
+		case STATS_DISCARD:
+			{
+				int			ret;
+
+				/* Attempt to remove the statistics file */
+				ret = unlink(PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+				if (ret != 0)
+				{
+					if (errno == ENOENT)
+						elog(LOG,
+							 "didn't need to unlink permanent stats file \"%s\" - didn't exist",
+							 PGSTAT_CUSTOM_EXTRA_DATA_DESC);
+					else
+						ereport(LOG,
+								(errcode_for_file_access(),
+								 errmsg("could not unlink permanent statistics file \"%s\": %m",
+										PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+				}
+				else
+				{
+					ereport(LOG,
+							(errmsg_internal("unlinked permanent statistics file \"%s\"",
+											 PGSTAT_CUSTOM_EXTRA_DATA_DESC)));
+				}
+			}
+			break;
+	}
+
+	fd_description = NULL;
+}
+
 /*--------------------------------------------------------------------------
  * Helper functions
  *--------------------------------------------------------------------------
@@ -161,8 +422,7 @@ pgstat_fetch_custom_entry(const char *stat_name)
  * pgstat_create_custom_var_stats
  *		Create new custom statistic entry
  *
- * Initializes a zero-valued statistics entry in shared memory.
- * Validates name length against NAMEDATALEN limit.
+ * Initializes a statistics entry with the given name and description.
  */
 PG_FUNCTION_INFO_V1(pgstat_create_custom_var_stats);
 Datum
@@ -171,6 +431,9 @@ pgstat_create_custom_var_stats(PG_FUNCTION_ARGS)
 	PgStat_EntryRef *entry_ref;
 	PgStatShared_CustomEntry *shared_entry;
 	char	   *stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	char	   *description = text_to_cstring(PG_GETARG_TEXT_PP(1));
+	dsa_pointer dp = InvalidDsaPointer;
+	bool		found;
 
 	/* Validate name length first */
 	if (strlen(stat_name) >= NAMEDATALEN)
@@ -179,6 +442,20 @@ pgstat_create_custom_var_stats(PG_FUNCTION_ARGS)
 				 errmsg("custom statistic name \"%s\" is too long", stat_name),
 				 errdetail("Name must be less than %d characters.", NAMEDATALEN)));
 
+	/* Initialize DSA and description provided */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("pgstat_custom_stat_dsa", &found);
+
+	if (!custom_stats_description_dsa)
+		ereport(ERROR,
+				(errmsg("could not access DSA for custom statistics descriptions")));
+
+	/* Allocate space in DSA and copy description */
+	dp = dsa_allocate(custom_stats_description_dsa, strlen(description) + 1);
+	memcpy(dsa_get_address(custom_stats_description_dsa, dp),
+		   description,
+		   strlen(description) + 1);
+
 	/* Create or get existing entry */
 	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
 											PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), true);
@@ -191,6 +468,9 @@ pgstat_create_custom_var_stats(PG_FUNCTION_ARGS)
 	/* Zero-initialize statistics */
 	memset(&shared_entry->stats, 0, sizeof(shared_entry->stats));
 
+	/* Store description pointer */
+	shared_entry->description = dp;
+
 	pgstat_unlock_entry(entry_ref);
 
 	PG_RETURN_VOID();
@@ -225,8 +505,7 @@ pgstat_update_custom_var_stats(PG_FUNCTION_ARGS)
  * pgstat_drop_custom_var_stats
  *		Remove custom statistic entry
  *
- * Drops the named statistic from shared memory and requests
- * garbage collection if needed.
+ * Drops the named statistic from shared memory.
  */
 PG_FUNCTION_INFO_V1(pgstat_drop_custom_var_stats);
 Datum
@@ -246,7 +525,7 @@ pgstat_drop_custom_var_stats(PG_FUNCTION_ARGS)
  * pgstat_report_custom_var_stats
  *		Retrieve custom statistic values
  *
- * Returns single row with statistic name and call count if the
+ * Returns single row with statistic name, call count, and description if the
  * statistic exists, otherwise returns no rows.
  */
 PG_FUNCTION_INFO_V1(pgstat_report_custom_var_stats);
@@ -280,9 +559,13 @@ pgstat_report_custom_var_stats(PG_FUNCTION_ARGS)
 
 	if (funcctx->call_cntr < funcctx->max_calls)
 	{
-		Datum		values[2];
-		bool		nulls[2] = {false, false};
+		Datum		values[3];
+		bool		nulls[3] = {false, false, false};
 		HeapTuple	tuple;
+		PgStat_EntryRef *entry_ref;
+		PgStatShared_CustomEntry *shared_entry;
+		char	   *description = NULL;
+		bool		found;
 
 		stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
 		stat_entry = pgstat_fetch_custom_entry(stat_name);
@@ -290,9 +573,33 @@ pgstat_report_custom_var_stats(PG_FUNCTION_ARGS)
 		/* Return row only if entry exists */
 		if (stat_entry)
 		{
+			/* Get entry ref to access shared entry */
+			entry_ref = pgstat_get_entry_ref(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
+											 PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), false, NULL);
+
+			if (entry_ref)
+			{
+				shared_entry = (PgStatShared_CustomEntry *) entry_ref->shared_stats;
+
+				/* Get description from DSA if available */
+				if (DsaPointerIsValid(shared_entry->description))
+				{
+					if (!custom_stats_description_dsa)
+						custom_stats_description_dsa = GetNamedDSA("pgstat_custom_stat_dsa", &found);
+
+					if (custom_stats_description_dsa)
+						description = dsa_get_address(custom_stats_description_dsa, shared_entry->description);
+				}
+			}
+
 			values[0] = PointerGetDatum(cstring_to_text(stat_name));
 			values[1] = Int64GetDatum(stat_entry->numcalls);
 
+			if (description)
+				values[2] = PointerGetDatum(cstring_to_text(description));
+			else
+				nulls[2] = true;
+
 			tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
 			SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
 		}
-- 
2.43.0

v4-0001-Move-custom-stats-tests-from-injection_points-to-.patchapplication/octet-stream; name=v4-0001-Move-custom-stats-tests-from-injection_points-to-.patchDownload
From 2bcde3ae93c6f45a71d399ebdd831398361a821d Mon Sep 17 00:00:00 2001
From: Ubuntu <ubuntu@ip-172-31-46-230.ec2.internal>
Date: Sat, 6 Dec 2025 00:54:06 +0000
Subject: [PATCH v4 1/2] Move custom stats tests from injection_points to
 dedicated module

Extract custom statistics testing code from injection_points module
into new test_custom_stats module with separate variable and fixed
statistics components. This improves test organization and removes
statistics functionality that was not core to injection points.

Discussion: https://www.postgresql.org/message-id/CAA5RZ0sJgO6GAwgFxmzg9MVP%3DrM7Us8KKcWpuqxe-f5qxmpE0g%40mail.gmail.com
---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/injection_points/Makefile    |   4 -
 .../injection_points--1.0.sql                 |  43 ---
 .../injection_points/injection_points.c       |  48 ---
 .../injection_points/injection_stats.c        | 228 -------------
 .../injection_points/injection_stats.h        |  35 --
 .../injection_points/injection_stats_fixed.c  | 214 -------------
 src/test/modules/injection_points/meson.build |  12 -
 .../modules/injection_points/t/001_stats.pl   | 103 ------
 src/test/modules/meson.build                  |   1 +
 src/test/modules/test_custom_stats/.gitignore |   4 +
 src/test/modules/test_custom_stats/Makefile   |  27 ++
 .../modules/test_custom_stats/meson.build     |  55 ++++
 .../test_custom_stats/t/001_custom_stats.pl   | 115 +++++++
 .../test_custom_fixed_stats--1.0.sql          |  20 ++
 .../test_custom_fixed_stats.c                 | 224 +++++++++++++
 .../test_custom_fixed_stats.control           |   4 +
 .../test_custom_var_stats--1.0.sql            |  25 ++
 .../test_custom_stats/test_custom_var_stats.c | 302 ++++++++++++++++++
 .../test_custom_var_stats.control             |   4 +
 20 files changed, 782 insertions(+), 687 deletions(-)
 delete mode 100644 src/test/modules/injection_points/injection_stats.c
 delete mode 100644 src/test/modules/injection_points/injection_stats.h
 delete mode 100644 src/test/modules/injection_points/injection_stats_fixed.c
 delete mode 100644 src/test/modules/injection_points/t/001_stats.pl
 create mode 100644 src/test/modules/test_custom_stats/.gitignore
 create mode 100644 src/test/modules/test_custom_stats/Makefile
 create mode 100644 src/test/modules/test_custom_stats/meson.build
 create mode 100644 src/test/modules/test_custom_stats/t/001_custom_stats.pl
 create mode 100644 src/test/modules/test_custom_stats/test_custom_fixed_stats--1.0.sql
 create mode 100644 src/test/modules/test_custom_stats/test_custom_fixed_stats.c
 create mode 100644 src/test/modules/test_custom_stats/test_custom_fixed_stats.control
 create mode 100644 src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
 create mode 100644 src/test/modules/test_custom_stats/test_custom_var_stats.c
 create mode 100644 src/test/modules/test_custom_stats/test_custom_var_stats.control

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index d079b91b1a2..4a109ccbf6c 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -21,6 +21,7 @@ SUBDIRS = \
 		  test_bloomfilter \
 		  test_copy_callbacks \
 		  test_custom_rmgrs \
+		  test_custom_stats \
 		  test_ddl_deparse \
 		  test_dsa \
 		  test_dsm_registry \
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index a618e6a9899..c85034eb8cc 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -4,8 +4,6 @@ MODULE_big = injection_points
 OBJS = \
 	$(WIN32RES) \
 	injection_points.o \
-	injection_stats.o \
-	injection_stats_fixed.o \
 	regress_injection.o
 EXTENSION = injection_points
 DATA = injection_points--1.0.sql
@@ -23,8 +21,6 @@ ISOLATION = basic \
 	    reindex-concurrently-upsert-on-constraint \
 	    reindex-concurrently-upsert-partitioned
 
-TAP_TESTS = 1
-
 # The injection points are cluster-wide, so disable installcheck
 NO_INSTALLCHECK = 1
 
diff --git a/src/test/modules/injection_points/injection_points--1.0.sql b/src/test/modules/injection_points/injection_points--1.0.sql
index a51ff538684..861c7355d4e 100644
--- a/src/test/modules/injection_points/injection_points--1.0.sql
+++ b/src/test/modules/injection_points/injection_points--1.0.sql
@@ -101,49 +101,6 @@ RETURNS SETOF record
 AS 'MODULE_PATHNAME', 'injection_points_list'
 LANGUAGE C STRICT VOLATILE PARALLEL RESTRICTED;
 
---
--- injection_points_stats_numcalls()
---
--- Reports statistics, if any, related to the given injection point.
---
-CREATE FUNCTION injection_points_stats_numcalls(IN point_name TEXT)
-RETURNS bigint
-AS 'MODULE_PATHNAME', 'injection_points_stats_numcalls'
-LANGUAGE C STRICT;
-
---
--- injection_points_stats_count()
---
--- Return the number of entries stored in the pgstats hash table.
---
-CREATE FUNCTION injection_points_stats_count()
-RETURNS bigint
-AS 'MODULE_PATHNAME', 'injection_points_stats_count'
-LANGUAGE C STRICT;
-
---
--- injection_points_stats_drop()
---
--- Drop all statistics of injection points.
---
-CREATE FUNCTION injection_points_stats_drop()
-RETURNS void
-AS 'MODULE_PATHNAME', 'injection_points_stats_drop'
-LANGUAGE C STRICT;
-
---
--- injection_points_stats_fixed()
---
--- Reports fixed-numbered statistics for injection points.
-CREATE FUNCTION injection_points_stats_fixed(OUT numattach int8,
-   OUT numdetach int8,
-   OUT numrun int8,
-   OUT numcached int8,
-   OUT numloaded int8)
-RETURNS record
-AS 'MODULE_PATHNAME', 'injection_points_stats_fixed'
-LANGUAGE C STRICT;
-
 --
 -- regress_injection.c functions
 --
diff --git a/src/test/modules/injection_points/injection_points.c b/src/test/modules/injection_points/injection_points.c
index b7c1c58ea56..417b61f31c5 100644
--- a/src/test/modules/injection_points/injection_points.c
+++ b/src/test/modules/injection_points/injection_points.c
@@ -19,7 +19,6 @@
 
 #include "fmgr.h"
 #include "funcapi.h"
-#include "injection_stats.h"
 #include "miscadmin.h"
 #include "nodes/pg_list.h"
 #include "nodes/value.h"
@@ -107,15 +106,6 @@ extern PGDLLEXPORT void injection_wait(const char *name,
 /* track if injection points attached in this process are linked to it */
 static bool injection_point_local = false;
 
-/*
- * GUC variable
- *
- * This GUC is useful to control if statistics should be enabled or not
- * during a test with injection points, like for example if a test relies
- * on a callback run in a critical section where no allocation should happen.
- */
-bool		inj_stats_enabled = false;
-
 /* Shared memory init callbacks */
 static shmem_request_hook_type prev_shmem_request_hook = NULL;
 static shmem_startup_hook_type prev_shmem_startup_hook = NULL;
@@ -235,9 +225,6 @@ injection_points_cleanup(int code, Datum arg)
 		char	   *name = strVal(lfirst(lc));
 
 		(void) InjectionPointDetach(name);
-
-		/* Remove stats entry */
-		pgstat_drop_inj(name);
 	}
 }
 
@@ -251,8 +238,6 @@ injection_error(const char *name, const void *private_data, void *arg)
 	if (!injection_point_allowed(condition))
 		return;
 
-	pgstat_report_inj(name);
-
 	if (argstr)
 		elog(ERROR, "error triggered for injection point %s (%s)",
 			 name, argstr);
@@ -269,8 +254,6 @@ injection_notice(const char *name, const void *private_data, void *arg)
 	if (!injection_point_allowed(condition))
 		return;
 
-	pgstat_report_inj(name);
-
 	if (argstr)
 		elog(NOTICE, "notice triggered for injection point %s (%s)",
 			 name, argstr);
@@ -293,8 +276,6 @@ injection_wait(const char *name, const void *private_data, void *arg)
 	if (!injection_point_allowed(condition))
 		return;
 
-	pgstat_report_inj(name);
-
 	/*
 	 * Use the injection point name for this custom wait event.  Note that
 	 * this custom wait event name is not released, but we don't care much for
@@ -371,7 +352,6 @@ injection_points_attach(PG_FUNCTION_ARGS)
 		condition.pid = MyProcPid;
 	}
 
-	pgstat_report_inj_fixed(1, 0, 0, 0, 0);
 	InjectionPointAttach(name, "injection_points", function, &condition,
 						 sizeof(InjectionPointCondition));
 
@@ -385,9 +365,6 @@ injection_points_attach(PG_FUNCTION_ARGS)
 		MemoryContextSwitchTo(oldctx);
 	}
 
-	/* Add entry for stats */
-	pgstat_create_inj(name);
-
 	PG_RETURN_VOID();
 }
 
@@ -422,7 +399,6 @@ injection_points_attach_func(PG_FUNCTION_ARGS)
 		private_data_size = VARSIZE_ANY_EXHDR(private_data);
 	}
 
-	pgstat_report_inj_fixed(1, 0, 0, 0, 0);
 	if (private_data != NULL)
 		InjectionPointAttach(name, lib_name, function, VARDATA_ANY(private_data),
 							 private_data_size);
@@ -444,7 +420,6 @@ injection_points_load(PG_FUNCTION_ARGS)
 	if (inj_state == NULL)
 		injection_init_shmem();
 
-	pgstat_report_inj_fixed(0, 0, 0, 0, 1);
 	INJECTION_POINT_LOAD(name);
 
 	PG_RETURN_VOID();
@@ -467,7 +442,6 @@ injection_points_run(PG_FUNCTION_ARGS)
 	if (!PG_ARGISNULL(1))
 		arg = text_to_cstring(PG_GETARG_TEXT_PP(1));
 
-	pgstat_report_inj_fixed(0, 0, 1, 0, 0);
 	INJECTION_POINT(name, arg);
 
 	PG_RETURN_VOID();
@@ -490,7 +464,6 @@ injection_points_cached(PG_FUNCTION_ARGS)
 	if (!PG_ARGISNULL(1))
 		arg = text_to_cstring(PG_GETARG_TEXT_PP(1));
 
-	pgstat_report_inj_fixed(0, 0, 0, 1, 0);
 	INJECTION_POINT_CACHED(name, arg);
 
 	PG_RETURN_VOID();
@@ -567,7 +540,6 @@ injection_points_detach(PG_FUNCTION_ARGS)
 {
 	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
 
-	pgstat_report_inj_fixed(0, 1, 0, 0, 0);
 	if (!InjectionPointDetach(name))
 		elog(ERROR, "could not detach injection point \"%s\"", name);
 
@@ -581,9 +553,6 @@ injection_points_detach(PG_FUNCTION_ARGS)
 		MemoryContextSwitchTo(oldctx);
 	}
 
-	/* Remove stats entry */
-	pgstat_drop_inj(name);
-
 	PG_RETURN_VOID();
 }
 
@@ -625,32 +594,15 @@ injection_points_list(PG_FUNCTION_ARGS)
 #undef NUM_INJECTION_POINTS_LIST
 }
 
-
 void
 _PG_init(void)
 {
 	if (!process_shared_preload_libraries_in_progress)
 		return;
 
-	DefineCustomBoolVariable("injection_points.stats",
-							 "Enables statistics for injection points.",
-							 NULL,
-							 &inj_stats_enabled,
-							 false,
-							 PGC_POSTMASTER,
-							 0,
-							 NULL,
-							 NULL,
-							 NULL);
-
-	MarkGUCPrefixReserved("injection_points");
-
 	/* Shared memory initialization */
 	prev_shmem_request_hook = shmem_request_hook;
 	shmem_request_hook = injection_shmem_request;
 	prev_shmem_startup_hook = shmem_startup_hook;
 	shmem_startup_hook = injection_shmem_startup;
-
-	pgstat_register_inj();
-	pgstat_register_inj_fixed();
 }
diff --git a/src/test/modules/injection_points/injection_stats.c b/src/test/modules/injection_points/injection_stats.c
deleted file mode 100644
index 158e1631af9..00000000000
--- a/src/test/modules/injection_points/injection_stats.c
+++ /dev/null
@@ -1,228 +0,0 @@
-/*--------------------------------------------------------------------------
- *
- * injection_stats.c
- *		Code for statistics of injection points.
- *
- * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
- * Portions Copyright (c) 1994, Regents of the University of California
- *
- * IDENTIFICATION
- *		src/test/modules/injection_points/injection_stats.c
- *
- * -------------------------------------------------------------------------
- */
-
-#include "postgres.h"
-
-#include "fmgr.h"
-
-#include "common/hashfn.h"
-#include "injection_stats.h"
-#include "pgstat.h"
-#include "utils/builtins.h"
-#include "utils/pgstat_internal.h"
-
-/* Structures for statistics of injection points */
-typedef struct PgStat_StatInjEntry
-{
-	PgStat_Counter numcalls;	/* number of times point has been run */
-} PgStat_StatInjEntry;
-
-typedef struct PgStatShared_InjectionPoint
-{
-	PgStatShared_Common header;
-	PgStat_StatInjEntry stats;
-} PgStatShared_InjectionPoint;
-
-static bool injection_stats_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
-
-static const PgStat_KindInfo injection_stats = {
-	.name = "injection_points",
-	.fixed_amount = false,		/* Bounded by the number of points */
-	.write_to_file = true,
-	.track_entry_count = true,
-
-	/* Injection points are system-wide */
-	.accessed_across_databases = true,
-
-	.shared_size = sizeof(PgStatShared_InjectionPoint),
-	.shared_data_off = offsetof(PgStatShared_InjectionPoint, stats),
-	.shared_data_len = sizeof(((PgStatShared_InjectionPoint *) 0)->stats),
-	.pending_size = sizeof(PgStat_StatInjEntry),
-	.flush_pending_cb = injection_stats_flush_cb,
-};
-
-/*
- * Compute stats entry idx from point name with an 8-byte hash.
- */
-#define PGSTAT_INJ_IDX(name) hash_bytes_extended((const unsigned char *) name, strlen(name), 0)
-
-/*
- * Kind ID reserved for statistics of injection points.
- */
-#define PGSTAT_KIND_INJECTION	25
-
-/* Track if stats are loaded */
-static bool inj_stats_loaded = false;
-
-/*
- * Callback for stats handling
- */
-static bool
-injection_stats_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
-{
-	PgStat_StatInjEntry *localent;
-	PgStatShared_InjectionPoint *shfuncent;
-
-	localent = (PgStat_StatInjEntry *) entry_ref->pending;
-	shfuncent = (PgStatShared_InjectionPoint *) entry_ref->shared_stats;
-
-	if (!pgstat_lock_entry(entry_ref, nowait))
-		return false;
-
-	shfuncent->stats.numcalls += localent->numcalls;
-
-	pgstat_unlock_entry(entry_ref);
-
-	return true;
-}
-
-/*
- * Support function for the SQL-callable pgstat* functions.  Returns
- * a pointer to the injection point statistics struct.
- */
-static PgStat_StatInjEntry *
-pgstat_fetch_stat_injentry(const char *name)
-{
-	PgStat_StatInjEntry *entry = NULL;
-
-	if (!inj_stats_loaded || !inj_stats_enabled)
-		return NULL;
-
-	/* Compile the lookup key as a hash of the point name */
-	entry = (PgStat_StatInjEntry *) pgstat_fetch_entry(PGSTAT_KIND_INJECTION,
-													   InvalidOid,
-													   PGSTAT_INJ_IDX(name));
-	return entry;
-}
-
-/*
- * Workhorse to do the registration work, called in _PG_init().
- */
-void
-pgstat_register_inj(void)
-{
-	pgstat_register_kind(PGSTAT_KIND_INJECTION, &injection_stats);
-
-	/* mark stats as loaded */
-	inj_stats_loaded = true;
-}
-
-/*
- * Report injection point creation.
- */
-void
-pgstat_create_inj(const char *name)
-{
-	PgStat_EntryRef *entry_ref;
-	PgStatShared_InjectionPoint *shstatent;
-
-	/* leave if disabled */
-	if (!inj_stats_loaded || !inj_stats_enabled)
-		return;
-
-	entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_INJECTION, InvalidOid,
-										  PGSTAT_INJ_IDX(name), NULL);
-
-	shstatent = (PgStatShared_InjectionPoint *) entry_ref->shared_stats;
-
-	/* initialize shared memory data */
-	memset(&shstatent->stats, 0, sizeof(shstatent->stats));
-}
-
-/*
- * Report injection point drop.
- */
-void
-pgstat_drop_inj(const char *name)
-{
-	/* leave if disabled */
-	if (!inj_stats_loaded || !inj_stats_enabled)
-		return;
-
-	if (!pgstat_drop_entry(PGSTAT_KIND_INJECTION, InvalidOid,
-						   PGSTAT_INJ_IDX(name)))
-		pgstat_request_entry_refs_gc();
-}
-
-/*
- * Report statistics for injection point.
- *
- * This is simple because the set of stats to report currently is simple:
- * track the number of times a point has been run.
- */
-void
-pgstat_report_inj(const char *name)
-{
-	PgStat_EntryRef *entry_ref;
-	PgStat_StatInjEntry *pending;
-
-	/* leave if disabled */
-	if (!inj_stats_loaded || !inj_stats_enabled)
-		return;
-
-	entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_INJECTION, InvalidOid,
-										  PGSTAT_INJ_IDX(name), NULL);
-
-	pending = (PgStat_StatInjEntry *) entry_ref->pending;
-
-	/* Update the injection point pending statistics */
-	pending->numcalls++;
-}
-
-/*
- * SQL function returning the number of times an injection point
- * has been called.
- */
-PG_FUNCTION_INFO_V1(injection_points_stats_numcalls);
-Datum
-injection_points_stats_numcalls(PG_FUNCTION_ARGS)
-{
-	char	   *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
-	PgStat_StatInjEntry *entry = pgstat_fetch_stat_injentry(name);
-
-	if (entry == NULL)
-		PG_RETURN_NULL();
-
-	PG_RETURN_INT64(entry->numcalls);
-}
-
-/*
- * SQL function returning the number of entries allocated for injection
- * points in the shared hashtable of pgstats.
- */
-PG_FUNCTION_INFO_V1(injection_points_stats_count);
-Datum
-injection_points_stats_count(PG_FUNCTION_ARGS)
-{
-	PG_RETURN_INT64(pgstat_get_entry_count(PGSTAT_KIND_INJECTION));
-}
-
-/* Only used by injection_points_stats_drop() */
-static bool
-match_inj_entries(PgStatShared_HashEntry *entry, Datum match_data)
-{
-	return entry->key.kind == PGSTAT_KIND_INJECTION;
-}
-
-/*
- * SQL function that drops all injection point statistics.
- */
-PG_FUNCTION_INFO_V1(injection_points_stats_drop);
-Datum
-injection_points_stats_drop(PG_FUNCTION_ARGS)
-{
-	pgstat_drop_matching_entries(match_inj_entries, 0);
-
-	PG_RETURN_VOID();
-}
diff --git a/src/test/modules/injection_points/injection_stats.h b/src/test/modules/injection_points/injection_stats.h
deleted file mode 100644
index ba310c52c7f..00000000000
--- a/src/test/modules/injection_points/injection_stats.h
+++ /dev/null
@@ -1,35 +0,0 @@
-/*--------------------------------------------------------------------------
- *
- * injection_stats.h
- *		Definitions for statistics of injection points.
- *
- * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
- * Portions Copyright (c) 1994, Regents of the University of California
- *
- * IDENTIFICATION
- *		src/test/modules/injection_points/injection_stats.h
- *
- * -------------------------------------------------------------------------
- */
-
-#ifndef INJECTION_STATS
-#define INJECTION_STATS
-
-/* GUC variable */
-extern bool inj_stats_enabled;
-
-/* injection_stats.c */
-extern void pgstat_register_inj(void);
-extern void pgstat_create_inj(const char *name);
-extern void pgstat_drop_inj(const char *name);
-extern void pgstat_report_inj(const char *name);
-
-/* injection_stats_fixed.c */
-extern void pgstat_register_inj_fixed(void);
-extern void pgstat_report_inj_fixed(uint32 numattach,
-									uint32 numdetach,
-									uint32 numrun,
-									uint32 numcached,
-									uint32 numloaded);
-
-#endif
diff --git a/src/test/modules/injection_points/injection_stats_fixed.c b/src/test/modules/injection_points/injection_stats_fixed.c
deleted file mode 100644
index b493e8f77a3..00000000000
--- a/src/test/modules/injection_points/injection_stats_fixed.c
+++ /dev/null
@@ -1,214 +0,0 @@
-/*--------------------------------------------------------------------------
- *
- * injection_stats_fixed.c
- *		Code for fixed-numbered statistics of injection points.
- *
- * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
- * Portions Copyright (c) 1994, Regents of the University of California
- *
- * IDENTIFICATION
- *		src/test/modules/injection_points/injection_stats_fixed.c
- *
- * -------------------------------------------------------------------------
- */
-
-#include "postgres.h"
-
-#include "fmgr.h"
-
-#include "access/htup_details.h"
-#include "common/hashfn.h"
-#include "funcapi.h"
-#include "injection_stats.h"
-#include "pgstat.h"
-#include "utils/builtins.h"
-#include "utils/pgstat_internal.h"
-
-/* Structures for statistics of injection points, fixed-size */
-typedef struct PgStat_StatInjFixedEntry
-{
-	PgStat_Counter numattach;	/* number of points attached */
-	PgStat_Counter numdetach;	/* number of points detached */
-	PgStat_Counter numrun;		/* number of points run */
-	PgStat_Counter numcached;	/* number of points cached */
-	PgStat_Counter numloaded;	/* number of points loaded */
-	TimestampTz stat_reset_timestamp;
-} PgStat_StatInjFixedEntry;
-
-typedef struct PgStatShared_InjectionPointFixed
-{
-	LWLock		lock;			/* protects all the counters */
-	uint32		changecount;
-	PgStat_StatInjFixedEntry stats;
-	PgStat_StatInjFixedEntry reset_offset;
-} PgStatShared_InjectionPointFixed;
-
-/* Callbacks for fixed-numbered stats */
-static void injection_stats_fixed_init_shmem_cb(void *stats);
-static void injection_stats_fixed_reset_all_cb(TimestampTz ts);
-static void injection_stats_fixed_snapshot_cb(void);
-
-static const PgStat_KindInfo injection_stats_fixed = {
-	.name = "injection_points_fixed",
-	.fixed_amount = true,
-	.write_to_file = true,
-
-	.shared_size = sizeof(PgStat_StatInjFixedEntry),
-	.shared_data_off = offsetof(PgStatShared_InjectionPointFixed, stats),
-	.shared_data_len = sizeof(((PgStatShared_InjectionPointFixed *) 0)->stats),
-
-	.init_shmem_cb = injection_stats_fixed_init_shmem_cb,
-	.reset_all_cb = injection_stats_fixed_reset_all_cb,
-	.snapshot_cb = injection_stats_fixed_snapshot_cb,
-};
-
-/*
- * Kind ID reserved for statistics of injection points.
- */
-#define PGSTAT_KIND_INJECTION_FIXED	26
-
-/* Track if fixed-numbered stats are loaded */
-static bool inj_fixed_loaded = false;
-
-static void
-injection_stats_fixed_init_shmem_cb(void *stats)
-{
-	PgStatShared_InjectionPointFixed *stats_shmem =
-		(PgStatShared_InjectionPointFixed *) stats;
-
-	LWLockInitialize(&stats_shmem->lock, LWTRANCHE_PGSTATS_DATA);
-}
-
-static void
-injection_stats_fixed_reset_all_cb(TimestampTz ts)
-{
-	PgStatShared_InjectionPointFixed *stats_shmem =
-		pgstat_get_custom_shmem_data(PGSTAT_KIND_INJECTION_FIXED);
-
-	LWLockAcquire(&stats_shmem->lock, LW_EXCLUSIVE);
-	pgstat_copy_changecounted_stats(&stats_shmem->reset_offset,
-									&stats_shmem->stats,
-									sizeof(stats_shmem->stats),
-									&stats_shmem->changecount);
-	stats_shmem->stats.stat_reset_timestamp = ts;
-	LWLockRelease(&stats_shmem->lock);
-}
-
-static void
-injection_stats_fixed_snapshot_cb(void)
-{
-	PgStatShared_InjectionPointFixed *stats_shmem =
-		pgstat_get_custom_shmem_data(PGSTAT_KIND_INJECTION_FIXED);
-	PgStat_StatInjFixedEntry *stat_snap =
-		pgstat_get_custom_snapshot_data(PGSTAT_KIND_INJECTION_FIXED);
-	PgStat_StatInjFixedEntry *reset_offset = &stats_shmem->reset_offset;
-	PgStat_StatInjFixedEntry reset;
-
-	pgstat_copy_changecounted_stats(stat_snap,
-									&stats_shmem->stats,
-									sizeof(stats_shmem->stats),
-									&stats_shmem->changecount);
-
-	LWLockAcquire(&stats_shmem->lock, LW_SHARED);
-	memcpy(&reset, reset_offset, sizeof(stats_shmem->stats));
-	LWLockRelease(&stats_shmem->lock);
-
-	/* compensate by reset offsets */
-#define FIXED_COMP(fld) stat_snap->fld -= reset.fld;
-	FIXED_COMP(numattach);
-	FIXED_COMP(numdetach);
-	FIXED_COMP(numrun);
-	FIXED_COMP(numcached);
-	FIXED_COMP(numloaded);
-#undef FIXED_COMP
-}
-
-/*
- * Workhorse to do the registration work, called in _PG_init().
- */
-void
-pgstat_register_inj_fixed(void)
-{
-	pgstat_register_kind(PGSTAT_KIND_INJECTION_FIXED, &injection_stats_fixed);
-
-	/* mark stats as loaded */
-	inj_fixed_loaded = true;
-}
-
-/*
- * Report fixed number of statistics for an injection point.
- */
-void
-pgstat_report_inj_fixed(uint32 numattach,
-						uint32 numdetach,
-						uint32 numrun,
-						uint32 numcached,
-						uint32 numloaded)
-{
-	PgStatShared_InjectionPointFixed *stats_shmem;
-
-	/* leave if disabled */
-	if (!inj_fixed_loaded || !inj_stats_enabled)
-		return;
-
-	stats_shmem = pgstat_get_custom_shmem_data(PGSTAT_KIND_INJECTION_FIXED);
-
-	LWLockAcquire(&stats_shmem->lock, LW_EXCLUSIVE);
-
-	pgstat_begin_changecount_write(&stats_shmem->changecount);
-	stats_shmem->stats.numattach += numattach;
-	stats_shmem->stats.numdetach += numdetach;
-	stats_shmem->stats.numrun += numrun;
-	stats_shmem->stats.numcached += numcached;
-	stats_shmem->stats.numloaded += numloaded;
-	pgstat_end_changecount_write(&stats_shmem->changecount);
-
-	LWLockRelease(&stats_shmem->lock);
-}
-
-/*
- * SQL function returning fixed-numbered statistics for injection points.
- */
-PG_FUNCTION_INFO_V1(injection_points_stats_fixed);
-Datum
-injection_points_stats_fixed(PG_FUNCTION_ARGS)
-{
-	TupleDesc	tupdesc;
-	Datum		values[5] = {0};
-	bool		nulls[5] = {0};
-	PgStat_StatInjFixedEntry *stats;
-
-	if (!inj_fixed_loaded || !inj_stats_enabled)
-		PG_RETURN_NULL();
-
-	pgstat_snapshot_fixed(PGSTAT_KIND_INJECTION_FIXED);
-	stats = pgstat_get_custom_snapshot_data(PGSTAT_KIND_INJECTION_FIXED);
-
-	/* Initialise attributes information in the tuple descriptor */
-	tupdesc = CreateTemplateTupleDesc(5);
-	TupleDescInitEntry(tupdesc, (AttrNumber) 1, "numattach",
-					   INT8OID, -1, 0);
-	TupleDescInitEntry(tupdesc, (AttrNumber) 2, "numdetach",
-					   INT8OID, -1, 0);
-	TupleDescInitEntry(tupdesc, (AttrNumber) 3, "numrun",
-					   INT8OID, -1, 0);
-	TupleDescInitEntry(tupdesc, (AttrNumber) 4, "numcached",
-					   INT8OID, -1, 0);
-	TupleDescInitEntry(tupdesc, (AttrNumber) 5, "numloaded",
-					   INT8OID, -1, 0);
-	BlessTupleDesc(tupdesc);
-
-	values[0] = Int64GetDatum(stats->numattach);
-	values[1] = Int64GetDatum(stats->numdetach);
-	values[2] = Int64GetDatum(stats->numrun);
-	values[3] = Int64GetDatum(stats->numcached);
-	values[4] = Int64GetDatum(stats->numloaded);
-	nulls[0] = false;
-	nulls[1] = false;
-	nulls[2] = false;
-	nulls[3] = false;
-	nulls[4] = false;
-
-	/* Returns the record as Datum */
-	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
-}
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 1a2af8a26c4..8d6f662040d 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -6,8 +6,6 @@ endif
 
 injection_points_sources = files(
   'injection_points.c',
-  'injection_stats.c',
-  'injection_stats_fixed.c',
   'regress_injection.c',
 )
 
@@ -58,14 +56,4 @@ tests += {
     # Some tests wait for all snapshots, so avoid parallel execution
     'runningcheck-parallel': false,
   },
-  'tap': {
-    'env': {
-      'enable_injection_points': get_option('injection_points') ? 'yes' : 'no',
-    },
-    'tests': [
-      't/001_stats.pl',
-    ],
-    # The injection points are cluster-wide, so disable installcheck
-    'runningcheck': false,
-  },
 }
diff --git a/src/test/modules/injection_points/t/001_stats.pl b/src/test/modules/injection_points/t/001_stats.pl
deleted file mode 100644
index 47ab58d0e9b..00000000000
--- a/src/test/modules/injection_points/t/001_stats.pl
+++ /dev/null
@@ -1,103 +0,0 @@
-
-# Copyright (c) 2024-2025, PostgreSQL Global Development Group
-
-# Tests for Custom Cumulative Statistics.
-
-use strict;
-use warnings FATAL => 'all';
-use locale;
-
-use PostgreSQL::Test::Cluster;
-use PostgreSQL::Test::Utils;
-use Test::More;
-
-# Test persistency of statistics generated for injection points.
-if ($ENV{enable_injection_points} ne 'yes')
-{
-	plan skip_all => 'Injection points not supported by this build';
-}
-
-# Node initialization
-my $node = PostgreSQL::Test::Cluster->new('master');
-$node->init;
-$node->append_conf(
-	'postgresql.conf', qq(
-shared_preload_libraries = 'injection_points'
-injection_points.stats = true
-));
-$node->start;
-$node->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
-
-# This should count for two calls.
-$node->safe_psql('postgres',
-	"SELECT injection_points_attach('stats-notice', 'notice');");
-$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice');");
-$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice');");
-my $numcalls = $node->safe_psql('postgres',
-	"SELECT injection_points_stats_numcalls('stats-notice');");
-is($numcalls, '2', 'number of stats calls');
-my $entrycount =
-  $node->safe_psql('postgres', "SELECT injection_points_stats_count();");
-is($entrycount, '1', 'number of entries');
-my $fixedstats = $node->safe_psql('postgres',
-	"SELECT * FROM injection_points_stats_fixed();");
-is($fixedstats, '1|0|2|0|0', 'fixed stats after some calls');
-
-# Loading and caching.
-$node->safe_psql(
-	'postgres', "
-SELECT injection_points_load('stats-notice');
-SELECT injection_points_cached('stats-notice');
-");
-$fixedstats = $node->safe_psql('postgres',
-	"SELECT * FROM injection_points_stats_fixed();");
-is($fixedstats, '1|0|2|1|1', 'fixed stats after loading and caching');
-
-# Restart the node cleanly, stats should still be around.
-$node->restart;
-$numcalls = $node->safe_psql('postgres',
-	"SELECT injection_points_stats_numcalls('stats-notice');");
-is($numcalls, '3', 'number of stats after clean restart');
-$entrycount =
-  $node->safe_psql('postgres', "SELECT injection_points_stats_count();");
-is($entrycount, '1', 'number of entries after clean restart');
-$fixedstats = $node->safe_psql('postgres',
-	"SELECT * FROM injection_points_stats_fixed();");
-is($fixedstats, '1|0|2|1|1', 'fixed stats after clean restart');
-
-# On crash the stats are gone.
-$node->stop('immediate');
-$node->start;
-$numcalls = $node->safe_psql('postgres',
-	"SELECT injection_points_stats_numcalls('stats-notice');");
-is($numcalls, '', 'number of stats after crash');
-$entrycount =
-  $node->safe_psql('postgres', "SELECT injection_points_stats_count();");
-is($entrycount, '0', 'number of entries after crash');
-$fixedstats = $node->safe_psql('postgres',
-	"SELECT * FROM injection_points_stats_fixed();");
-is($fixedstats, '0|0|0|0|0', 'fixed stats after crash');
-
-# On drop all stats are gone
-$node->safe_psql('postgres',
-	"SELECT injection_points_attach('stats-notice', 'notice');");
-$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice');");
-$node->safe_psql('postgres', "SELECT injection_points_run('stats-notice');");
-$numcalls = $node->safe_psql('postgres',
-	"SELECT injection_points_stats_numcalls('stats-notice');");
-is($numcalls, '2', 'number of stats calls');
-$node->safe_psql('postgres', "SELECT injection_points_stats_drop();");
-$numcalls = $node->safe_psql('postgres',
-	"SELECT injection_points_stats_numcalls('stats-notice');");
-is($numcalls, '', 'no stats after drop via SQL function');
-$entrycount =
-  $node->safe_psql('postgres', "SELECT injection_points_stats_count();");
-is($entrycount, '0', 'number of entries after drop via SQL function');
-
-# Stop the server, disable the module, then restart.  The server
-# should be able to come up.
-$node->stop;
-$node->adjust_conf('postgresql.conf', 'shared_preload_libraries', "''");
-$node->start;
-
-done_testing();
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index cc57461e59a..2806db485d3 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -21,6 +21,7 @@ subdir('test_bitmapset')
 subdir('test_bloomfilter')
 subdir('test_copy_callbacks')
 subdir('test_custom_rmgrs')
+subdir('test_custom_stats')
 subdir('test_ddl_deparse')
 subdir('test_dsa')
 subdir('test_dsm_registry')
diff --git a/src/test/modules/test_custom_stats/.gitignore b/src/test/modules/test_custom_stats/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/test_custom_stats/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/test_custom_stats/Makefile b/src/test/modules/test_custom_stats/Makefile
new file mode 100644
index 00000000000..5b065a1cd42
--- /dev/null
+++ b/src/test/modules/test_custom_stats/Makefile
@@ -0,0 +1,27 @@
+# src/test/modules/test_custom_stats/Makefile
+
+MODULES = test_custom_var_stats test_custom_fixed_stats
+
+EXTENSION = test_custom_var_stats test_custom_fixed_stats
+
+OBJS = \
+	$(WIN32RES) \
+	test_custom_var_stats.o \
+	test_custom_fixed_stats.o
+PGFILEDESC = "test_custom_stats - test code for custom stat kinds"
+
+DATA = test_custom_var_stats--1.0.sql \
+       test_custom_fixed_stats--1.0.sql
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_custom_stats
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_custom_stats/meson.build b/src/test/modules/test_custom_stats/meson.build
new file mode 100644
index 00000000000..a734467e169
--- /dev/null
+++ b/src/test/modules/test_custom_stats/meson.build
@@ -0,0 +1,55 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+test_custom_var_stats_sources = files(
+  'test_custom_var_stats.c',
+)
+
+if host_system == 'windows'
+  test_custom_var_stats_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_custom_var_stats',
+    '--FILEDESC', 'test_custom_var_stats - test code for variable custom stat kinds',])
+endif
+
+test_custom_var_stats = shared_module('test_custom_var_stats',
+  test_custom_var_stats_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_custom_var_stats
+
+test_install_data += files(
+  'test_custom_var_stats.control',
+  'test_custom_var_stats--1.0.sql',
+)
+
+test_custom_fixed_stats_sources = files(
+  'test_custom_fixed_stats.c',
+)
+
+if host_system == 'windows'
+  test_custom_fixed_stats_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_custom_fixed_stats',
+    '--FILEDESC', 'test_custom_fixed_stats - test code for fixed custom stat kinds',])
+endif
+
+test_custom_fixed_stats = shared_module('test_custom_fixed_stats',
+  test_custom_fixed_stats_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_custom_fixed_stats
+
+test_install_data += files(
+  'test_custom_fixed_stats.control',
+  'test_custom_fixed_stats--1.0.sql',
+)
+
+tests += {
+  'name': 'test_custom_stats',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_custom_stats.pl',
+    ],
+    'runningcheck': false,
+  },
+}
diff --git a/src/test/modules/test_custom_stats/t/001_custom_stats.pl b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
new file mode 100644
index 00000000000..c4fceb7d267
--- /dev/null
+++ b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
@@ -0,0 +1,115 @@
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+
+# Test custom statistics functionality
+#
+# Tests both variable-amount and fixed-amount custom statistics:
+# - Creation, updates, and reporting
+# - Persistence across clean restarts
+# - Loss after crash recovery
+# - Reset functionality for fixed stats
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+use File::Copy;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->append_conf('postgresql.conf',
+	"shared_preload_libraries = 'test_custom_var_stats, test_custom_fixed_stats'");
+$node->start;
+
+$node->safe_psql('postgres', q(CREATE EXTENSION test_custom_var_stats));
+$node->safe_psql('postgres', q(CREATE EXTENSION test_custom_fixed_stats));
+
+# Create variable statistics entries
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry1')));
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry2')));
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry3')));
+$node->safe_psql('postgres', q(select pgstat_create_custom_var_stats('entry4')));
+
+# Update counters: entry1=2, entry2=3, entry3=2, entry4=3, fixed=3
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry1')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry1')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry2')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry2')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry2')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry3')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry3')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry4')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry4')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_var_stats('entry4')));
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+
+# Test variable statistics reporting
+my $result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry1')));
+is($result, "entry1|2", "var stats entry1 reports correct calls");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry2')));
+is($result, "entry2|3", "var stats entry2 reports correct calls");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry3')));
+is($result, "entry3|2", "var stats entry3 reports correct calls");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry4')));
+is($result, "entry4|3", "var stats entry4 reports correct calls");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_fixed_stats()));
+is($result, "3|", "fixed stats reports correct calls");
+
+# Test variable statistics drop functionality
+$result = $node->safe_psql('postgres', q(select * from pgstat_drop_custom_var_stats('entry3')));
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry3')));
+is($result, "", "entry3 not found after drop");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_drop_custom_var_stats('entry4')));
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry4')));
+is($result, "", "entry4 not found after drop");
+
+# Test persistence across clean restart
+$node->stop();
+$node->start();
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry1')));
+is($result, "entry1|2", "var stats entry1 persists after clean restart");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry2')));
+is($result, "entry2|3", "var stats entry2 persists after clean restart");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_fixed_stats()));
+is($result, "3|", "fixed stats persists after clean restart");
+
+# Test crash recovery behavior
+$node->stop('immediate');
+$node->start;
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry1')));
+is($result, "", "var stats entry1 lost after crash recovery");
+
+$result = $node->safe_psql('postgres', q(select * from pgstat_report_custom_var_stats('entry2')));
+is($result, "", "var stats entry2 lost after crash recovery");
+
+# crash recovery sets the reset timestamp
+$result = $node->safe_psql('postgres', q(select numcalls from pgstat_report_custom_fixed_stats() where stats_reset is not null));
+is($result, "0", "fixed stats reset after crash recovery");
+
+# Test fixed statistics reset functionality
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+$node->safe_psql('postgres', q(select pgstat_update_custom_fixed_stats()));
+
+$result = $node->safe_psql('postgres', q(select numcalls from pgstat_report_custom_fixed_stats()));
+is($result, "3", "fixed stats shows calls before manual reset");
+
+$node->safe_psql('postgres', q(select pgstat_reset_custom_fixed_stats()));
+
+$result = $node->safe_psql('postgres', q(select numcalls from pgstat_report_custom_fixed_stats() where stats_reset is not null));
+is($result, "0", "fixed stats reset after manual reset");
+
+# Test completed successfully
+done_testing();
\ No newline at end of file
diff --git a/src/test/modules/test_custom_stats/test_custom_fixed_stats--1.0.sql b/src/test/modules/test_custom_stats/test_custom_fixed_stats--1.0.sql
new file mode 100644
index 00000000000..e4c39749398
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_fixed_stats--1.0.sql
@@ -0,0 +1,20 @@
+/* src/test/modules/test_custom_stats/test_custom_fixed_stats--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_custom_fixed_stats" to load this file. \quit
+
+CREATE FUNCTION pgstat_update_custom_fixed_stats()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_update_custom_fixed_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+CREATE FUNCTION pgstat_report_custom_fixed_stats(OUT numcalls bigint,
+    OUT stats_reset timestamptz)
+RETURNS record
+AS 'MODULE_PATHNAME', 'pgstat_report_custom_fixed_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+CREATE FUNCTION pgstat_reset_custom_fixed_stats()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_reset_custom_fixed_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
\ No newline at end of file
diff --git a/src/test/modules/test_custom_stats/test_custom_fixed_stats.c b/src/test/modules/test_custom_stats/test_custom_fixed_stats.c
new file mode 100644
index 00000000000..f0821b4848a
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_fixed_stats.c
@@ -0,0 +1,224 @@
+/*--------------------------------------------------------------------------
+ *
+ * test_custom_fixed_stats.c
+ *		Test module for fixed-amount custom statistics
+ *
+ * Copyright (c) 2024-2025, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		src/test/modules/test_custom_stats/test_custom_fixed_stats.c
+ *
+ * -------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "access/htup_details.h"
+#include "funcapi.h"
+#include "pgstat.h"
+#include "utils/builtins.h"
+#include "utils/pgstat_internal.h"
+
+PG_MODULE_MAGIC_EXT(
+					.name = "test_custom_fixed_stats",
+					.version = PG_VERSION
+);
+
+/* Fixed-amount custom statistics entry */
+typedef struct PgStat_StatCustomFixedEntry
+{
+	PgStat_Counter numcalls;	/* # of times update function called */
+	TimestampTz stat_reset_timestamp;
+}			PgStat_StatCustomFixedEntry;
+
+typedef struct PgStatShared_CustomFixedEntry
+{
+	LWLock		lock;			/* protects counters */
+	uint32		changecount;	/* for atomic reads */
+	PgStat_StatCustomFixedEntry stats;	/* current counters */
+	PgStat_StatCustomFixedEntry reset_offset;	/* reset baseline */
+}			PgStatShared_CustomFixedEntry;
+
+/* Callbacks for fixed-amount statistics */
+static void pgstat_custom_fixed_init_shmem_cb(void *stats);
+static void pgstat_custom_fixed_reset_all_cb(TimestampTz ts);
+static void pgstat_custom_fixed_snapshot_cb(void);
+
+static const PgStat_KindInfo custom_stats = {
+	.name = "test_custom_fixed_stats",
+	.fixed_amount = true,		/* exactly one entry */
+	.write_to_file = true,		/* persist to stats file */
+
+	.shared_size = sizeof(PgStat_StatCustomFixedEntry),
+	.shared_data_off = offsetof(PgStatShared_CustomFixedEntry, stats),
+	.shared_data_len = sizeof(((PgStatShared_CustomFixedEntry *) 0)->stats),
+
+	.init_shmem_cb = pgstat_custom_fixed_init_shmem_cb,
+	.reset_all_cb = pgstat_custom_fixed_reset_all_cb,
+	.snapshot_cb = pgstat_custom_fixed_snapshot_cb,
+};
+
+/*
+ * Kind ID for test_custom_fixed_stats.
+ */
+#define PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS 26
+
+/*--------------------------------------------------------------------------
+ * Module initialization
+ *--------------------------------------------------------------------------
+ */
+
+void
+_PG_init(void)
+{
+	/* Must be loaded via shared_preload_libraries */
+	if (!process_shared_preload_libraries_in_progress)
+		return;
+
+	/* Register custom statistics kind */
+	pgstat_register_kind(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS, &custom_stats);
+}
+
+/*
+ * pgstat_custom_fixed_init_shmem_cb
+ *		Initialize shared memory structure
+ */
+static void
+pgstat_custom_fixed_init_shmem_cb(void *stats)
+{
+	PgStatShared_CustomFixedEntry *stats_shmem =
+		(PgStatShared_CustomFixedEntry *) stats;
+
+	LWLockInitialize(&stats_shmem->lock, LWTRANCHE_PGSTATS_DATA);
+}
+
+/*
+ * pgstat_custom_fixed_reset_all_cb
+ *		Reset the fixed stats
+ */
+static void
+pgstat_custom_fixed_reset_all_cb(TimestampTz ts)
+{
+	PgStatShared_CustomFixedEntry *stats_shmem =
+		pgstat_get_custom_shmem_data(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+
+	/* see explanation above PgStatShared_Archiver for the reset protocol */
+	LWLockAcquire(&stats_shmem->lock, LW_EXCLUSIVE);
+	pgstat_copy_changecounted_stats(&stats_shmem->reset_offset,
+									&stats_shmem->stats,
+									sizeof(stats_shmem->stats),
+									&stats_shmem->changecount);
+	stats_shmem->stats.stat_reset_timestamp = ts;
+	LWLockRelease(&stats_shmem->lock);
+}
+
+/*
+ * pgstat_custom_fixed_snapshot_cb
+ *		Copy current stats to snapshot area
+ */
+static void
+pgstat_custom_fixed_snapshot_cb(void)
+{
+	PgStatShared_CustomFixedEntry *stats_shmem =
+		pgstat_get_custom_shmem_data(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+	PgStat_StatCustomFixedEntry *stat_snap =
+		pgstat_get_custom_snapshot_data(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+	PgStat_StatCustomFixedEntry *reset_offset = &stats_shmem->reset_offset;
+	PgStat_StatCustomFixedEntry reset;
+
+	pgstat_copy_changecounted_stats(stat_snap,
+									&stats_shmem->stats,
+									sizeof(stats_shmem->stats),
+									&stats_shmem->changecount);
+
+	LWLockAcquire(&stats_shmem->lock, LW_SHARED);
+	memcpy(&reset, reset_offset, sizeof(stats_shmem->stats));
+	LWLockRelease(&stats_shmem->lock);
+
+	/* Apply reset offsets */
+#define FIXED_COMP(fld) stat_snap->fld -= reset.fld;
+	FIXED_COMP(numcalls);
+#undef FIXED_COMP
+}
+
+/*--------------------------------------------------------------------------
+ * SQL-callable functions
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * pgstat_update_custom_fixed_stats
+ *		Increment call counter
+ */
+PG_FUNCTION_INFO_V1(pgstat_update_custom_fixed_stats);
+Datum
+pgstat_update_custom_fixed_stats(PG_FUNCTION_ARGS)
+{
+	PgStatShared_CustomFixedEntry *stats_shmem;
+
+	stats_shmem = pgstat_get_custom_shmem_data(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+
+	LWLockAcquire(&stats_shmem->lock, LW_EXCLUSIVE);
+
+	pgstat_begin_changecount_write(&stats_shmem->changecount);
+	stats_shmem->stats.numcalls++;
+	pgstat_end_changecount_write(&stats_shmem->changecount);
+
+	LWLockRelease(&stats_shmem->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_reset_custom_fixed_stats
+ *		Reset statistics by calling pgstat system
+ */
+PG_FUNCTION_INFO_V1(pgstat_reset_custom_fixed_stats);
+Datum
+pgstat_reset_custom_fixed_stats(PG_FUNCTION_ARGS)
+{
+	pgstat_reset_of_kind(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_report_custom_fixed_stats
+ *		Return current counter values
+ */
+PG_FUNCTION_INFO_V1(pgstat_report_custom_fixed_stats);
+Datum
+pgstat_report_custom_fixed_stats(PG_FUNCTION_ARGS)
+{
+	TupleDesc	tupdesc;
+	Datum		values[2] = {0};
+	bool		nulls[2] = {false};
+	PgStat_StatCustomFixedEntry *stats;
+
+	/* Take snapshot (applies reset offsets) */
+	pgstat_snapshot_fixed(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+	stats = pgstat_get_custom_snapshot_data(PGSTAT_KIND_TEST_CUSTOM_FIXED_STATS);
+
+	/* Build return tuple */
+	tupdesc = CreateTemplateTupleDesc(2);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 1, "numcalls",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 2, "stats_reset",
+					   TIMESTAMPTZOID, -1, 0);
+	BlessTupleDesc(tupdesc);
+
+	values[0] = Int64GetDatum(stats->numcalls);
+
+	/* Handle uninitialized timestamp (no reset yet) */
+	if (stats->stat_reset_timestamp == 0)
+	{
+		nulls[1] = true;
+	}
+	else
+	{
+		values[1] = TimestampTzGetDatum(stats->stat_reset_timestamp);
+	}
+
+	/* Return as tuple */
+	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
+}
diff --git a/src/test/modules/test_custom_stats/test_custom_fixed_stats.control b/src/test/modules/test_custom_stats/test_custom_fixed_stats.control
new file mode 100644
index 00000000000..b96e2aa18fb
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_fixed_stats.control
@@ -0,0 +1,4 @@
+comment = 'Test code for fixed custom stat kinds'
+default_version = '1.0'
+module_pathname = '$libdir/test_custom_fixed_stats'
+relocatable = true
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
new file mode 100644
index 00000000000..84ae2bf5666
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
@@ -0,0 +1,25 @@
+/* src/test/modules/test_custom_var_stats/test_custom_var_stats--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_custom_var_stats" to load this file. \quit
+
+CREATE FUNCTION pgstat_create_custom_var_stats(IN name TEXT)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_create_custom_var_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+CREATE FUNCTION pgstat_update_custom_var_stats(IN name TEXT)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_update_custom_var_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+CREATE FUNCTION pgstat_drop_custom_var_stats(IN name TEXT)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pgstat_drop_custom_var_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
+
+
+CREATE FUNCTION pgstat_report_custom_var_stats(INOUT name TEXT, OUT calls BIGINT)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pgstat_report_custom_var_stats'
+LANGUAGE C STRICT PARALLEL UNSAFE;
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats.c b/src/test/modules/test_custom_stats/test_custom_var_stats.c
new file mode 100644
index 00000000000..6320eaf2cae
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats.c
@@ -0,0 +1,302 @@
+/*------------------------------------------------------------------------------------
+ *
+ * test_custom_var_stats.c
+ *		Test module for custom PostgreSQL variable-numbered custom statistic kinds
+ *
+ * Copyright (c) 2024-2025, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		src/test/modules/test_custom_var_stats/test_custom_var_stats.c
+ *
+ * ------------------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "funcapi.h"
+#include "utils/builtins.h"
+#include "utils/pgstat_internal.h"
+
+PG_MODULE_MAGIC_EXT(
+					.name = "test_custom_var_stats",
+					.version = PG_VERSION
+);
+
+/*--------------------------------------------------------------------------
+ * Macros and constants
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * Kind ID for test_custom_var_stats statistics.
+ * Reuses the same ID as injection points to avoid reserving a new kind ID.
+ */
+#define PGSTAT_KIND_TEST_CUSTOM_VAR_STATS 25
+
+/*
+ * Hash statistic name to generate entry index for pgstat lookup.
+ */
+#define PGSTAT_CUSTOM_VAR_STATS_IDX(name) hash_bytes_extended((const unsigned char *) name, strlen(name), 0)
+
+/*--------------------------------------------------------------------------
+ * Type definitions
+ *--------------------------------------------------------------------------
+ */
+
+/* Backend-local pending statistics before flush to shared memory */
+typedef struct PgStat_StatCustomEntry
+{
+	PgStat_Counter numcalls;	/* times statistic was incremented */
+}			PgStat_StatCustomEntry;
+
+/* Shared memory statistics entry visible to all backends */
+typedef struct PgStatShared_CustomEntry
+{
+	PgStatShared_Common header; /* standard pgstat entry header */
+	PgStat_StatCustomEntry stats;	/* custom statistics data */
+}			PgStatShared_CustomEntry;
+
+/*--------------------------------------------------------------------------
+ * Function prototypes
+ *--------------------------------------------------------------------------
+ */
+
+/* Flush callback: merge pending stats into shared memory */
+static bool pgstat_custom_entry_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
+
+/*--------------------------------------------------------------------------
+ * Custom kind configuration
+ *--------------------------------------------------------------------------
+ */
+
+static const PgStat_KindInfo custom_stats = {
+	.name = "test_custom_var_stats",
+	.fixed_amount = false,		/* variable number of entries */
+	.write_to_file = true,		/* persist across restarts */
+	.track_entry_count = true,	/* count active entries */
+	.accessed_across_databases = true,	/* global statistics */
+	.shared_size = sizeof(PgStatShared_CustomEntry),
+	.shared_data_off = offsetof(PgStatShared_CustomEntry, stats),
+	.shared_data_len = sizeof(((PgStatShared_CustomEntry *) 0)->stats),
+	.pending_size = sizeof(PgStat_StatCustomEntry),
+	.flush_pending_cb = pgstat_custom_entry_flush_cb,
+};
+
+/*--------------------------------------------------------------------------
+ * Module initialization
+ *--------------------------------------------------------------------------
+ */
+
+void
+_PG_init(void)
+{
+	/* Must be loaded via shared_preload_libraries */
+	if (!process_shared_preload_libraries_in_progress)
+		return;
+
+	/* Register custom statistics kind */
+	pgstat_register_kind(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, &custom_stats);
+}
+
+/*--------------------------------------------------------------------------
+ * Statistics callback functions
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * pgstat_custom_entry_flush_cb
+ *		Merge pending backend statistics into shared memory
+ *
+ * Called by pgstat collector to flush accumulated local statistics
+ * to shared memory where other backends can read them.
+ *
+ * Returns false only if nowait=true and lock acquisition fails.
+ */
+static bool
+pgstat_custom_entry_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
+{
+	PgStat_StatCustomEntry *pending_entry;
+	PgStatShared_CustomEntry *shared_entry;
+
+	pending_entry = (PgStat_StatCustomEntry *) entry_ref->pending;
+	shared_entry = (PgStatShared_CustomEntry *) entry_ref->shared_stats;
+
+	if (!pgstat_lock_entry(entry_ref, nowait))
+		return false;
+
+	/* Add pending counts to shared totals */
+	shared_entry->stats.numcalls += pending_entry->numcalls;
+
+	pgstat_unlock_entry(entry_ref);
+
+	return true;
+}
+
+/*--------------------------------------------------------------------------
+ * Helper functions
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * pgstat_fetch_custom_entry
+ *		Look up custom statistic by name
+ *
+ * Returns statistics entry from shared memory, or NULL if not found.
+ */
+static PgStat_StatCustomEntry *
+pgstat_fetch_custom_entry(const char *stat_name)
+{
+	/* Fetch entry by hashed name */
+	return (PgStat_StatCustomEntry *) pgstat_fetch_entry(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS,
+														 InvalidOid,
+														 PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name));
+}
+
+/*--------------------------------------------------------------------------
+ * SQL-callable functions
+ *--------------------------------------------------------------------------
+ */
+
+/*
+ * pgstat_create_custom_var_stats
+ *		Create new custom statistic entry
+ *
+ * Initializes a zero-valued statistics entry in shared memory.
+ * Validates name length against NAMEDATALEN limit.
+ */
+PG_FUNCTION_INFO_V1(pgstat_create_custom_var_stats);
+Datum
+pgstat_create_custom_var_stats(PG_FUNCTION_ARGS)
+{
+	PgStat_EntryRef *entry_ref;
+	PgStatShared_CustomEntry *shared_entry;
+	char	   *stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	/* Validate name length first */
+	if (strlen(stat_name) >= NAMEDATALEN)
+		ereport(ERROR,
+				(errcode(ERRCODE_NAME_TOO_LONG),
+				 errmsg("custom statistic name \"%s\" is too long", stat_name),
+				 errdetail("Name must be less than %d characters.", NAMEDATALEN)));
+
+	/* Create or get existing entry */
+	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
+											PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), true);
+
+	if (!entry_ref)
+		PG_RETURN_VOID();
+
+	shared_entry = (PgStatShared_CustomEntry *) entry_ref->shared_stats;
+
+	/* Zero-initialize statistics */
+	memset(&shared_entry->stats, 0, sizeof(shared_entry->stats));
+
+	pgstat_unlock_entry(entry_ref);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_update_custom_var_stats
+ *		Increment custom statistic counter
+ *
+ * Increments call count in backend-local memory. Changes are flushed
+ * to shared memory by the statistics collector.
+ */
+PG_FUNCTION_INFO_V1(pgstat_update_custom_var_stats);
+Datum
+pgstat_update_custom_var_stats(PG_FUNCTION_ARGS)
+{
+	char	   *stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	PgStat_EntryRef *entry_ref;
+	PgStat_StatCustomEntry *pending_entry;
+
+	/* Get pending entry in local memory */
+	entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
+										  PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), NULL);
+
+	pending_entry = (PgStat_StatCustomEntry *) entry_ref->pending;
+	pending_entry->numcalls++;
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_drop_custom_var_stats
+ *		Remove custom statistic entry
+ *
+ * Drops the named statistic from shared memory and requests
+ * garbage collection if needed.
+ */
+PG_FUNCTION_INFO_V1(pgstat_drop_custom_var_stats);
+Datum
+pgstat_drop_custom_var_stats(PG_FUNCTION_ARGS)
+{
+	char	   *stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	/* Drop entry and request GC if the entry could not be freed */
+	if (!pgstat_drop_entry(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
+						   PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name)))
+		pgstat_request_entry_refs_gc();
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pgstat_report_custom_var_stats
+ *		Retrieve custom statistic values
+ *
+ * Returns single row with statistic name and call count if the
+ * statistic exists, otherwise returns no rows.
+ */
+PG_FUNCTION_INFO_V1(pgstat_report_custom_var_stats);
+Datum
+pgstat_report_custom_var_stats(PG_FUNCTION_ARGS)
+{
+	FuncCallContext *funcctx;
+	char	   *stat_name;
+	PgStat_StatCustomEntry *stat_entry;
+
+	if (SRF_IS_FIRSTCALL())
+	{
+		TupleDesc	tupdesc;
+		MemoryContext oldcontext;
+
+		/* Initialize SRF context */
+		funcctx = SRF_FIRSTCALL_INIT();
+		oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
+
+		/* Get composite return type */
+		if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+			elog(ERROR, "pgstat_report_custom_var_stats: return type is not composite");
+
+		funcctx->tuple_desc = BlessTupleDesc(tupdesc);
+		funcctx->max_calls = 1; /* single row result */
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	funcctx = SRF_PERCALL_SETUP();
+
+	if (funcctx->call_cntr < funcctx->max_calls)
+	{
+		Datum		values[2];
+		bool		nulls[2] = {false, false};
+		HeapTuple	tuple;
+
+		stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+		stat_entry = pgstat_fetch_custom_entry(stat_name);
+
+		/* Return row only if entry exists */
+		if (stat_entry)
+		{
+			values[0] = PointerGetDatum(cstring_to_text(stat_name));
+			values[1] = Int64GetDatum(stat_entry->numcalls);
+
+			tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
+			SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
+		}
+	}
+
+	SRF_RETURN_DONE(funcctx);
+}
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats.control b/src/test/modules/test_custom_stats/test_custom_var_stats.control
new file mode 100644
index 00000000000..43a2783f965
--- /dev/null
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats.control
@@ -0,0 +1,4 @@
+comment = 'Test code for variable custom stat kinds'
+default_version = '1.0'
+module_pathname = '$libdir/test_custom_var_stats'
+relocatable = true
-- 
2.43.0

#24Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#22)
Re: [Proposal] Adding callback support for custom statistics kinds

On Fri, Dec 05, 2025 at 07:27:50PM -0600, Sami Imseih wrote:

Attached is the new test module that replaces the custom statistics
tests currently in the injection points tests. Under test_custom_stats, there
are two separate modules: one for variable-amount stats and one for
fixed-amount stats. With this, we can completely remove the
stats-related tests and supporting code under
src/test/modules/injection_points/.

Yes, thanks. Structurally, this is better and more flexible than what
we had originally, and I have noticed that you have copied the
original files while adding more comments and renaming a bit things:
the structure of the functions was exactly the same. Anyway, I have
worked on that for a good portion of the day, splitting the module
drop and the new module into two commits, and applied the result after
tweaking quite a few things in terms of names and comments (no
pgstat_*, a bit more "Var" and "Fixed", etc.), applying a much more
consistent set of names across the board for the functions and the
structures. This cleanup part is moved out of the way now, so that
you ease the introduction of the next pieces you are proposing.

The tests for the reset of fixed-sized stats was a nice addition,
indeed. If you have more areas that you think could be improved,
ideas are of course welcome.
--
Michael

#25Sami Imseih
samimseih@gmail.com
In reply to: Michael Paquier (#24)
1 attachment(s)
Re: [Proposal] Adding callback support for custom statistics kinds

Yes, thanks. Structurally, this is better and more flexible than what
we had originally, and I have noticed that you have copied the
original files while adding more comments and renaming a bit things:
the structure of the functions was exactly the same. Anyway, I have
worked on that for a good portion of the day, splitting the module
drop and the new module into two commits, and applied the result after
tweaking quite a few things in terms of names and comments (no
pgstat_*, a bit more "Var" and "Fixed", etc.), applying a much more
consistent set of names across the board for the functions and the
structures. This cleanup part is moved out of the way now, so that
you ease the introduction of the next pieces you are proposing.

Thanks for getting these committed!

I rebased the custom callbacks patch in v5.

One very minor thing from the earlier commits that I corrected here is
the test for entry 2 after a clean restart.

-is($result, "entry1|2", "variable-sized stats persist after clean restart");
+is($result, "entry1|2|Test entry 1", "variable-sized stats persist
after clean restart");
+
+$result = $node->safe_psql('postgres', q(select * from
test_custom_stats_var_report('entry2')));
+is($result, "entry2|3|Test entry 2", "variable-sized stats persist
after clean restart");
+

--
Sami Imseih
Amazon Web Services (AWS)

Attachments:

v5-0001-Allow-cumulative-statistics-to-serialize-auxiliar.patchapplication/octet-stream; name=v5-0001-Allow-cumulative-statistics-to-serialize-auxiliar.patchDownload
From 5df6c2d11ca1a05a1a5aaa3caecb6638664e48a2 Mon Sep 17 00:00:00 2001
From: Sami Imseih <simseih@amazon.com>
Date: Mon, 10 Nov 2025 00:03:41 -0600
Subject: [PATCH v5 1/1] Allow cumulative statistics to serialize auxiliary
 data to disk.

Cumulative Statistics kinds can now write additional per-entry data to
the statistics file that doesn't fit in shared memory. This is useful
for statistics with variable-length auxiliary data.

Three new optional callbacks are added to PgStat_KindInfo:

* to_serialized_extra_stats: writes auxiliary data for an entry
* from_serialized_extra_stats: reads auxiliary data for an entry
* end_extra_stats: performs cleanup after read/write/discard operations

All three callbacks must be provided together to ensure the reader
consumes exactly what the writer produces. The end_extra_stats callback
is invoked after processing all entries of a kind, allowing extensions
to close file handles and clean up resources.

Tests are also added to test_custom_stats.pl

Discussion: https://www.postgresql.org/message-id/flat/CAA5RZ0s9SDOu+Z6veoJCHWk+kDeTktAtC-KY9fQ9Z6BJdDUirQ@mail.gmail.com
---
 src/backend/utils/activity/pgstat.c           |  89 ++++-
 src/include/utils/pgstat_internal.h           |  37 ++
 .../test_custom_stats/t/001_custom_stats.pl   |  26 +-
 .../test_custom_var_stats--1.0.sql            |   7 +-
 .../test_custom_stats/test_custom_var_stats.c | 323 +++++++++++++++++-
 5 files changed, 460 insertions(+), 22 deletions(-)

diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index 8713c7a0483..2e0c1bbb061 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -194,6 +194,7 @@ static void pgstat_build_snapshot(void);
 static void pgstat_build_snapshot_fixed(PgStat_Kind kind);
 
 static inline bool pgstat_is_kind_valid(PgStat_Kind kind);
+static inline bool pgstat_check_extra_callbacks(PgStat_Kind kind);
 
 
 /* ----------
@@ -523,6 +524,7 @@ pgstat_discard_stats(void)
 
 	/* NB: this needs to be done even in single user mode */
 
+	/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
 	ret = unlink(PGSTAT_STAT_PERMANENT_FILENAME);
 	if (ret != 0)
 	{
@@ -544,6 +546,15 @@ pgstat_discard_stats(void)
 								 PGSTAT_STAT_PERMANENT_FILENAME)));
 	}
 
+	/* Let each stats kind run its cleanup callback, if it provides one */
+	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->end_extra_stats)
+			kind_info->end_extra_stats(STATS_DISCARD);
+	}
+
 	/*
 	 * Reset stats contents. This will set reset timestamps of fixed-numbered
 	 * stats to the current time (no variable stats exist).
@@ -645,6 +656,13 @@ pgstat_initialize(void)
 
 	pgstat_attach_shmem();
 
+	/* Check a kind's extra-data callback setup */
+	for (PgStat_Kind kind = PGSTAT_KIND_BUILTIN_MIN; kind <= PGSTAT_KIND_BUILTIN_MAX; kind++)
+		if (!pgstat_check_extra_callbacks(kind))
+			ereport(ERROR,
+					errmsg("incomplete extra serialization callbacks for stats kind %d",
+						   kind));
+
 	pgstat_init_snapshot_fixed();
 
 	/* Backend initialization callbacks */
@@ -1432,6 +1450,33 @@ pgstat_is_kind_valid(PgStat_Kind kind)
 	return pgstat_is_kind_builtin(kind) || pgstat_is_kind_custom(kind);
 }
 
+/*
+ * Validate that extra stats callbacks are all provided together or not at all.
+ */
+static inline bool
+pgstat_check_extra_callbacks(PgStat_Kind kind)
+{
+	const PgStat_KindInfo *kind_info = pgstat_get_kind_info(kind);
+	int			count = 0;
+
+	/* Custom kind not yet registered, skip validation */
+	if (!kind_info)
+		return true;
+
+	if (kind_info->to_serialized_extra_stats)
+		count++;
+	if (kind_info->from_serialized_extra_stats)
+		count++;
+	if (kind_info->end_extra_stats)
+		count++;
+
+	/* Either all three callbacks must be provided, or none */
+	if (count != 0 && count != 3)
+		return false;
+
+	return true;
+}
+
 const PgStat_KindInfo *
 pgstat_get_kind_info(PgStat_Kind kind)
 {
@@ -1525,6 +1570,13 @@ pgstat_register_kind(PgStat_Kind kind, const PgStat_KindInfo *kind_info)
 					 errdetail("Existing cumulative statistics with ID %u has the same name.", existing_kind)));
 	}
 
+	/* Check a kind's extra-data callback setup */
+	if (!pgstat_check_extra_callbacks(kind))
+		ereport(ERROR,
+				(errmsg("incomplete serialization callbacks for statistics kind \"%s\"",
+						kind_info->name),
+				 errdetail("callbacks to_serialized_extra_stats, from_serialized_extra_stats, and end_extra_stats must be provided together.")));
+
 	/* Register it */
 	pgstat_kind_custom_infos[idx] = kind_info;
 	ereport(LOG,
@@ -1702,6 +1754,9 @@ pgstat_write_statsfile(void)
 		pgstat_write_chunk(fpout,
 						   pgstat_get_entry_data(ps->key.kind, shstats),
 						   pgstat_get_entry_len(ps->key.kind));
+
+		if (kind_info->to_serialized_extra_stats)
+			kind_info->to_serialized_extra_stats(&ps->key, shstats, fpout);
 	}
 	dshash_seq_term(&hstat);
 
@@ -1734,6 +1789,15 @@ pgstat_write_statsfile(void)
 		/* durable_rename already emitted log message */
 		unlink(tmpfile);
 	}
+
+	/* Now, allow kinds to finalize the writes for the extra files */
+	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->end_extra_stats)
+			kind_info->end_extra_stats(STATS_WRITE);
+	}
 }
 
 /* helper for pgstat_read_statsfile() */
@@ -1871,6 +1935,7 @@ pgstat_read_statsfile(void)
 					PgStat_HashKey key;
 					PgStatShared_HashEntry *p;
 					PgStatShared_Common *header;
+					const PgStat_KindInfo *kind_info = NULL;
 
 					CHECK_FOR_INTERRUPTS();
 
@@ -1891,7 +1956,8 @@ pgstat_read_statsfile(void)
 							goto error;
 						}
 
-						if (!pgstat_get_kind_info(key.kind))
+						kind_info = pgstat_get_kind_info(key.kind);
+						if (!kind_info)
 						{
 							elog(WARNING, "could not find information of kind for entry %u/%u/%" PRIu64 " of type %c",
 								 key.kind, key.dboid,
@@ -1902,7 +1968,6 @@ pgstat_read_statsfile(void)
 					else
 					{
 						/* stats entry identified by name on disk (e.g. slots) */
-						const PgStat_KindInfo *kind_info = NULL;
 						PgStat_Kind kind;
 						NameData	name;
 
@@ -1996,6 +2061,16 @@ pgstat_read_statsfile(void)
 						goto error;
 					}
 
+					if (kind_info->from_serialized_extra_stats)
+					{
+						if (!kind_info->from_serialized_extra_stats(&key, header, fpin))
+						{
+							elog(WARNING, "could not read extra stats for entry %u/%u/%" PRIu64,
+								 key.kind, key.dboid, key.objid);
+							goto error;
+						}
+					}
+
 					break;
 				}
 			case PGSTAT_FILE_ENTRY_END:
@@ -2019,11 +2094,21 @@ pgstat_read_statsfile(void)
 	}
 
 done:
+	/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
 	FreeFile(fpin);
 
 	elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
 	unlink(statfile);
 
+	/* Let each stats kind run its cleanup callback, if it provides one */
+	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->end_extra_stats)
+			kind_info->end_extra_stats(STATS_READ);
+	}
+
 	return;
 
 error:
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index ca1ba6420ca..48b40816570 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -63,6 +63,20 @@ typedef struct PgStat_HashKey
 								 * identifier. */
 } PgStat_HashKey;
 
+/*
+ * Tracks if the stats file is being read, written or discarded.
+ *
+ * These states allow plugins that create extra statistics files
+ * to determine the current operation and perform any necessary
+ * file cleanup.
+ */
+typedef enum PgStat_StatsFileOp
+{
+	STATS_WRITE,
+	STATS_READ,
+	STATS_DISCARD,
+}			PgStat_StatsFileOp;
+
 /*
  * PgStat_HashKey should not have any padding.  Checking that the structure
  * size matches with the sum of each field is a check simple enough to
@@ -303,6 +317,29 @@ typedef struct PgStat_KindInfo
 									   const PgStatShared_Common *header, NameData *name);
 	bool		(*from_serialized_name) (const NameData *name, PgStat_HashKey *key);
 
+	/*
+	 * Optional callbacks for kinds that write additional per-entry data to
+	 * the stats file.  If any of these callbacks are provided, all three must
+	 * be provided to ensure that the reader consumes exactly the data written
+	 * by the writer.
+	 *
+	 * to_serialized_extra_stats: write extra data for an entry.
+	 *
+	 * from_serialized_extra_stats: read the extra data for an entry. Returns
+	 * true on success, false on read error.
+	 *
+	 * end_extra_stats: invoked once per operation (read, write, discard)
+	 * after all entries of this kind have been processed.
+	 *
+	 * Note: statfile is a pointer to the main stats file,
+	 * PGSTAT_STAT_PERMANENT_FILENAME.
+	 */
+	void		(*to_serialized_extra_stats) (const PgStat_HashKey *key,
+											  const PgStatShared_Common *header, FILE *statfile);
+	bool		(*from_serialized_extra_stats) (const PgStat_HashKey *key,
+												const PgStatShared_Common *header, FILE *statfile);
+	void		(*end_extra_stats) (PgStat_StatsFileOp status);
+
 	/*
 	 * For fixed-numbered statistics: Initialize shared memory state.
 	 *
diff --git a/src/test/modules/test_custom_stats/t/001_custom_stats.pl b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
index e528595cfb0..b3b25819411 100644
--- a/src/test/modules/test_custom_stats/t/001_custom_stats.pl
+++ b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
@@ -29,13 +29,13 @@ $node->safe_psql('postgres', q(CREATE EXTENSION test_custom_fixed_stats));
 
 # Create entries for variable-sized stats.
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry1')));
+	q(select test_custom_stats_var_create('entry1', 'Test entry 1')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry2')));
+	q(select test_custom_stats_var_create('entry2', 'Test entry 2')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry3')));
+	q(select test_custom_stats_var_create('entry3', 'Test entry 3')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry4')));
+	q(select test_custom_stats_var_create('entry4', 'Test entry 4')));
 
 # Update counters: entry1=2, entry2=3, entry3=2, entry4=3, fixed=3
 $node->safe_psql('postgres',
@@ -65,16 +65,20 @@ $node->safe_psql('postgres', q(select test_custom_stats_fixed_update()));
 # Test data reports.
 my $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry1')));
-is($result, "entry1|2", "report for variable-sized data of entry1");
+is($result, "entry1|2|Test entry 1", "report for variable-sized data of entry1");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry2')));
-is($result, "entry2|3", "report for variable-sized data of entry2");
+is($result, "entry2|3|Test entry 2", "report for variable-sized data of entry2");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry3')));
-is($result, "entry3|2", "report for variable-sized data of entry3");
+is($result, "entry3|2|Test entry 3", "report for variable-sized data of entry3");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry4')));
-is($result, "entry4|3", "report for variable-sized data of entry4");
+is($result, "entry4|3|Test entry 4", "report for variable-sized data of entry4");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_fixed_report()));
 is($result, "3|", "report for fixed-sized stats");
@@ -97,7 +101,11 @@ $node->start();
 
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry1')));
-is($result, "entry1|2", "variable-sized stats persist after clean restart");
+is($result, "entry1|2|Test entry 1", "variable-sized stats persist after clean restart");
+
+$result = $node->safe_psql('postgres', q(select * from test_custom_stats_var_report('entry2')));
+is($result, "entry2|3|Test entry 2", "variable-sized stats persist after clean restart");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_fixed_report()));
 is($result, "3|", "fixed-sized stats persist after clean restart");
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
index d5f82b5d546..5ed8cfc2dcf 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
@@ -3,7 +3,7 @@
 -- complain if script is sourced in psql, rather than via CREATE EXTENSION
 \echo Use "CREATE EXTENSION test_custom_var_stats" to load this file. \quit
 
-CREATE FUNCTION test_custom_stats_var_create(IN name TEXT)
+CREATE FUNCTION test_custom_stats_var_create(IN name TEXT, in description TEXT)
 RETURNS void
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_create'
 LANGUAGE C STRICT PARALLEL UNSAFE;
@@ -18,8 +18,9 @@ RETURNS void
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_drop'
 LANGUAGE C STRICT PARALLEL UNSAFE;
 
-
-CREATE FUNCTION test_custom_stats_var_report(INOUT name TEXT, OUT calls BIGINT)
+CREATE FUNCTION test_custom_stats_var_report(INOUT name TEXT,
+                                             OUT calls BIGINT,
+                                             OUT description TEXT)
 RETURNS SETOF record
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_report'
 LANGUAGE C STRICT PARALLEL UNSAFE;
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats.c b/src/test/modules/test_custom_stats/test_custom_var_stats.c
index d4905ab4ee9..332cc792213 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats.c
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats.c
@@ -14,6 +14,7 @@
 
 #include "common/hashfn.h"
 #include "funcapi.h"
+#include "storage/dsm_registry.h"
 #include "utils/builtins.h"
 #include "utils/pgstat_internal.h"
 
@@ -32,6 +33,9 @@ PG_MODULE_MAGIC_EXT(
  */
 #define PGSTAT_KIND_TEST_CUSTOM_VAR_STATS 25
 
+/* File paths for extra statistics data serialization */
+#define TEST_CUSTOM_EXTRA_DATA_DESC "pg_stat/test_custom_var_stats_desc.stats"
+
 /*
  * Hash statistic name to generate entry index for pgstat lookup.
  */
@@ -53,7 +57,22 @@ typedef struct PgStatShared_CustomVarEntry
 {
 	PgStatShared_Common header; /* standard pgstat entry header */
 	PgStat_StatCustomVarEntry stats;	/* custom statistics data */
-} PgStatShared_CustomVarEntry;
+	dsa_pointer description;	/* extra statistics data */
+}			PgStatShared_CustomVarEntry;
+
+/*--------------------------------------------------------------------------
+ * Global Variables
+ *--------------------------------------------------------------------------
+ */
+
+/* File handle for extra statistics data serialization */
+static FILE *fd_description = NULL;
+
+/* Current write offset in fd_description file */
+static long fd_description_offset = 0;
+
+/* DSA area for storing variable-length description strings */
+dsa_area   *custom_stats_description_dsa = NULL;
 
 /*--------------------------------------------------------------------------
  * Function prototypes
@@ -64,6 +83,17 @@ typedef struct PgStatShared_CustomVarEntry
 static bool test_custom_stats_var_flush_pending_cb(PgStat_EntryRef *entry_ref,
 												   bool nowait);
 
+/* Serialization callback: serialize extra statistics data */
+static void test_custom_var_stats_serialize(const PgStat_HashKey *key,
+											  const PgStatShared_Common *header, FILE *statfile);
+
+/* Deserialization callback: deserialize extra statistics data */
+static bool test_custom_var_stats_deserialize(const PgStat_HashKey *key,
+												const PgStatShared_Common *header, FILE *statfile);
+
+/* Cleanup callback: end of statistics file operations */
+static void test_custom_var_stats_file_cleanup(PgStat_StatsFileOp status);
+
 /*--------------------------------------------------------------------------
  * Custom kind configuration
  *--------------------------------------------------------------------------
@@ -80,6 +110,9 @@ static const PgStat_KindInfo custom_stats = {
 	.shared_data_len = sizeof(((PgStatShared_CustomVarEntry *) 0)->stats),
 	.pending_size = sizeof(PgStat_StatCustomVarEntry),
 	.flush_pending_cb = test_custom_stats_var_flush_pending_cb,
+	.to_serialized_extra_stats = test_custom_var_stats_serialize,
+	.from_serialized_extra_stats = test_custom_var_stats_deserialize,
+	.end_extra_stats = test_custom_var_stats_file_cleanup,
 };
 
 /*--------------------------------------------------------------------------
@@ -132,6 +165,234 @@ test_custom_stats_var_flush_pending_cb(PgStat_EntryRef *entry_ref, bool nowait)
 	return true;
 }
 
+/*
+ * test_custom_var_stats_serialize() -
+ *
+ * Serialize extra data (descriptions) for custom statistics entries to
+ * the statistics file. Called during statistics file writing to preserve
+ * description strings across restarts.
+ */
+static void
+test_custom_var_stats_serialize(const PgStat_HashKey *key,
+								  const PgStatShared_Common *header, FILE *statfile)
+{
+	char	   *description;
+	size_t		len;
+	long		offset;
+	PgStatShared_CustomVarEntry *entry = (PgStatShared_CustomVarEntry *) header;
+	bool		found;
+
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	/* Open statistics file for writing if not already open */
+	if (!fd_description)
+	{
+		fd_description = AllocateFile(TEST_CUSTOM_EXTRA_DATA_DESC, PG_BINARY_W);
+		if (fd_description == NULL)
+		{
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not open statistics file \"%s\" for writing: %m",
+							TEST_CUSTOM_EXTRA_DATA_DESC)));
+			len = 0;
+			offset = 0;
+			fwrite(&len, sizeof(len), 1, statfile);
+			fwrite(&offset, sizeof(offset), 1, statfile);
+			return;
+		}
+		fd_description_offset = 0;
+	}
+
+	/* Handle entries without descriptions */
+	if (!DsaPointerIsValid(entry->description) || !custom_stats_description_dsa)
+	{
+		len = 0;
+		offset = 0;
+		fwrite(&len, sizeof(len), 1, statfile);
+		fwrite(&offset, sizeof(offset), 1, statfile);
+		return;
+	}
+
+	/* Get current offset in fd_description */
+	offset = fd_description_offset;
+
+	/* Retrieve description from DSA and write to fd_description */
+	description = dsa_get_address(custom_stats_description_dsa, entry->description);
+	len = strlen(description) + 1;
+	fwrite(description, 1, len, fd_description);
+	fd_description_offset += len;
+
+	/* Write length and offset to statfile */
+	fwrite(&len, sizeof(len), 1, statfile);
+	fwrite(&offset, sizeof(offset), 1, statfile);
+}
+
+/*
+ * test_custom_var_stats_deserialize() -
+ *
+ * Deserialize extra data (descriptions) for custom statistics entries from
+ * the statistics file. Called during statistics file reading to restore
+ * description strings after a restart.
+ */
+static bool
+test_custom_var_stats_deserialize(const PgStat_HashKey *key,
+									const PgStatShared_Common *header, FILE *statfile)
+{
+	PgStatShared_CustomVarEntry *entry;
+	dsa_pointer dp;
+	size_t		len;
+	long		offset;
+	char	   *buffer;
+	bool		found;
+
+	/* Read length and offset from statfile */
+	if (fread(&len, sizeof(len), 1, statfile) != 1 ||
+		fread(&offset, sizeof(offset), 1, statfile) != 1)
+	{
+		elog(WARNING, "failed to read description metadata from statistics file");
+		return false;
+	}
+
+	entry = (PgStatShared_CustomVarEntry *) header;
+
+	/* Handle empty descriptions */
+	if (len == 0)
+	{
+		entry->description = InvalidDsaPointer;
+		return true;
+	}
+
+	/* Initialize DSA if needed */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	if (!custom_stats_description_dsa)
+	{
+		elog(WARNING, "could not access DSA for custom statistics descriptions");
+		return false;
+	}
+
+	/* Open statistics file for reading if not already open */
+	if (!fd_description)
+	{
+		fd_description = AllocateFile(TEST_CUSTOM_EXTRA_DATA_DESC, PG_BINARY_R);
+		if (fd_description == NULL)
+		{
+			if (errno != ENOENT)
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not open statistics file \"%s\" for reading: %m",
+								TEST_CUSTOM_EXTRA_DATA_DESC)));
+			pgstat_reset_of_kind(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS);
+			return false;
+		}
+	}
+
+	/* Seek to the offset and read description */
+	if (fseek(fd_description, offset, SEEK_SET) != 0)
+	{
+		elog(WARNING, "failed to seek to offset %ld in description file", offset);
+		return false;
+	}
+
+	buffer = palloc(len);
+	if (fread(buffer, 1, len, fd_description) != len)
+	{
+		pfree(buffer);
+		elog(WARNING, "failed to read description from file");
+		return false;
+	}
+
+	/* Allocate space in DSA and copy the description */
+	dp = dsa_allocate(custom_stats_description_dsa, len);
+	memcpy(dsa_get_address(custom_stats_description_dsa, dp), buffer, len);
+	entry->description = dp;
+
+	pfree(buffer);
+
+	return true;
+}
+
+/*
+ * test_custom_var_stats_file_cleanup() -
+ *
+ * Cleanup function called at the end of statistics file operations.
+ * Handles closing files and cleanup based on the operation type.
+ */
+static void
+test_custom_var_stats_file_cleanup(PgStat_StatsFileOp status)
+{
+	switch (status)
+	{
+		case STATS_WRITE:
+			if (!fd_description)
+				return;
+
+			fd_description_offset = 0;
+
+			/* Check for write errors and cleanup if necessary */
+			if (ferror(fd_description))
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not write statistics file \"%s\": %m",
+								TEST_CUSTOM_EXTRA_DATA_DESC)));
+				FreeFile(fd_description);
+				unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+			}
+			else if (FreeFile(fd_description) < 0)
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not close statistics file \"%s\": %m",
+								TEST_CUSTOM_EXTRA_DATA_DESC)));
+				unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+			}
+			break;
+
+		case STATS_READ:
+			if (!fd_description)
+				return;
+
+			FreeFile(fd_description);
+
+			/* Remove the temporary statistics file after reading */
+			elog(DEBUG2, "removing statistics file \"%s\"", TEST_CUSTOM_EXTRA_DATA_DESC);
+			unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+			break;
+
+		case STATS_DISCARD:
+			{
+				int			ret;
+
+				/* Attempt to remove the statistics file */
+				ret = unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+				if (ret != 0)
+				{
+					if (errno == ENOENT)
+						elog(LOG,
+							 "didn't need to unlink permanent stats file \"%s\" - didn't exist",
+							 TEST_CUSTOM_EXTRA_DATA_DESC);
+					else
+						ereport(LOG,
+								(errcode_for_file_access(),
+								 errmsg("could not unlink permanent statistics file \"%s\": %m",
+										TEST_CUSTOM_EXTRA_DATA_DESC)));
+				}
+				else
+				{
+					ereport(LOG,
+							(errmsg_internal("unlinked permanent statistics file \"%s\"",
+											 TEST_CUSTOM_EXTRA_DATA_DESC)));
+				}
+			}
+			break;
+	}
+
+	fd_description = NULL;
+}
+
 /*--------------------------------------------------------------------------
  * Helper functions
  *--------------------------------------------------------------------------
@@ -162,8 +423,7 @@ test_custom_stats_var_fetch_entry(const char *stat_name)
  * test_custom_stats_var_create
  *		Create new custom statistic entry
  *
- * Initializes a zero-valued statistics entry in shared memory.
- * Validates name length against NAMEDATALEN limit.
+ * Initializes a statistics entry with the given name and description.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_create);
 Datum
@@ -172,6 +432,9 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 	PgStat_EntryRef *entry_ref;
 	PgStatShared_CustomVarEntry *shared_entry;
 	char	   *stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	char	   *description = text_to_cstring(PG_GETARG_TEXT_PP(1));
+	dsa_pointer dp = InvalidDsaPointer;
+	bool		found;
 
 	/* Validate name length first */
 	if (strlen(stat_name) >= NAMEDATALEN)
@@ -180,6 +443,20 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 				 errmsg("custom statistic name \"%s\" is too long", stat_name),
 				 errdetail("Name must be less than %d characters.", NAMEDATALEN)));
 
+	/* Initialize DSA and description provided */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	if (!custom_stats_description_dsa)
+		ereport(ERROR,
+				(errmsg("could not access DSA for custom statistics descriptions")));
+
+	/* Allocate space in DSA and copy description */
+	dp = dsa_allocate(custom_stats_description_dsa, strlen(description) + 1);
+	memcpy(dsa_get_address(custom_stats_description_dsa, dp),
+		   description,
+		   strlen(description) + 1);
+
 	/* Create or get existing entry */
 	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
 											PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), true);
@@ -192,6 +469,9 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 	/* Zero-initialize statistics */
 	memset(&shared_entry->stats, 0, sizeof(shared_entry->stats));
 
+	/* Store description pointer */
+	shared_entry->description = dp;
+
 	pgstat_unlock_entry(entry_ref);
 
 	PG_RETURN_VOID();
@@ -226,8 +506,7 @@ test_custom_stats_var_update(PG_FUNCTION_ARGS)
  * test_custom_stats_var_drop
  *		Remove custom statistic entry
  *
- * Drops the named statistic from shared memory and requests
- * garbage collection if needed.
+ * Drops the named statistic from shared memory.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_drop);
 Datum
@@ -247,7 +526,7 @@ test_custom_stats_var_drop(PG_FUNCTION_ARGS)
  * test_custom_stats_var_report
  *		Retrieve custom statistic values
  *
- * Returns single row with statistic name and call count if the
+ * Returns single row with statistic name, call count, and description if the
  * statistic exists, otherwise returns no rows.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_report);
@@ -281,9 +560,13 @@ test_custom_stats_var_report(PG_FUNCTION_ARGS)
 
 	if (funcctx->call_cntr < funcctx->max_calls)
 	{
-		Datum		values[2];
-		bool		nulls[2] = {false, false};
+		Datum		values[3];
+		bool		nulls[3] = {false, false, false};
 		HeapTuple	tuple;
+		PgStat_EntryRef *entry_ref;
+		PgStatShared_CustomVarEntry *shared_entry;
+		char	   *description = NULL;
+		bool		found;
 
 		stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
 		stat_entry = test_custom_stats_var_fetch_entry(stat_name);
@@ -291,9 +574,33 @@ test_custom_stats_var_report(PG_FUNCTION_ARGS)
 		/* Return row only if entry exists */
 		if (stat_entry)
 		{
+			/* Get entry ref to access shared entry */
+			entry_ref = pgstat_get_entry_ref(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
+											 PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), false, NULL);
+
+			if (entry_ref)
+			{
+				shared_entry = (PgStatShared_CustomVarEntry *) entry_ref->shared_stats;
+
+				/* Get description from DSA if available */
+				if (DsaPointerIsValid(shared_entry->description))
+				{
+					if (!custom_stats_description_dsa)
+						custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+					if (custom_stats_description_dsa)
+						description = dsa_get_address(custom_stats_description_dsa, shared_entry->description);
+				}
+			}
+
 			values[0] = PointerGetDatum(cstring_to_text(stat_name));
 			values[1] = Int64GetDatum(stat_entry->numcalls);
 
+			if (description)
+				values[2] = PointerGetDatum(cstring_to_text(description));
+			else
+				nulls[2] = true;
+
 			tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
 			SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
 		}
-- 
2.43.0

#26Chao Li
li.evan.chao@gmail.com
In reply to: Sami Imseih (#25)
Re: [Proposal] Adding callback support for custom statistics kinds

On Dec 9, 2025, at 03:09, Sami Imseih <samimseih@gmail.com> wrote:

Yes, thanks. Structurally, this is better and more flexible than what
we had originally, and I have noticed that you have copied the
original files while adding more comments and renaming a bit things:
the structure of the functions was exactly the same. Anyway, I have
worked on that for a good portion of the day, splitting the module
drop and the new module into two commits, and applied the result after
tweaking quite a few things in terms of names and comments (no
pgstat_*, a bit more "Var" and "Fixed", etc.), applying a much more
consistent set of names across the board for the functions and the
structures. This cleanup part is moved out of the way now, so that
you ease the introduction of the next pieces you are proposing.

Thanks for getting these committed!

I rebased the custom callbacks patch in v5.

One very minor thing from the earlier commits that I corrected here is
the test for entry 2 after a clean restart.

-is($result, "entry1|2", "variable-sized stats persist after clean restart");
+is($result, "entry1|2|Test entry 1", "variable-sized stats persist
after clean restart");
+
+$result = $node->safe_psql('postgres', q(select * from
test_custom_stats_var_report('entry2')));
+is($result, "entry2|3|Test entry 2", "variable-sized stats persist
after clean restart");
+

--
Sami Imseih
Amazon Web Services (AWS)
<v5-0001-Allow-cumulative-statistics-to-serialize-auxiliar.patch>

```
+					if (kind_info->from_serialized_extra_stats)
+					{
+						if (!kind_info->from_serialized_extra_stats(&key, header, fpin))
+						{
+							elog(WARNING, "could not read extra stats for entry %u/%u/%" PRIu64,
+								 key.kind, key.dboid, key.objid);
+							goto error;
+						}
+					}
```

When deserialize failed, it goes to error. In the error clause, it calls pgstat_reset_after_failure(), so do we want to give extensions a chance to do some reset operations? If yes, then we can add a reset_after_failure() callback.

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

#27Sami Imseih
samimseih@gmail.com
In reply to: Chao Li (#26)
Re: [Proposal] Adding callback support for custom statistics kinds
+                                       if (kind_info->from_serialized_extra_stats)
+                                       {
+                                               if (!kind_info->from_serialized_extra_stats(&key, header, fpin))
+                                               {
+                                                       elog(WARNING, "could not read extra stats for entry %u/%u/%" PRIu64,
+                                                                key.kind, key.dboid, key.objid);
+                                                       goto error;
+                                               }
+                                       }
```

When deserialize failed, it goes to error. In the error clause, it calls pgstat_reset_after_failure(), so do we want to give extensions a chance to do some reset operations? If yes, then we can add a reset_after_failure() callback.

The way v5 is dealing with a deserialize failure is that when
it goes to error, the pgstat_reset_after_failure() will reset the
stats for all kinds, since pgstat_drop_all_entries() is called
during that call. So there is nothing for an extension to have
to do on its own. The extension will then clean-up resources
at the end when all the kinds are iterated over and
kind_info->end_extra_stats(STATS_READ) is called for each
kind.

Let me know if I'm still missing something?

--
Sami Imseih
Amazon Web Services (AWS)

#28Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#27)
Re: [Proposal] Adding callback support for custom statistics kinds

On Mon, Dec 08, 2025 at 09:57:15PM -0600, Sami Imseih wrote:

The way v5 is dealing with a deserialize failure is that when
it goes to error, the pgstat_reset_after_failure() will reset the
stats for all kinds, since pgstat_drop_all_entries() is called
during that call. So there is nothing for an extension to have
to do on its own. The extension will then clean-up resources
at the end when all the kinds are iterated over and
kind_info->end_extra_stats(STATS_READ) is called for each
kind.

Let me know if I'm still missing something?

It seems to me that you are missing nothing here, and that Chao has
missed the fact that the end of pgstat_read_statsfile() does a "goto
done", meaning that we would take a round of
end_extra_stats(STATS_READ) to do all the cleanup after resetting all
the stats. That's what I would expect.

+static inline bool pgstat_check_extra_callbacks(PgStat_Kind kind);
[...]
@@ -645,6 +656,13 @@ pgstat_initialize(void)
+	/* Check a kind's extra-data callback setup */
+	for (PgStat_Kind kind = PGSTAT_KIND_BUILTIN_MIN; kind <= PGSTAT_KIND_BUILTIN_MAX; kind++)
+		if (!pgstat_check_extra_callbacks(kind))
+			ereport(ERROR,
+					errmsg("incomplete extra serialization callbacks for stats kind %d",
+						   kind));

Why does this part need to run each time a backend initializes its
access to pgstats? Shouldn't this happen only once when a stats kind
is registered? pgstat_register_kind() should be the only code path
that does such sanity checks.

By the way, checking that to_serialized_extra_stats and
kind_info->from_serialized_extra_stats need to be both defined is
fine as these are coupled together, but I am not following the reason
why end_extra_stats would need to be included in the set? For
example, a stats kind could decide to add some data to the main
pgstats file without creating extra files, hence they may not need to
define end_extra_stats.
--
Michael

#29Chao Li
li.evan.chao@gmail.com
In reply to: Michael Paquier (#28)
Re: [Proposal] Adding callback support for custom statistics kinds

On Dec 9, 2025, at 12:45, Michael Paquier <michael@paquier.xyz> wrote:

It seems to me that you are missing nothing here, and that Chao has
missed the fact that the end of pgstat_read_statsfile() does a "goto
done", meaning that we would take a round of

No, I didn’t miss that part. But in the “done” clause:

```
done:
/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
FreeFile(fpin);

elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
unlink(statfile);

/* Let each stats kind run its cleanup callback, if it provides one */
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->end_extra_stats)
kind_info->end_extra_stats(STATS_READ);
}
```

end_extra_stats(STATS_READ) has no failure indication.

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

#30Chao Li
li.evan.chao@gmail.com
In reply to: Chao Li (#29)
Re: [Proposal] Adding callback support for custom statistics kinds

On Dec 9, 2025, at 13:23, Chao Li <li.evan.chao@gmail.com> wrote:

On Dec 9, 2025, at 12:45, Michael Paquier <michael@paquier.xyz> wrote:

It seems to me that you are missing nothing here, and that Chao has
missed the fact that the end of pgstat_read_statsfile() does a "goto
done", meaning that we would take a round of

No, I didn’t miss that part. But in the “done” clause:

```
done:
/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
FreeFile(fpin);

elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
unlink(statfile);

/* Let each stats kind run its cleanup callback, if it provides one */
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->end_extra_stats)
kind_info->end_extra_stats(STATS_READ);
}
```

end_extra_stats(STATS_READ) has no failure indication.

Sorry, I incidentally clicked “send” too quickly.

My point is that, there are many places jumping to “error”, then from “error” goto “done”, if an error didn’t happen from the deserialize callback, how end_extra_stats() can know if failure happened and takes action accordingly?

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

#31Sami Imseih
samimseih@gmail.com
In reply to: Chao Li (#30)
1 attachment(s)
Re: [Proposal] Adding callback support for custom statistics kinds

My point is that, there are many places jumping to “error”, then from “error” goto “done”,
if an error didn’t happen from the deserialize callback, how end_extra_stats()
can know if failure happened and takes action accordingly?

IIUC, if *any* error occurs outside of a deserialize callback, first the "error"
code will be called, followed by "done" which will then trigger the
end_extra_stats
callback that will perform the cleanup.

Attached is v6 with a few minor indentation fixes and a correction to
freeing the file in the cleanup callback.

--
Sami Imseih
Amazon Web Services (AWS)

Attachments:

v6-0001-Allow-cumulative-statistics-to-serialize-auxiliar.patchapplication/octet-stream; name=v6-0001-Allow-cumulative-statistics-to-serialize-auxiliar.patchDownload
From 650f3fd68ab8d9d11616689bfcf8f6c3dfb772e0 Mon Sep 17 00:00:00 2001
From: Sami Imseih <simseih@amazon.com>
Date: Mon, 10 Nov 2025 00:03:41 -0600
Subject: [PATCH v6 1/1] Allow cumulative statistics to serialize auxiliary
 data to disk.

Cumulative Statistics kinds can now write additional per-entry data to
the statistics file that doesn't fit in shared memory. This is useful
for statistics with variable-length auxiliary data.

Three new optional callbacks are added to PgStat_KindInfo:

* to_serialized_extra_stats: writes auxiliary data for an entry
* from_serialized_extra_stats: reads auxiliary data for an entry
* end_extra_stats: performs cleanup after read/write/discard operations

All three callbacks must be provided together to ensure the reader
consumes exactly what the writer produces. The end_extra_stats callback
is invoked after processing all entries of a kind, allowing extensions
to close file handles and clean up resources.

Tests are also added to test_custom_stats.pl

Discussion: https://www.postgresql.org/message-id/flat/CAA5RZ0s9SDOu+Z6veoJCHWk+kDeTktAtC-KY9fQ9Z6BJdDUirQ@mail.gmail.com
---
 src/backend/utils/activity/pgstat.c           |  89 ++++-
 src/include/utils/pgstat_internal.h           |  37 ++
 .../test_custom_stats/t/001_custom_stats.pl   |  26 +-
 .../test_custom_var_stats--1.0.sql            |   7 +-
 .../test_custom_stats/test_custom_var_stats.c | 319 +++++++++++++++++-
 5 files changed, 457 insertions(+), 21 deletions(-)

diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index 8713c7a0483..2e0c1bbb061 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -194,6 +194,7 @@ static void pgstat_build_snapshot(void);
 static void pgstat_build_snapshot_fixed(PgStat_Kind kind);
 
 static inline bool pgstat_is_kind_valid(PgStat_Kind kind);
+static inline bool pgstat_check_extra_callbacks(PgStat_Kind kind);
 
 
 /* ----------
@@ -523,6 +524,7 @@ pgstat_discard_stats(void)
 
 	/* NB: this needs to be done even in single user mode */
 
+	/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
 	ret = unlink(PGSTAT_STAT_PERMANENT_FILENAME);
 	if (ret != 0)
 	{
@@ -544,6 +546,15 @@ pgstat_discard_stats(void)
 								 PGSTAT_STAT_PERMANENT_FILENAME)));
 	}
 
+	/* Let each stats kind run its cleanup callback, if it provides one */
+	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->end_extra_stats)
+			kind_info->end_extra_stats(STATS_DISCARD);
+	}
+
 	/*
 	 * Reset stats contents. This will set reset timestamps of fixed-numbered
 	 * stats to the current time (no variable stats exist).
@@ -645,6 +656,13 @@ pgstat_initialize(void)
 
 	pgstat_attach_shmem();
 
+	/* Check a kind's extra-data callback setup */
+	for (PgStat_Kind kind = PGSTAT_KIND_BUILTIN_MIN; kind <= PGSTAT_KIND_BUILTIN_MAX; kind++)
+		if (!pgstat_check_extra_callbacks(kind))
+			ereport(ERROR,
+					errmsg("incomplete extra serialization callbacks for stats kind %d",
+						   kind));
+
 	pgstat_init_snapshot_fixed();
 
 	/* Backend initialization callbacks */
@@ -1432,6 +1450,33 @@ pgstat_is_kind_valid(PgStat_Kind kind)
 	return pgstat_is_kind_builtin(kind) || pgstat_is_kind_custom(kind);
 }
 
+/*
+ * Validate that extra stats callbacks are all provided together or not at all.
+ */
+static inline bool
+pgstat_check_extra_callbacks(PgStat_Kind kind)
+{
+	const PgStat_KindInfo *kind_info = pgstat_get_kind_info(kind);
+	int			count = 0;
+
+	/* Custom kind not yet registered, skip validation */
+	if (!kind_info)
+		return true;
+
+	if (kind_info->to_serialized_extra_stats)
+		count++;
+	if (kind_info->from_serialized_extra_stats)
+		count++;
+	if (kind_info->end_extra_stats)
+		count++;
+
+	/* Either all three callbacks must be provided, or none */
+	if (count != 0 && count != 3)
+		return false;
+
+	return true;
+}
+
 const PgStat_KindInfo *
 pgstat_get_kind_info(PgStat_Kind kind)
 {
@@ -1525,6 +1570,13 @@ pgstat_register_kind(PgStat_Kind kind, const PgStat_KindInfo *kind_info)
 					 errdetail("Existing cumulative statistics with ID %u has the same name.", existing_kind)));
 	}
 
+	/* Check a kind's extra-data callback setup */
+	if (!pgstat_check_extra_callbacks(kind))
+		ereport(ERROR,
+				(errmsg("incomplete serialization callbacks for statistics kind \"%s\"",
+						kind_info->name),
+				 errdetail("callbacks to_serialized_extra_stats, from_serialized_extra_stats, and end_extra_stats must be provided together.")));
+
 	/* Register it */
 	pgstat_kind_custom_infos[idx] = kind_info;
 	ereport(LOG,
@@ -1702,6 +1754,9 @@ pgstat_write_statsfile(void)
 		pgstat_write_chunk(fpout,
 						   pgstat_get_entry_data(ps->key.kind, shstats),
 						   pgstat_get_entry_len(ps->key.kind));
+
+		if (kind_info->to_serialized_extra_stats)
+			kind_info->to_serialized_extra_stats(&ps->key, shstats, fpout);
 	}
 	dshash_seq_term(&hstat);
 
@@ -1734,6 +1789,15 @@ pgstat_write_statsfile(void)
 		/* durable_rename already emitted log message */
 		unlink(tmpfile);
 	}
+
+	/* Now, allow kinds to finalize the writes for the extra files */
+	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->end_extra_stats)
+			kind_info->end_extra_stats(STATS_WRITE);
+	}
 }
 
 /* helper for pgstat_read_statsfile() */
@@ -1871,6 +1935,7 @@ pgstat_read_statsfile(void)
 					PgStat_HashKey key;
 					PgStatShared_HashEntry *p;
 					PgStatShared_Common *header;
+					const PgStat_KindInfo *kind_info = NULL;
 
 					CHECK_FOR_INTERRUPTS();
 
@@ -1891,7 +1956,8 @@ pgstat_read_statsfile(void)
 							goto error;
 						}
 
-						if (!pgstat_get_kind_info(key.kind))
+						kind_info = pgstat_get_kind_info(key.kind);
+						if (!kind_info)
 						{
 							elog(WARNING, "could not find information of kind for entry %u/%u/%" PRIu64 " of type %c",
 								 key.kind, key.dboid,
@@ -1902,7 +1968,6 @@ pgstat_read_statsfile(void)
 					else
 					{
 						/* stats entry identified by name on disk (e.g. slots) */
-						const PgStat_KindInfo *kind_info = NULL;
 						PgStat_Kind kind;
 						NameData	name;
 
@@ -1996,6 +2061,16 @@ pgstat_read_statsfile(void)
 						goto error;
 					}
 
+					if (kind_info->from_serialized_extra_stats)
+					{
+						if (!kind_info->from_serialized_extra_stats(&key, header, fpin))
+						{
+							elog(WARNING, "could not read extra stats for entry %u/%u/%" PRIu64,
+								 key.kind, key.dboid, key.objid);
+							goto error;
+						}
+					}
+
 					break;
 				}
 			case PGSTAT_FILE_ENTRY_END:
@@ -2019,11 +2094,21 @@ pgstat_read_statsfile(void)
 	}
 
 done:
+	/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
 	FreeFile(fpin);
 
 	elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
 	unlink(statfile);
 
+	/* Let each stats kind run its cleanup callback, if it provides one */
+	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->end_extra_stats)
+			kind_info->end_extra_stats(STATS_READ);
+	}
+
 	return;
 
 error:
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index ca1ba6420ca..48b40816570 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -63,6 +63,20 @@ typedef struct PgStat_HashKey
 								 * identifier. */
 } PgStat_HashKey;
 
+/*
+ * Tracks if the stats file is being read, written or discarded.
+ *
+ * These states allow plugins that create extra statistics files
+ * to determine the current operation and perform any necessary
+ * file cleanup.
+ */
+typedef enum PgStat_StatsFileOp
+{
+	STATS_WRITE,
+	STATS_READ,
+	STATS_DISCARD,
+}			PgStat_StatsFileOp;
+
 /*
  * PgStat_HashKey should not have any padding.  Checking that the structure
  * size matches with the sum of each field is a check simple enough to
@@ -303,6 +317,29 @@ typedef struct PgStat_KindInfo
 									   const PgStatShared_Common *header, NameData *name);
 	bool		(*from_serialized_name) (const NameData *name, PgStat_HashKey *key);
 
+	/*
+	 * Optional callbacks for kinds that write additional per-entry data to
+	 * the stats file.  If any of these callbacks are provided, all three must
+	 * be provided to ensure that the reader consumes exactly the data written
+	 * by the writer.
+	 *
+	 * to_serialized_extra_stats: write extra data for an entry.
+	 *
+	 * from_serialized_extra_stats: read the extra data for an entry. Returns
+	 * true on success, false on read error.
+	 *
+	 * end_extra_stats: invoked once per operation (read, write, discard)
+	 * after all entries of this kind have been processed.
+	 *
+	 * Note: statfile is a pointer to the main stats file,
+	 * PGSTAT_STAT_PERMANENT_FILENAME.
+	 */
+	void		(*to_serialized_extra_stats) (const PgStat_HashKey *key,
+											  const PgStatShared_Common *header, FILE *statfile);
+	bool		(*from_serialized_extra_stats) (const PgStat_HashKey *key,
+												const PgStatShared_Common *header, FILE *statfile);
+	void		(*end_extra_stats) (PgStat_StatsFileOp status);
+
 	/*
 	 * For fixed-numbered statistics: Initialize shared memory state.
 	 *
diff --git a/src/test/modules/test_custom_stats/t/001_custom_stats.pl b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
index e528595cfb0..b3b25819411 100644
--- a/src/test/modules/test_custom_stats/t/001_custom_stats.pl
+++ b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
@@ -29,13 +29,13 @@ $node->safe_psql('postgres', q(CREATE EXTENSION test_custom_fixed_stats));
 
 # Create entries for variable-sized stats.
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry1')));
+	q(select test_custom_stats_var_create('entry1', 'Test entry 1')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry2')));
+	q(select test_custom_stats_var_create('entry2', 'Test entry 2')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry3')));
+	q(select test_custom_stats_var_create('entry3', 'Test entry 3')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry4')));
+	q(select test_custom_stats_var_create('entry4', 'Test entry 4')));
 
 # Update counters: entry1=2, entry2=3, entry3=2, entry4=3, fixed=3
 $node->safe_psql('postgres',
@@ -65,16 +65,20 @@ $node->safe_psql('postgres', q(select test_custom_stats_fixed_update()));
 # Test data reports.
 my $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry1')));
-is($result, "entry1|2", "report for variable-sized data of entry1");
+is($result, "entry1|2|Test entry 1", "report for variable-sized data of entry1");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry2')));
-is($result, "entry2|3", "report for variable-sized data of entry2");
+is($result, "entry2|3|Test entry 2", "report for variable-sized data of entry2");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry3')));
-is($result, "entry3|2", "report for variable-sized data of entry3");
+is($result, "entry3|2|Test entry 3", "report for variable-sized data of entry3");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry4')));
-is($result, "entry4|3", "report for variable-sized data of entry4");
+is($result, "entry4|3|Test entry 4", "report for variable-sized data of entry4");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_fixed_report()));
 is($result, "3|", "report for fixed-sized stats");
@@ -97,7 +101,11 @@ $node->start();
 
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry1')));
-is($result, "entry1|2", "variable-sized stats persist after clean restart");
+is($result, "entry1|2|Test entry 1", "variable-sized stats persist after clean restart");
+
+$result = $node->safe_psql('postgres', q(select * from test_custom_stats_var_report('entry2')));
+is($result, "entry2|3|Test entry 2", "variable-sized stats persist after clean restart");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_fixed_report()));
 is($result, "3|", "fixed-sized stats persist after clean restart");
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
index d5f82b5d546..5ed8cfc2dcf 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
@@ -3,7 +3,7 @@
 -- complain if script is sourced in psql, rather than via CREATE EXTENSION
 \echo Use "CREATE EXTENSION test_custom_var_stats" to load this file. \quit
 
-CREATE FUNCTION test_custom_stats_var_create(IN name TEXT)
+CREATE FUNCTION test_custom_stats_var_create(IN name TEXT, in description TEXT)
 RETURNS void
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_create'
 LANGUAGE C STRICT PARALLEL UNSAFE;
@@ -18,8 +18,9 @@ RETURNS void
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_drop'
 LANGUAGE C STRICT PARALLEL UNSAFE;
 
-
-CREATE FUNCTION test_custom_stats_var_report(INOUT name TEXT, OUT calls BIGINT)
+CREATE FUNCTION test_custom_stats_var_report(INOUT name TEXT,
+                                             OUT calls BIGINT,
+                                             OUT description TEXT)
 RETURNS SETOF record
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_report'
 LANGUAGE C STRICT PARALLEL UNSAFE;
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats.c b/src/test/modules/test_custom_stats/test_custom_var_stats.c
index d4905ab4ee9..da57fb192ab 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats.c
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats.c
@@ -14,6 +14,7 @@
 
 #include "common/hashfn.h"
 #include "funcapi.h"
+#include "storage/dsm_registry.h"
 #include "utils/builtins.h"
 #include "utils/pgstat_internal.h"
 
@@ -32,6 +33,9 @@ PG_MODULE_MAGIC_EXT(
  */
 #define PGSTAT_KIND_TEST_CUSTOM_VAR_STATS 25
 
+/* File paths for extra statistics data serialization */
+#define TEST_CUSTOM_EXTRA_DATA_DESC "pg_stat/test_custom_var_stats_desc.stats"
+
 /*
  * Hash statistic name to generate entry index for pgstat lookup.
  */
@@ -53,8 +57,23 @@ typedef struct PgStatShared_CustomVarEntry
 {
 	PgStatShared_Common header; /* standard pgstat entry header */
 	PgStat_StatCustomVarEntry stats;	/* custom statistics data */
+	dsa_pointer description;	/* extra statistics data */
 } PgStatShared_CustomVarEntry;
 
+/*--------------------------------------------------------------------------
+ * Global Variables
+ *--------------------------------------------------------------------------
+ */
+
+/* File handle for extra statistics data serialization */
+static FILE *fd_description = NULL;
+
+/* Current write offset in fd_description file */
+static long fd_description_offset = 0;
+
+/* DSA area for storing variable-length description strings */
+dsa_area   *custom_stats_description_dsa = NULL;
+
 /*--------------------------------------------------------------------------
  * Function prototypes
  *--------------------------------------------------------------------------
@@ -64,6 +83,17 @@ typedef struct PgStatShared_CustomVarEntry
 static bool test_custom_stats_var_flush_pending_cb(PgStat_EntryRef *entry_ref,
 												   bool nowait);
 
+/* Serialization callback: serialize extra statistics data */
+static void test_custom_var_stats_serialize(const PgStat_HashKey *key,
+											const PgStatShared_Common *header, FILE *statfile);
+
+/* Deserialization callback: deserialize extra statistics data */
+static bool test_custom_var_stats_deserialize(const PgStat_HashKey *key,
+											  const PgStatShared_Common *header, FILE *statfile);
+
+/* Cleanup callback: end of statistics file operations */
+static void test_custom_var_stats_file_cleanup(PgStat_StatsFileOp status);
+
 /*--------------------------------------------------------------------------
  * Custom kind configuration
  *--------------------------------------------------------------------------
@@ -80,6 +110,9 @@ static const PgStat_KindInfo custom_stats = {
 	.shared_data_len = sizeof(((PgStatShared_CustomVarEntry *) 0)->stats),
 	.pending_size = sizeof(PgStat_StatCustomVarEntry),
 	.flush_pending_cb = test_custom_stats_var_flush_pending_cb,
+	.to_serialized_extra_stats = test_custom_var_stats_serialize,
+	.from_serialized_extra_stats = test_custom_var_stats_deserialize,
+	.end_extra_stats = test_custom_var_stats_file_cleanup,
 };
 
 /*--------------------------------------------------------------------------
@@ -132,6 +165,232 @@ test_custom_stats_var_flush_pending_cb(PgStat_EntryRef *entry_ref, bool nowait)
 	return true;
 }
 
+/*
+ * test_custom_var_stats_serialize() -
+ *
+ * Serialize extra data (descriptions) for custom statistics entries to
+ * the statistics file. Called during statistics file writing to preserve
+ * description strings across restarts.
+ */
+static void
+test_custom_var_stats_serialize(const PgStat_HashKey *key,
+								const PgStatShared_Common *header, FILE *statfile)
+{
+	char	   *description;
+	size_t		len;
+	long		offset;
+	PgStatShared_CustomVarEntry *entry = (PgStatShared_CustomVarEntry *) header;
+	bool		found;
+
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	/* Open statistics file for writing if not already open */
+	if (!fd_description)
+	{
+		fd_description = AllocateFile(TEST_CUSTOM_EXTRA_DATA_DESC, PG_BINARY_W);
+		if (fd_description == NULL)
+		{
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not open statistics file \"%s\" for writing: %m",
+							TEST_CUSTOM_EXTRA_DATA_DESC)));
+			len = 0;
+			offset = 0;
+			fwrite(&len, sizeof(len), 1, statfile);
+			fwrite(&offset, sizeof(offset), 1, statfile);
+			return;
+		}
+		fd_description_offset = 0;
+	}
+
+	/* Handle entries without descriptions */
+	if (!DsaPointerIsValid(entry->description) || !custom_stats_description_dsa)
+	{
+		len = 0;
+		offset = 0;
+		fwrite(&len, sizeof(len), 1, statfile);
+		fwrite(&offset, sizeof(offset), 1, statfile);
+		return;
+	}
+
+	/* Get current offset in fd_description */
+	offset = fd_description_offset;
+
+	/* Retrieve description from DSA and write to fd_description */
+	description = dsa_get_address(custom_stats_description_dsa, entry->description);
+	len = strlen(description) + 1;
+	fwrite(description, 1, len, fd_description);
+	fd_description_offset += len;
+
+	/* Write length and offset to statfile */
+	fwrite(&len, sizeof(len), 1, statfile);
+	fwrite(&offset, sizeof(offset), 1, statfile);
+}
+
+/*
+ * test_custom_var_stats_deserialize() -
+ *
+ * Deserialize extra data (descriptions) for custom statistics entries from
+ * the statistics file. Called during statistics file reading to restore
+ * description strings after a restart.
+ */
+static bool
+test_custom_var_stats_deserialize(const PgStat_HashKey *key,
+								  const PgStatShared_Common *header, FILE *statfile)
+{
+	PgStatShared_CustomVarEntry *entry;
+	dsa_pointer dp;
+	size_t		len;
+	long		offset;
+	char	   *buffer;
+	bool		found;
+
+	/* Read length and offset from statfile */
+	if (fread(&len, sizeof(len), 1, statfile) != 1 ||
+		fread(&offset, sizeof(offset), 1, statfile) != 1)
+	{
+		elog(WARNING, "failed to read description metadata from statistics file");
+		return false;
+	}
+
+	entry = (PgStatShared_CustomVarEntry *) header;
+
+	/* Handle empty descriptions */
+	if (len == 0)
+	{
+		entry->description = InvalidDsaPointer;
+		return true;
+	}
+
+	/* Initialize DSA if needed */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	if (!custom_stats_description_dsa)
+	{
+		elog(WARNING, "could not access DSA for custom statistics descriptions");
+		return false;
+	}
+
+	/* Open statistics file for reading if not already open */
+	if (!fd_description)
+	{
+		fd_description = AllocateFile(TEST_CUSTOM_EXTRA_DATA_DESC, PG_BINARY_R);
+		if (fd_description == NULL)
+		{
+			if (errno != ENOENT)
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not open statistics file \"%s\" for reading: %m",
+								TEST_CUSTOM_EXTRA_DATA_DESC)));
+			pgstat_reset_of_kind(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS);
+			return false;
+		}
+	}
+
+	/* Seek to the offset and read description */
+	if (fseek(fd_description, offset, SEEK_SET) != 0)
+	{
+		elog(WARNING, "failed to seek to offset %ld in description file", offset);
+		return false;
+	}
+
+	buffer = palloc(len);
+	if (fread(buffer, 1, len, fd_description) != len)
+	{
+		pfree(buffer);
+		elog(WARNING, "failed to read description from file");
+		return false;
+	}
+
+	/* Allocate space in DSA and copy the description */
+	dp = dsa_allocate(custom_stats_description_dsa, len);
+	memcpy(dsa_get_address(custom_stats_description_dsa, dp), buffer, len);
+	entry->description = dp;
+
+	pfree(buffer);
+
+	return true;
+}
+
+/*
+ * test_custom_var_stats_file_cleanup() -
+ *
+ * Cleanup function called at the end of statistics file operations.
+ * Handles closing files and cleanup based on the operation type.
+ */
+static void
+test_custom_var_stats_file_cleanup(PgStat_StatsFileOp status)
+{
+	switch (status)
+	{
+		case STATS_WRITE:
+			if (!fd_description)
+				return;
+
+			fd_description_offset = 0;
+
+			/* Check for write errors and cleanup if necessary */
+			if (ferror(fd_description))
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not write statistics file \"%s\": %m",
+								TEST_CUSTOM_EXTRA_DATA_DESC)));
+				FreeFile(fd_description);
+				unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+			}
+			else if (FreeFile(fd_description) < 0)
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not close statistics file \"%s\": %m",
+								TEST_CUSTOM_EXTRA_DATA_DESC)));
+				unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+			}
+			break;
+
+		case STATS_READ:
+			if (fd_description)
+				FreeFile(fd_description);
+
+			/* Remove the temporary statistics file after reading */
+			elog(DEBUG2, "removing statistics file \"%s\"", TEST_CUSTOM_EXTRA_DATA_DESC);
+			unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+			break;
+
+		case STATS_DISCARD:
+			{
+				int			ret;
+
+				/* Attempt to remove the statistics file */
+				ret = unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+				if (ret != 0)
+				{
+					if (errno == ENOENT)
+						elog(LOG,
+							 "didn't need to unlink permanent stats file \"%s\" - didn't exist",
+							 TEST_CUSTOM_EXTRA_DATA_DESC);
+					else
+						ereport(LOG,
+								(errcode_for_file_access(),
+								 errmsg("could not unlink permanent statistics file \"%s\": %m",
+										TEST_CUSTOM_EXTRA_DATA_DESC)));
+				}
+				else
+				{
+					ereport(LOG,
+							(errmsg_internal("unlinked permanent statistics file \"%s\"",
+											 TEST_CUSTOM_EXTRA_DATA_DESC)));
+				}
+			}
+			break;
+	}
+
+	fd_description = NULL;
+}
+
 /*--------------------------------------------------------------------------
  * Helper functions
  *--------------------------------------------------------------------------
@@ -162,8 +421,7 @@ test_custom_stats_var_fetch_entry(const char *stat_name)
  * test_custom_stats_var_create
  *		Create new custom statistic entry
  *
- * Initializes a zero-valued statistics entry in shared memory.
- * Validates name length against NAMEDATALEN limit.
+ * Initializes a statistics entry with the given name and description.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_create);
 Datum
@@ -172,6 +430,9 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 	PgStat_EntryRef *entry_ref;
 	PgStatShared_CustomVarEntry *shared_entry;
 	char	   *stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	char	   *description = text_to_cstring(PG_GETARG_TEXT_PP(1));
+	dsa_pointer dp = InvalidDsaPointer;
+	bool		found;
 
 	/* Validate name length first */
 	if (strlen(stat_name) >= NAMEDATALEN)
@@ -180,6 +441,20 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 				 errmsg("custom statistic name \"%s\" is too long", stat_name),
 				 errdetail("Name must be less than %d characters.", NAMEDATALEN)));
 
+	/* Initialize DSA and description provided */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	if (!custom_stats_description_dsa)
+		ereport(ERROR,
+				(errmsg("could not access DSA for custom statistics descriptions")));
+
+	/* Allocate space in DSA and copy description */
+	dp = dsa_allocate(custom_stats_description_dsa, strlen(description) + 1);
+	memcpy(dsa_get_address(custom_stats_description_dsa, dp),
+		   description,
+		   strlen(description) + 1);
+
 	/* Create or get existing entry */
 	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
 											PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), true);
@@ -192,6 +467,9 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 	/* Zero-initialize statistics */
 	memset(&shared_entry->stats, 0, sizeof(shared_entry->stats));
 
+	/* Store description pointer */
+	shared_entry->description = dp;
+
 	pgstat_unlock_entry(entry_ref);
 
 	PG_RETURN_VOID();
@@ -226,8 +504,7 @@ test_custom_stats_var_update(PG_FUNCTION_ARGS)
  * test_custom_stats_var_drop
  *		Remove custom statistic entry
  *
- * Drops the named statistic from shared memory and requests
- * garbage collection if needed.
+ * Drops the named statistic from shared memory.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_drop);
 Datum
@@ -247,7 +524,7 @@ test_custom_stats_var_drop(PG_FUNCTION_ARGS)
  * test_custom_stats_var_report
  *		Retrieve custom statistic values
  *
- * Returns single row with statistic name and call count if the
+ * Returns single row with statistic name, call count, and description if the
  * statistic exists, otherwise returns no rows.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_report);
@@ -281,9 +558,13 @@ test_custom_stats_var_report(PG_FUNCTION_ARGS)
 
 	if (funcctx->call_cntr < funcctx->max_calls)
 	{
-		Datum		values[2];
-		bool		nulls[2] = {false, false};
+		Datum		values[3];
+		bool		nulls[3] = {false, false, false};
 		HeapTuple	tuple;
+		PgStat_EntryRef *entry_ref;
+		PgStatShared_CustomVarEntry *shared_entry;
+		char	   *description = NULL;
+		bool		found;
 
 		stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
 		stat_entry = test_custom_stats_var_fetch_entry(stat_name);
@@ -291,9 +572,33 @@ test_custom_stats_var_report(PG_FUNCTION_ARGS)
 		/* Return row only if entry exists */
 		if (stat_entry)
 		{
+			/* Get entry ref to access shared entry */
+			entry_ref = pgstat_get_entry_ref(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
+											 PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), false, NULL);
+
+			if (entry_ref)
+			{
+				shared_entry = (PgStatShared_CustomVarEntry *) entry_ref->shared_stats;
+
+				/* Get description from DSA if available */
+				if (DsaPointerIsValid(shared_entry->description))
+				{
+					if (!custom_stats_description_dsa)
+						custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+					if (custom_stats_description_dsa)
+						description = dsa_get_address(custom_stats_description_dsa, shared_entry->description);
+				}
+			}
+
 			values[0] = PointerGetDatum(cstring_to_text(stat_name));
 			values[1] = Int64GetDatum(stat_entry->numcalls);
 
+			if (description)
+				values[2] = PointerGetDatum(cstring_to_text(description));
+			else
+				nulls[2] = true;
+
 			tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
 			SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
 		}
-- 
2.43.0

#32Chao Li
li.evan.chao@gmail.com
In reply to: Sami Imseih (#31)
Re: [Proposal] Adding callback support for custom statistics kinds

On Dec 10, 2025, at 05:54, Sami Imseih <samimseih@gmail.com> wrote:

IIUC, if *any* error occurs outside of a deserialize callback, first the "error"
code will be called, followed by "done" which will then trigger the
end_extra_stats
callback that will perform the cleanup.

That is true. But problem is, without an error indication, end_extra_stats(STATS_READ) can only blindly perform cleanup works. As you are providing general purposed callbacks, who knows what scenarios extensions would do, so it’s better to provide more information to callbacks. IMO, letting end_extra_stats() know current situation (normal or failure, even error code) is very meaningful. For example, my extension may want to log “I am forced to quite due to outside error” or “I am done successfully” in end_extra_stats(). Anyway, that’s my own opinion. If you and Michael still consider that’s not a problem, I won’t argue more.

Best reagards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

#33Sami Imseih
samimseih@gmail.com
In reply to: Chao Li (#32)
Re: [Proposal] Adding callback support for custom statistics kinds

IIUC, if *any* error occurs outside of a deserialize callback, first the

"error"

code will be called, followed by "done" which will then trigger the
end_extra_stats
callback that will perform the cleanup.

That is true. But problem is, without an error indication,
end_extra_stats(STATS_READ) can only blindly perform cleanup works. As you
are providing general purposed callbacks, who knows what scenarios
extensions would do, so it’s better to provide more information to
callbacks. IMO, letting end_extra_stats() know current situation (normal or
failure, even error code) is very meaningful. For example, my extension may
want to log “I am forced to quite due to outside error” or “I am done
successfully” in end_extra_stats(). Anyway, that’s my own opinion. If you
and Michael still consider that’s not a problem, I won’t argue more.

Thanks for explaining. If there is a good use-case to add more detail to
the “end” callback, it’s not very obvious yet. Maybe in the future, there
will be a convincing reason to do so.

When we hit the clean-up code on any “error”, it should be accompanied by
an error log. That is
done in all cases inside pgstat.c, and I expect an extension to log the
error as well.

--
Sami Imseih
Amazon Web Services (AWS)

#34Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#33)
Re: [Proposal] Adding callback support for custom statistics kinds

On Tue, Dec 09, 2025 at 05:15:45PM -0600, Sami Imseih wrote:

Thanks for explaining. If there is a good use-case to add more detail to
the “end” callback, it’s not very obvious yet. Maybe in the future, there
will be a convincing reason to do so.

The step taken by test_custom_var_stats_file_cleanup() for the
STATS_READ case shines for its simplicity. The STATS_DISCARD case is
also simple: we know that we want to ditch the stats.

Now, it is kind of true that the STATS_WRITE case feels a bit
disturbing written this way: we let a module take an action, but we
don't actually know the state of the main pgstats file when inside the
callback. I mean, you can know how things are going on, but it means
that a module can just rely on a check if
PGSTAT_STAT_PERMANENT_FILENAME is on disk, but an unlink() could have
failed as well. So, yes, I am wondering whether we should do what
Chao is suggesting, passing an extra state to the callback to let the
module know if we have actually succeeded or failed the operations
that have been taken on the main stats file before the callback
end_extra_stats is called in the three cases. It does not matter for
the STATS_READ case, but it may matter for the STATS_DISCARD or
STATS_WRITE case.

When we hit the clean-up code on any “error”, it should be accompanied by
an error log. That is
done in all cases inside pgstat.c, and I expect an extension to log the
error as well.

FWIW, I still have the same question as the one posted here about the
business in pgstat_initialize(), still present in v6:
/messages/by-id/aTepXZ97PsXpuywI@paquier.xyz

This remains unanswered.
--
Michael

#35Sami Imseih
samimseih@gmail.com
In reply to: Michael Paquier (#34)
Re: [Proposal] Adding callback support for custom statistics kinds

Now, it is kind of true that the STATS_WRITE case feels a bit
disturbing written this way: we let a module take an action, but we
don't actually know the state of the main pgstats file when inside the
callback. I mean, you can know how things are going on, but it means
that a module can just rely on a check if
PGSTAT_STAT_PERMANENT_FILENAME is on disk, but an unlink() could have
failed as well. So, yes, I am wondering whether we should do what
Chao is suggesting, passing an extra state to the callback to let the
module know if we have actually succeeded or failed the operations
that have been taken on the main stats file before the callback
end_extra_stats is called in the three cases. It does not matter for
the STATS_READ case, but it may matter for the STATS_DISCARD or
STATS_WRITE case.

I am having a hard time being convinced that this extra status is needed.
I am not expecting an extension to operate on the main stats file inside
the end_extra_stats callback, and even if some operation failed on the
main stats file, the cleanup callback will need to take the steps to
perform the cleanup on its own resources.

Is there a concrete example?

FWIW, I still have the same question as the one posted here about the
business in pgstat_initialize(), still present in v6:
/messages/by-id/aTepXZ97PsXpuywI@paquier.xyz

This remains unanswered.

Responding to the questions from the thread above.

Why does this part need to run each time a backend initializes its
access to pgstats?

Good point. This is unnecessary. This validation should really be
done inside StatsShmemInit by postmaster.

By the way, checking that to_serialized_extra_stats and
kind_info->from_serialized_extra_stats need to be both defined is
fine as these are coupled together, but I am not following the reason
why end_extra_stats would need to be included in the set? For
example, a stats kind could decide to add some data to the main
pgstats file without creating extra files, hence they may not need to
define end_extra_stats.

.. and after giving this more thought, I actually don't think we should
do any validation for any of the callbacks. If an extension is writing
to any file ( core or custom ), naturally they will want to read it back.
Now I am not sure what these validations are protecting us against.
Also, maybe the extension wants to just read data from the main stats
file, I could see that use-case, perhaps.

So, I am proposing removing the validation altogether. What do
you think?

--
Sami Imseih
Amazon Web Services (AWS)

#36Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#35)
Re: [Proposal] Adding callback support for custom statistics kinds

On Wed, Dec 10, 2025 at 12:36:36PM -0600, Sami Imseih wrote:

.. and after giving this more thought, I actually don't think we should
do any validation for any of the callbacks. If an extension is writing
to any file ( core or custom ), naturally they will want to read it back.
Now I am not sure what these validations are protecting us against.
Also, maybe the extension wants to just read data from the main stats
file, I could see that use-case, perhaps.

So, I am proposing removing the validation altogether. What do
you think?

The to and from callbacks are coupled with each other, so there may be
a point in making sure that if one is defined so is the other. Now, I
have never done any enforcement for the existing from/to serialization
callbacks either because it would be quickly clear for one what needs
to be done when implementing a custom kind. So I'd agree with just
removing these checks and keep the code simpler.

FWIW, I have begun putting my hands on your patch, editing it at some
degree. I am not sure that I will be able to finish that today, but
I'm working towards getting something done.
--
Michael

#37Michael Paquier
michael@paquier.xyz
In reply to: Michael Paquier (#36)
1 attachment(s)
Re: [Proposal] Adding callback support for custom statistics kinds

On Fri, Dec 12, 2025 at 11:58:54AM +0900, Michael Paquier wrote:

FWIW, I have begun putting my hands on your patch, editing it at some
degree. I am not sure that I will be able to finish that today, but
I'm working towards getting something done.

Well, I have been able to do enough progress to have something to
share, and I'm getting pretty happy about how things are shaping. As
you will notice, I have edited quite a few things.. In details:
- Less fwrite() and fread(), more read_chunk() and write_chunk(). We
are exposing these APIs, let's use them.
- Comments, much more comments and documentation.
- The callbacks are renamed, to be more generic: "finish" for the
end-of-operation actions and to/from_serialized_data.
- The format of the extra data in the main pgstats file and the
secondary file was a bit strange. Mainly, why adding the length to
the main file and not the secondary file? I have extended that a
little bit:
-- Addition of a magic number in the main file, to provide an extra
layer of safety in the read callback, letting the callback know that
it needs to read some data.
-- The offset of the secondary file follows immeditely.
-- The secondary file includes at the offset a copy of the hash key,
the description length, and the description.
- Reorganization of the read/write flow for the callbacks in the
modules, tracking the offset at write more precisely. The handling of
the empty descriptions becomes simpler than what you have proposed
previously.

This way, we can make sure that the main stats file is OK with the
magic number, and we have a sanity check in the secondary file based
on the hash key whose copy is in the main stats file.

Regarding the error state that could be sent to the "end" callback, I
think that you are right. We are not gaining much with that as by
design we are already pretty loose on the write side, hoping for the
best, relying on the read side to enforce all sanity checks. So a
status in the "from" callback sounds like a good enough balance.

At the end of the day, I'm feeling pretty much OK with the core
changes and the layer we have here. The module changes need an extra
round of lookup (did as well some tests with corrupted and empty
secondary files to test the stability at reload), and I'm pretty tired
so I may have missed something there. The patch needs to be split in
two parts: one for the backend changes, and one for the module itself.
The backend changes are feeling pretty good, the module changes feel
better.
--
Michael

Attachments:

v7-0001-Allow-cumulative-statistics-to-serialize-auxiliar.patchtext/x-diff; charset=us-asciiDownload
From cafc8251e6f56e113ad4ad19ffb72fa9ad312def Mon Sep 17 00:00:00 2001
From: Michael Paquier <michael@paquier.xyz>
Date: Fri, 12 Dec 2025 18:38:05 +0900
Subject: [PATCH v7] Allow cumulative statistics to serialize auxiliary data to
 disk.

Cumulative Statistics kinds can now write additional per-entry data to
the statistics file that doesn't fit in shared memory. This is useful
for statistics with variable-length auxiliary data.

Three new optional callbacks are added to PgStat_KindInfo:

* to_serialized_data: writes auxiliary data for an entry
* from_serialized_data: reads auxiliary data for an entry
* finish: performs actions after read/write/discard operations.

The finish callback is invoked after processing all entries of a kind,
allowing extensions to close file handles and clean up resources.

Tests are also added to test_custom_stats.pl

Discussion: https://postgr.es/m/CAA5RZ0s9SDOu+Z6veoJCHWk+kDeTktAtC-KY9fQ9Z6BJdDUirQ@mail.gmail.com
---
 src/include/executor/executor.h               |   2 +-
 src/include/libpq/libpq-be-fe-helpers.h       |   2 +-
 src/include/utils/pgstat_internal.h           |  47 ++
 src/backend/libpq/pqformat.c                  |   3 +-
 src/backend/rewrite/rewriteHandler.c          |   2 +-
 src/backend/storage/file/fileset.c            |   3 +-
 src/backend/utils/activity/pgstat.c           |  50 ++-
 src/backend/utils/adt/numutils.c              |   2 +-
 src/backend/utils/cache/relcache.c            |   2 +-
 src/backend/utils/mb/conv.c                   |   4 +-
 src/backend/utils/mb/mbutils.c                |   6 +-
 src/backend/utils/misc/guc.c                  |   2 +-
 src/backend/utils/mmgr/alignedalloc.c         |   4 +-
 src/backend/utils/mmgr/portalmem.c            |   3 +-
 src/backend/utils/sort/tuplesortvariants.c    |   1 -
 .../test_custom_stats/t/001_custom_stats.pl   |  39 +-
 .../test_custom_var_stats--1.0.sql            |   7 +-
 .../test_custom_stats/test_custom_var_stats.c | 400 +++++++++++++++++-
 src/tools/pgindent/typedefs.list              |   1 +
 19 files changed, 542 insertions(+), 38 deletions(-)

diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h
index fa2b657fb2ff..7cd6a49309f0 100644
--- a/src/include/executor/executor.h
+++ b/src/include/executor/executor.h
@@ -404,7 +404,7 @@ ExecEvalExpr(ExprState *state,
  * Like ExecEvalExpr(), but for cases where no return value is expected,
  * because the side-effects of expression evaluation are what's desired. This
  * is e.g. used for projection and aggregate transition computation.
-
+ *
  * Evaluate expression identified by "state" in the execution context
  * given by "econtext".
  *
diff --git a/src/include/libpq/libpq-be-fe-helpers.h b/src/include/libpq/libpq-be-fe-helpers.h
index 1c4a342090c3..d2f6b3b13484 100644
--- a/src/include/libpq/libpq-be-fe-helpers.h
+++ b/src/include/libpq/libpq-be-fe-helpers.h
@@ -69,7 +69,7 @@ libpqsrv_connect(const char *conninfo, uint32 wait_event_info)
 /*
  * Like libpqsrv_connect(), except that this is a wrapper for
  * PQconnectdbParams().
-  */
+ */
 static inline PGconn *
 libpqsrv_connect_params(const char *const *keywords,
 						const char *const *values,
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index ca1ba6420ca1..031fd34ad788 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -63,6 +63,21 @@ typedef struct PgStat_HashKey
 								 * identifier. */
 } PgStat_HashKey;
 
+/*
+ * Tracks if the stats file is being read, written or discarded, used in
+ * combination with the finish callback.
+ *
+ * These states allow plugins that create extra statistics files
+ * to determine the current operation and perform any necessary
+ * file cleanup.
+ */
+typedef enum PgStat_StatsFileOp
+{
+	STATS_WRITE,
+	STATS_READ,
+	STATS_DISCARD,
+} PgStat_StatsFileOp;
+
 /*
  * PgStat_HashKey should not have any padding.  Checking that the structure
  * size matches with the sum of each field is a check simple enough to
@@ -303,6 +318,38 @@ typedef struct PgStat_KindInfo
 									   const PgStatShared_Common *header, NameData *name);
 	bool		(*from_serialized_name) (const NameData *name, PgStat_HashKey *key);
 
+	/*
+	 * For variable-numbered stats: read or write additional data related to a
+	 * given entry, in the stats file or optionally in a different file.
+	 * Optional.
+	 *
+	 * to_serialized_data: write extra data for an entry.
+	 *
+	 * from_serialized_data: read extra data for an entry.  Returns true on
+	 * success, false on read error.
+	 *
+	 * "statfile" is a pointer to the on-disk stats file, named
+	 * PGSTAT_STAT_PERMANENT_FILENAME.  "key" is the hash key of the entry
+	 * just written or read.  "header" is a pointer to the stats data.
+	 */
+	void		(*to_serialized_data) (const PgStat_HashKey *key,
+									   const PgStatShared_Common *header,
+									   FILE *statfile);
+	bool		(*from_serialized_data) (const PgStat_HashKey *key,
+										 const PgStatShared_Common *header,
+										 FILE *statfile);
+
+	/*
+	 * For fixed-numbered or variable-numbered statistics.
+	 *
+	 * Perform custom actions when done processing the on-disk stats file
+	 * after all the stats entries have been processed.  Optional.
+	 *
+	 * "status" tracks the operation done for the on-disk stats file (read,
+	 * write, discard).
+	 */
+	void		(*finish) (PgStat_StatsFileOp status);
+
 	/*
 	 * For fixed-numbered statistics: Initialize shared memory state.
 	 *
diff --git a/src/backend/libpq/pqformat.c b/src/backend/libpq/pqformat.c
index 1cc126772f7c..67bdd3d93d05 100644
--- a/src/backend/libpq/pqformat.c
+++ b/src/backend/libpq/pqformat.c
@@ -307,9 +307,8 @@ pq_endmessage(StringInfo buf)
  *
  * The data buffer is *not* freed, allowing to reuse the buffer with
  * pq_beginmessage_reuse.
- --------------------------------
+ * --------------------------------
  */
-
 void
 pq_endmessage_reuse(StringInfo buf)
 {
diff --git a/src/backend/rewrite/rewriteHandler.c b/src/backend/rewrite/rewriteHandler.c
index 688dcd09ca6e..0852322cc588 100644
--- a/src/backend/rewrite/rewriteHandler.c
+++ b/src/backend/rewrite/rewriteHandler.c
@@ -2620,7 +2620,7 @@ view_col_is_auto_updatable(RangeTblRef *rtr, TargetEntry *tle)
  * view_query_is_auto_updatable - test whether the specified view definition
  * represents an auto-updatable view. Returns NULL (if the view can be updated)
  * or a message string giving the reason that it cannot be.
-
+ *
  * The returned string has not been translated; if it is shown as an error
  * message, the caller should apply _() to translate it.
  *
diff --git a/src/backend/storage/file/fileset.c b/src/backend/storage/file/fileset.c
index 4d5ee353fd7a..2061aa44e773 100644
--- a/src/backend/storage/file/fileset.c
+++ b/src/backend/storage/file/fileset.c
@@ -114,7 +114,8 @@ FileSetCreate(FileSet *fileset, const char *name)
 }
 
 /*
- * Open a file that was created with FileSetCreate() */
+ * Open a file that was created with FileSetCreate()
+ */
 File
 FileSetOpen(FileSet *fileset, const char *name, int mode)
 {
diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index 8713c7a04834..647fe9b111a4 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -523,6 +523,7 @@ pgstat_discard_stats(void)
 
 	/* NB: this needs to be done even in single user mode */
 
+	/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
 	ret = unlink(PGSTAT_STAT_PERMANENT_FILENAME);
 	if (ret != 0)
 	{
@@ -544,6 +545,15 @@ pgstat_discard_stats(void)
 								 PGSTAT_STAT_PERMANENT_FILENAME)));
 	}
 
+	/* Finish callbacks, if required */
+	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->finish)
+			kind_info->finish(STATS_DISCARD);
+	}
+
 	/*
 	 * Reset stats contents. This will set reset timestamps of fixed-numbered
 	 * stats to the current time (no variable stats exist).
@@ -1702,6 +1712,10 @@ pgstat_write_statsfile(void)
 		pgstat_write_chunk(fpout,
 						   pgstat_get_entry_data(ps->key.kind, shstats),
 						   pgstat_get_entry_len(ps->key.kind));
+
+		/* Write more data for the entry, if required */
+		if (kind_info->to_serialized_data)
+			kind_info->to_serialized_data(&ps->key, shstats, fpout);
 	}
 	dshash_seq_term(&hstat);
 
@@ -1734,6 +1748,15 @@ pgstat_write_statsfile(void)
 		/* durable_rename already emitted log message */
 		unlink(tmpfile);
 	}
+
+	/* Now, allow stats kinds to finalize the data writes */
+	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->finish)
+			kind_info->finish(STATS_WRITE);
+	}
 }
 
 /* helper for pgstat_read_statsfile() */
@@ -1871,6 +1894,7 @@ pgstat_read_statsfile(void)
 					PgStat_HashKey key;
 					PgStatShared_HashEntry *p;
 					PgStatShared_Common *header;
+					const PgStat_KindInfo *kind_info = NULL;
 
 					CHECK_FOR_INTERRUPTS();
 
@@ -1891,7 +1915,8 @@ pgstat_read_statsfile(void)
 							goto error;
 						}
 
-						if (!pgstat_get_kind_info(key.kind))
+						kind_info = pgstat_get_kind_info(key.kind);
+						if (!kind_info)
 						{
 							elog(WARNING, "could not find information of kind for entry %u/%u/%" PRIu64 " of type %c",
 								 key.kind, key.dboid,
@@ -1902,7 +1927,6 @@ pgstat_read_statsfile(void)
 					else
 					{
 						/* stats entry identified by name on disk (e.g. slots) */
-						const PgStat_KindInfo *kind_info = NULL;
 						PgStat_Kind kind;
 						NameData	name;
 
@@ -1996,6 +2020,18 @@ pgstat_read_statsfile(void)
 						goto error;
 					}
 
+					/* read more data for the entry, if required */
+					if (kind_info->from_serialized_data)
+					{
+						if (!kind_info->from_serialized_data(&key, header, fpin))
+						{
+							elog(WARNING, "could not read extra stats for entry %u/%u/%" PRIu64 " of type %c",
+								 key.kind, key.dboid,
+								 key.objid, t);
+							goto error;
+						}
+					}
+
 					break;
 				}
 			case PGSTAT_FILE_ENTRY_END:
@@ -2019,11 +2055,21 @@ pgstat_read_statsfile(void)
 	}
 
 done:
+	/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
 	FreeFile(fpin);
 
 	elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
 	unlink(statfile);
 
+	/* Let each stats kind run its finish callback, if required */
+	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->finish)
+			kind_info->finish(STATS_READ);
+	}
+
 	return;
 
 error:
diff --git a/src/backend/utils/adt/numutils.c b/src/backend/utils/adt/numutils.c
index 3bf30774a0c9..254c5cf82e4b 100644
--- a/src/backend/utils/adt/numutils.c
+++ b/src/backend/utils/adt/numutils.c
@@ -113,7 +113,7 @@ static const int8 hexlookup[128] = {
  * pg_strtoint16() will throw ereport() upon bad input format or overflow;
  * while pg_strtoint16_safe() instead returns such complaints in *escontext,
  * if it's an ErrorSaveContext.
-*
+ *
  * NB: Accumulate input as an unsigned number, to deal with two's complement
  * representation of the most negative number, which can't be represented as a
  * signed positive number.
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index a4dc1cbe5aec..2d0cb7bcfd4a 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -5643,7 +5643,7 @@ RelationGetIdentityKeyBitmap(Relation relation)
  * This should be called only for an index that is known to have an associated
  * exclusion constraint or primary key/unique constraint using WITHOUT
  * OVERLAPS.
-
+ *
  * It returns arrays (palloc'd in caller's context) of the exclusion operator
  * OIDs, their underlying functions' OIDs, and their strategy numbers in the
  * index's opclasses.  We cache all this information since it requires a fair
diff --git a/src/backend/utils/mb/conv.c b/src/backend/utils/mb/conv.c
index 4a312ab429b6..d53e885b067e 100644
--- a/src/backend/utils/mb/conv.c
+++ b/src/backend/utils/mb/conv.c
@@ -484,7 +484,7 @@ pg_mb_radix_conv(const pg_mb_radix_tree *rt,
  * utf: input string in UTF8 encoding (need not be null-terminated)
  * len: length of input string (in bytes)
  * iso: pointer to the output area (must be large enough!)
-		  (output string will be null-terminated)
+ *		  (output string will be null-terminated)
  * map: conversion map for single characters
  * cmap: conversion map for combined characters
  *		  (optional, pass NULL if none)
@@ -694,7 +694,7 @@ UtfToLocal(const unsigned char *utf, int len,
  * iso: input string in local encoding (need not be null-terminated)
  * len: length of input string (in bytes)
  * utf: pointer to the output area (must be large enough!)
-		  (output string will be null-terminated)
+ *		  (output string will be null-terminated)
  * map: conversion map for single characters
  * cmap: conversion map for combined characters
  *		  (optional, pass NULL if none)
diff --git a/src/backend/utils/mb/mbutils.c b/src/backend/utils/mb/mbutils.c
index dbce0e61812b..d1701d69b16e 100644
--- a/src/backend/utils/mb/mbutils.c
+++ b/src/backend/utils/mb/mbutils.c
@@ -497,7 +497,8 @@ pg_do_encoding_conversion_buf(Oid proc,
  * Convert string to encoding encoding_name. The source
  * encoding is the DB encoding.
  *
- * BYTEA convert_to(TEXT string, NAME encoding_name) */
+ * BYTEA convert_to(TEXT string, NAME encoding_name)
+ */
 Datum
 pg_convert_to(PG_FUNCTION_ARGS)
 {
@@ -522,7 +523,8 @@ pg_convert_to(PG_FUNCTION_ARGS)
  * Convert string from encoding encoding_name. The destination
  * encoding is the DB encoding.
  *
- * TEXT convert_from(BYTEA string, NAME encoding_name) */
+ * TEXT convert_from(BYTEA string, NAME encoding_name)
+ */
 Datum
 pg_convert_from(PG_FUNCTION_ARGS)
 {
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index f7d63e04c046..935c235e1b39 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -5449,7 +5449,7 @@ ShowGUCOption(const struct config_generic *record, bool use_units)
  *		variable sourceline, integer
  *		variable source, integer
  *		variable scontext, integer
-*		variable srole, OID
+ *		variable srole, OID
  */
 static void
 write_one_nondefault_variable(FILE *fp, struct config_generic *gconf)
diff --git a/src/backend/utils/mmgr/alignedalloc.c b/src/backend/utils/mmgr/alignedalloc.c
index b1be74269149..daee3fc80a1c 100644
--- a/src/backend/utils/mmgr/alignedalloc.c
+++ b/src/backend/utils/mmgr/alignedalloc.c
@@ -23,8 +23,8 @@
 
 /*
  * AlignedAllocFree
-*		Frees allocated memory; memory is removed from its owning context.
-*/
+ *		Frees allocated memory; memory is removed from its owning context.
+ */
 void
 AlignedAllocFree(void *pointer)
 {
diff --git a/src/backend/utils/mmgr/portalmem.c b/src/backend/utils/mmgr/portalmem.c
index 943da087c9f5..1f2a423f38a6 100644
--- a/src/backend/utils/mmgr/portalmem.c
+++ b/src/backend/utils/mmgr/portalmem.c
@@ -853,7 +853,8 @@ AtAbort_Portals(void)
 /*
  * Post-abort cleanup for portals.
  *
- * Delete all portals not held over from prior transactions.  */
+ * Delete all portals not held over from prior transactions.
+ */
 void
 AtCleanup_Portals(void)
 {
diff --git a/src/backend/utils/sort/tuplesortvariants.c b/src/backend/utils/sort/tuplesortvariants.c
index 079a51c474d0..a1f5c19ee976 100644
--- a/src/backend/utils/sort/tuplesortvariants.c
+++ b/src/backend/utils/sort/tuplesortvariants.c
@@ -1132,7 +1132,6 @@ tuplesort_getgintuple(Tuplesortstate *state, Size *len, bool forward)
  * efficient, but only safe for callers that are prepared to have any
  * subsequent manipulation of the tuplesort's state invalidate slot contents.
  * For byval Datums, the value of the 'copy' parameter has no effect.
-
  */
 bool
 tuplesort_getdatum(Tuplesortstate *state, bool forward, bool copy,
diff --git a/src/test/modules/test_custom_stats/t/001_custom_stats.pl b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
index e528595cfb0c..378ec22bbdf7 100644
--- a/src/test/modules/test_custom_stats/t/001_custom_stats.pl
+++ b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
@@ -29,13 +29,13 @@ $node->safe_psql('postgres', q(CREATE EXTENSION test_custom_fixed_stats));
 
 # Create entries for variable-sized stats.
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry1')));
+	q(select test_custom_stats_var_create('entry1', 'Test entry 1')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry2')));
+	q(select test_custom_stats_var_create('entry2', 'Test entry 2')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry3')));
+	q(select test_custom_stats_var_create('entry3', 'Test entry 3')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry4')));
+	q(select test_custom_stats_var_create('entry4', 'Test entry 4')));
 
 # Update counters: entry1=2, entry2=3, entry3=2, entry4=3, fixed=3
 $node->safe_psql('postgres',
@@ -65,16 +65,28 @@ $node->safe_psql('postgres', q(select test_custom_stats_fixed_update()));
 # Test data reports.
 my $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry1')));
-is($result, "entry1|2", "report for variable-sized data of entry1");
+is( $result,
+	"entry1|2|Test entry 1",
+	"report for variable-sized data of entry1");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry2')));
-is($result, "entry2|3", "report for variable-sized data of entry2");
+is( $result,
+	"entry2|3|Test entry 2",
+	"report for variable-sized data of entry2");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry3')));
-is($result, "entry3|2", "report for variable-sized data of entry3");
+is( $result,
+	"entry3|2|Test entry 3",
+	"report for variable-sized data of entry3");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry4')));
-is($result, "entry4|3", "report for variable-sized data of entry4");
+is( $result,
+	"entry4|3|Test entry 4",
+	"report for variable-sized data of entry4");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_fixed_report()));
 is($result, "3|", "report for fixed-sized stats");
@@ -97,7 +109,16 @@ $node->start();
 
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry1')));
-is($result, "entry1|2", "variable-sized stats persist after clean restart");
+is( $result,
+	"entry1|2|Test entry 1",
+	"variable-sized stats persist after clean restart");
+
+$result = $node->safe_psql('postgres',
+	q(select * from test_custom_stats_var_report('entry2')));
+is( $result,
+	"entry2|3|Test entry 2",
+	"variable-sized stats persist after clean restart");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_fixed_report()));
 is($result, "3|", "fixed-sized stats persist after clean restart");
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
index d5f82b5d546e..5ed8cfc2dcf1 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
@@ -3,7 +3,7 @@
 -- complain if script is sourced in psql, rather than via CREATE EXTENSION
 \echo Use "CREATE EXTENSION test_custom_var_stats" to load this file. \quit
 
-CREATE FUNCTION test_custom_stats_var_create(IN name TEXT)
+CREATE FUNCTION test_custom_stats_var_create(IN name TEXT, in description TEXT)
 RETURNS void
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_create'
 LANGUAGE C STRICT PARALLEL UNSAFE;
@@ -18,8 +18,9 @@ RETURNS void
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_drop'
 LANGUAGE C STRICT PARALLEL UNSAFE;
 
-
-CREATE FUNCTION test_custom_stats_var_report(INOUT name TEXT, OUT calls BIGINT)
+CREATE FUNCTION test_custom_stats_var_report(INOUT name TEXT,
+                                             OUT calls BIGINT,
+                                             OUT description TEXT)
 RETURNS SETOF record
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_report'
 LANGUAGE C STRICT PARALLEL UNSAFE;
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats.c b/src/test/modules/test_custom_stats/test_custom_var_stats.c
index d4905ab4ee99..9cc9ff8cd9f9 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats.c
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats.c
@@ -14,6 +14,7 @@
 
 #include "common/hashfn.h"
 #include "funcapi.h"
+#include "storage/dsm_registry.h"
 #include "utils/builtins.h"
 #include "utils/pgstat_internal.h"
 
@@ -22,6 +23,8 @@ PG_MODULE_MAGIC_EXT(
 					.version = PG_VERSION
 );
 
+#define TEST_CUSTOM_VAR_MAGIC_NUMBER (0xBEEFBEEF)
+
 /*--------------------------------------------------------------------------
  * Macros and constants
  *--------------------------------------------------------------------------
@@ -32,6 +35,9 @@ PG_MODULE_MAGIC_EXT(
  */
 #define PGSTAT_KIND_TEST_CUSTOM_VAR_STATS 25
 
+/* File paths for extra statistics data serialization */
+#define TEST_CUSTOM_EXTRA_DATA_DESC "pg_stat/test_custom_var_stats_desc.stats"
+
 /*
  * Hash statistic name to generate entry index for pgstat lookup.
  */
@@ -53,8 +59,23 @@ typedef struct PgStatShared_CustomVarEntry
 {
 	PgStatShared_Common header; /* standard pgstat entry header */
 	PgStat_StatCustomVarEntry stats;	/* custom statistics data */
+	dsa_pointer description;	/* extra statistics data */
 } PgStatShared_CustomVarEntry;
 
+/*--------------------------------------------------------------------------
+ * Global Variables
+ *--------------------------------------------------------------------------
+ */
+
+/* File handle for extra statistics data serialization */
+static FILE *fd_description = NULL;
+
+/* Current write offset in fd_description file */
+static long fd_description_offset = 0;
+
+/* DSA area for storing variable-length description strings */
+static dsa_area *custom_stats_description_dsa = NULL;
+
 /*--------------------------------------------------------------------------
  * Function prototypes
  *--------------------------------------------------------------------------
@@ -64,6 +85,19 @@ typedef struct PgStatShared_CustomVarEntry
 static bool test_custom_stats_var_flush_pending_cb(PgStat_EntryRef *entry_ref,
 												   bool nowait);
 
+/* Serialization callback: serialize extra statistics data */
+static void test_custom_stats_var_to_serialized_data(const PgStat_HashKey *key,
+													 const PgStatShared_Common *header,
+													 FILE *statfile);
+
+/* Deserialization callback: deserialize extra statistics data */
+static bool test_custom_stats_var_from_serialized_data(const PgStat_HashKey *key,
+													   const PgStatShared_Common *header,
+													   FILE *statfile);
+
+/* Cleanup callback: end of statistics file operations */
+static void test_custom_stats_var_finish(PgStat_StatsFileOp status);
+
 /*--------------------------------------------------------------------------
  * Custom kind configuration
  *--------------------------------------------------------------------------
@@ -80,6 +114,9 @@ static const PgStat_KindInfo custom_stats = {
 	.shared_data_len = sizeof(((PgStatShared_CustomVarEntry *) 0)->stats),
 	.pending_size = sizeof(PgStat_StatCustomVarEntry),
 	.flush_pending_cb = test_custom_stats_var_flush_pending_cb,
+	.to_serialized_data = test_custom_stats_var_to_serialized_data,
+	.from_serialized_data = test_custom_stats_var_from_serialized_data,
+	.finish = test_custom_stats_var_finish,
 };
 
 /*--------------------------------------------------------------------------
@@ -132,6 +169,309 @@ test_custom_stats_var_flush_pending_cb(PgStat_EntryRef *entry_ref, bool nowait)
 	return true;
 }
 
+/*
+ * test_custom_stats_var_to_serialized_data() -
+ *
+ * Serialize extra data (descriptions) for custom statistics entries to
+ * the statistics file.  Called during statistics file writing to preserve
+ * description strings across restarts.
+ *
+ * This callback writes a mix of data within the main pgstats file and a
+ * secondary file.  The following data is written to the main file for
+ * each entry:
+ * - An arbitrary magic number.
+ * - An offset.  This is used to know the location we need to look at
+ * to retrieve the information from the second file.
+ *
+ * The following data is written to the secondary file:
+ * - The entry key, cross-checked with the data from the main file
+ * when reloaded.
+ * - The length of the description.
+ * - The description data itself.
+ */
+static void
+test_custom_stats_var_to_serialized_data(const PgStat_HashKey *key,
+										 const PgStatShared_Common *header,
+										 FILE *statfile)
+{
+	char	   *description;
+	size_t		len;
+	PgStatShared_CustomVarEntry *entry = (PgStatShared_CustomVarEntry *) header;
+	bool		found;
+	uint32		magic_number = TEST_CUSTOM_VAR_MAGIC_NUMBER;
+
+	/*
+	 * First mark the main file with a magic number, keeping a trace that some
+	 * extra data will exist in the secondary file.
+	 */
+	pgstat_write_chunk_s(statfile, &magic_number);
+
+	/* Open statistics file for writing. */
+	if (!fd_description)
+	{
+		fd_description = AllocateFile(TEST_CUSTOM_EXTRA_DATA_DESC, PG_BINARY_W);
+		if (fd_description == NULL)
+		{
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not open statistics file \"%s\" for writing: %m",
+							TEST_CUSTOM_EXTRA_DATA_DESC)));
+			return;
+		}
+
+		/* Initialize offset for secondary file. */
+		fd_description_offset = 0;
+	}
+
+	/* Write offset to the main data file */
+	pgstat_write_chunk_s(statfile, &fd_description_offset);
+
+	/*
+	 * First write the entry key to the secondary file.  This will be
+	 * cross-checked with the key read from main stats file at loading time.
+	 */
+	pgstat_write_chunk_s(fd_description, (PgStat_HashKey *) key);
+	fd_description_offset += sizeof(PgStat_HashKey);
+
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	/* Handle entries without descriptions */
+	if (!DsaPointerIsValid(entry->description) || !custom_stats_description_dsa)
+	{
+		/* length to description file */
+		len = 0;
+		pgstat_write_chunk_s(fd_description, &len);
+		fd_description_offset += sizeof(size_t);
+		return;
+	}
+
+	/*
+	 * Retrieve description from DSA, then write the length followed by the
+	 * description.
+	 */
+	description = dsa_get_address(custom_stats_description_dsa,
+								  entry->description);
+	len = strlen(description) + 1;
+	pgstat_write_chunk_s(fd_description, &len);
+	pgstat_write_chunk(fd_description, description, len);
+
+	/*
+	 * Update offset for next entry, counting for the length (size_t) of the
+	 * description and the description contents.
+	 */
+	fd_description_offset += len + sizeof(size_t);
+}
+
+/*
+ * test_custom_stats_var_from_serialized_data() -
+ *
+ * Read extra data (descriptions) for custom statistics entries from
+ * the statistics file.  This is called while loading the statistics
+ * at startup.
+ *
+ * See the top of test_custom_stats_var_to_serialized_data() for a
+ * detailed description of the data layout read here.
+ */
+static bool
+test_custom_stats_var_from_serialized_data(const PgStat_HashKey *key,
+										   const PgStatShared_Common *header,
+										   FILE *statfile)
+{
+	PgStatShared_CustomVarEntry *entry;
+	dsa_pointer dp;
+	size_t		len;
+	long		offset;
+	char	   *buffer;
+	bool		found;
+	uint32		magic_number = 0;
+	PgStat_HashKey file_key;
+
+	/* Check the magic number first, in the main file. */
+	if (!pgstat_read_chunk_s(statfile, &magic_number))
+	{
+		elog(WARNING, "failed to read magic number from statistics file");
+		return false;
+	}
+
+	if (magic_number != TEST_CUSTOM_VAR_MAGIC_NUMBER)
+	{
+		elog(WARNING, "found magic number %u from statistics file, should be %u",
+			 magic_number, TEST_CUSTOM_VAR_MAGIC_NUMBER);
+		return false;
+	}
+
+	/*
+	 * Read the offset from the main stats file, to be able to read the extra
+	 * data from the secondary file.
+	 */
+	if (!pgstat_read_chunk_s(statfile, &offset))
+	{
+		elog(WARNING, "failed to read metadata offset from statistics file");
+		return false;
+	}
+
+	/* Open statistics file for reading if not already open */
+	if (!fd_description)
+	{
+		fd_description = AllocateFile(TEST_CUSTOM_EXTRA_DATA_DESC, PG_BINARY_R);
+		if (fd_description == NULL)
+		{
+			if (errno != ENOENT)
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not open statistics file \"%s\" for reading: %m",
+								TEST_CUSTOM_EXTRA_DATA_DESC)));
+			pgstat_reset_of_kind(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS);
+			return false;
+		}
+	}
+
+	/* Read data from the secondary file, at the specified offset */
+	if (fseek(fd_description, offset, SEEK_SET) != 0)
+	{
+		elog(WARNING, "failed to seek to offset %ld in description file", offset);
+		return false;
+	}
+
+	/* Read the hash key from the secondary file */
+	if (!pgstat_read_chunk_s(fd_description, &file_key))
+	{
+		elog(WARNING, "failed to read hash key from file");
+		return false;
+	}
+
+	/* Check key consistency */
+	if (file_key.kind != key->kind ||
+		file_key.dboid != key->dboid ||
+		file_key.objid != key->objid)
+	{
+		elog(WARNING, "found entry key %u/%u/%" PRIu64 " not matching with %u/%u/%" PRIu64,
+			 file_key.kind, file_key.dboid, file_key.objid,
+			 key->kind, key->dboid, key->objid);
+		return false;
+	}
+
+	entry = (PgStatShared_CustomVarEntry *) header;
+
+	/* Read the description length and its data */
+	if (!pgstat_read_chunk_s(fd_description, &len))
+	{
+		elog(WARNING, "failed to read metadata length from statistics file");
+		return false;
+	}
+
+	/* Handle empty descriptions */
+	if (len == 0)
+	{
+		entry->description = InvalidDsaPointer;
+		return true;
+	}
+
+	/* Initialize DSA if needed */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	if (!custom_stats_description_dsa)
+	{
+		elog(WARNING, "could not access DSA for custom statistics descriptions");
+		return false;
+	}
+
+	buffer = palloc(len);
+	if (!pgstat_read_chunk(fd_description, buffer, len))
+	{
+		pfree(buffer);
+		elog(WARNING, "failed to read description from file");
+		return false;
+	}
+
+	/* Allocate space in DSA and copy the description */
+	dp = dsa_allocate(custom_stats_description_dsa, len);
+	memcpy(dsa_get_address(custom_stats_description_dsa, dp), buffer, len);
+	entry->description = dp;
+	pfree(buffer);
+
+	return true;
+}
+
+/*
+ * test_custom_stats_var_finish() -
+ *
+ * Cleanup function called at the end of statistics file operations.
+ * Handles closing files and cleanup based on the operation type.
+ */
+static void
+test_custom_stats_var_finish(PgStat_StatsFileOp status)
+{
+	switch (status)
+	{
+		case STATS_WRITE:
+			if (!fd_description)
+				return;
+
+			fd_description_offset = 0;
+
+			/* Check for write errors and cleanup if necessary */
+			if (ferror(fd_description))
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not write statistics file \"%s\": %m",
+								TEST_CUSTOM_EXTRA_DATA_DESC)));
+				FreeFile(fd_description);
+				unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+			}
+			else if (FreeFile(fd_description) < 0)
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not close statistics file \"%s\": %m",
+								TEST_CUSTOM_EXTRA_DATA_DESC)));
+				unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+			}
+			break;
+
+		case STATS_READ:
+			if (fd_description)
+				FreeFile(fd_description);
+
+			/* Remove the temporary statistics file after reading */
+			elog(DEBUG2, "removing statistics file \"%s\"", TEST_CUSTOM_EXTRA_DATA_DESC);
+			unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+			break;
+
+		case STATS_DISCARD:
+			{
+				int			ret;
+
+				/* Attempt to remove the statistics file */
+				ret = unlink(TEST_CUSTOM_EXTRA_DATA_DESC);
+				if (ret != 0)
+				{
+					if (errno == ENOENT)
+						elog(LOG,
+							 "didn't need to unlink permanent stats file \"%s\" - didn't exist",
+							 TEST_CUSTOM_EXTRA_DATA_DESC);
+					else
+						ereport(LOG,
+								(errcode_for_file_access(),
+								 errmsg("could not unlink permanent statistics file \"%s\": %m",
+										TEST_CUSTOM_EXTRA_DATA_DESC)));
+				}
+				else
+				{
+					ereport(LOG,
+							(errmsg_internal("unlinked permanent statistics file \"%s\"",
+											 TEST_CUSTOM_EXTRA_DATA_DESC)));
+				}
+			}
+			break;
+	}
+
+	fd_description = NULL;
+}
+
 /*--------------------------------------------------------------------------
  * Helper functions
  *--------------------------------------------------------------------------
@@ -162,8 +502,7 @@ test_custom_stats_var_fetch_entry(const char *stat_name)
  * test_custom_stats_var_create
  *		Create new custom statistic entry
  *
- * Initializes a zero-valued statistics entry in shared memory.
- * Validates name length against NAMEDATALEN limit.
+ * Initializes a statistics entry with the given name and description.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_create);
 Datum
@@ -172,6 +511,9 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 	PgStat_EntryRef *entry_ref;
 	PgStatShared_CustomVarEntry *shared_entry;
 	char	   *stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	char	   *description = text_to_cstring(PG_GETARG_TEXT_PP(1));
+	dsa_pointer dp = InvalidDsaPointer;
+	bool		found;
 
 	/* Validate name length first */
 	if (strlen(stat_name) >= NAMEDATALEN)
@@ -180,6 +522,20 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 				 errmsg("custom statistic name \"%s\" is too long", stat_name),
 				 errdetail("Name must be less than %d characters.", NAMEDATALEN)));
 
+	/* Initialize DSA and description provided */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	if (!custom_stats_description_dsa)
+		ereport(ERROR,
+				(errmsg("could not access DSA for custom statistics descriptions")));
+
+	/* Allocate space in DSA and copy description */
+	dp = dsa_allocate(custom_stats_description_dsa, strlen(description) + 1);
+	memcpy(dsa_get_address(custom_stats_description_dsa, dp),
+		   description,
+		   strlen(description) + 1);
+
 	/* Create or get existing entry */
 	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
 											PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), true);
@@ -192,6 +548,9 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 	/* Zero-initialize statistics */
 	memset(&shared_entry->stats, 0, sizeof(shared_entry->stats));
 
+	/* Store description pointer */
+	shared_entry->description = dp;
+
 	pgstat_unlock_entry(entry_ref);
 
 	PG_RETURN_VOID();
@@ -226,8 +585,7 @@ test_custom_stats_var_update(PG_FUNCTION_ARGS)
  * test_custom_stats_var_drop
  *		Remove custom statistic entry
  *
- * Drops the named statistic from shared memory and requests
- * garbage collection if needed.
+ * Drops the named statistic from shared memory.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_drop);
 Datum
@@ -247,7 +605,7 @@ test_custom_stats_var_drop(PG_FUNCTION_ARGS)
  * test_custom_stats_var_report
  *		Retrieve custom statistic values
  *
- * Returns single row with statistic name and call count if the
+ * Returns single row with statistic name, call count, and description if the
  * statistic exists, otherwise returns no rows.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_report);
@@ -281,9 +639,13 @@ test_custom_stats_var_report(PG_FUNCTION_ARGS)
 
 	if (funcctx->call_cntr < funcctx->max_calls)
 	{
-		Datum		values[2];
-		bool		nulls[2] = {false, false};
+		Datum		values[3];
+		bool		nulls[3] = {false, false, false};
 		HeapTuple	tuple;
+		PgStat_EntryRef *entry_ref;
+		PgStatShared_CustomVarEntry *shared_entry;
+		char	   *description = NULL;
+		bool		found;
 
 		stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
 		stat_entry = test_custom_stats_var_fetch_entry(stat_name);
@@ -291,9 +653,33 @@ test_custom_stats_var_report(PG_FUNCTION_ARGS)
 		/* Return row only if entry exists */
 		if (stat_entry)
 		{
+			/* Get entry ref to access shared entry */
+			entry_ref = pgstat_get_entry_ref(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
+											 PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), false, NULL);
+
+			if (entry_ref)
+			{
+				shared_entry = (PgStatShared_CustomVarEntry *) entry_ref->shared_stats;
+
+				/* Get description from DSA if available */
+				if (DsaPointerIsValid(shared_entry->description))
+				{
+					if (!custom_stats_description_dsa)
+						custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+					if (custom_stats_description_dsa)
+						description = dsa_get_address(custom_stats_description_dsa, shared_entry->description);
+				}
+			}
+
 			values[0] = PointerGetDatum(cstring_to_text(stat_name));
 			values[1] = Int64GetDatum(stat_entry->numcalls);
 
+			if (description)
+				values[2] = PointerGetDatum(cstring_to_text(description));
+			else
+				nulls[2] = true;
+
 			tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
 			SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
 		}
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 9dd65b102544..14f7f8217491 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2269,6 +2269,7 @@ PgStat_StatFuncEntry
 PgStat_StatReplSlotEntry
 PgStat_StatSubEntry
 PgStat_StatTabEntry
+PgStat_StatsFileOp
 PgStat_SubXactStatus
 PgStat_TableCounts
 PgStat_TableStatus
-- 
2.51.0

#38Sami Imseih
samimseih@gmail.com
In reply to: Michael Paquier (#37)
1 attachment(s)
Re: [Proposal] Adding callback support for custom statistics kinds

Thanks for the updates!

- Less fwrite() and fread(), more read_chunk() and write_chunk(). We
are exposing these APIs, let's use them.

oops. That totally slipped my mind :( sorry about that.

- The callbacks are renamed, to be more generic: "finish" for the
end-of-operation actions and to/from_serialized_data.

At first I wasn’t a fan of the name “finish” for the callback.
I was thinking of calling it “finish_auxiliary”. But, we’re not
forcing callbacks to be used together, and there could perhaps
be cases where “finish" can be used on its own, so this is fine by me.

I made some changes as well, in v8:

1/ looks like b4cbc106a6ce snuck into v7. I fixed that.

2/ After looking this over, I realized that “extra” and “auxiliary”
were being used interchangeably. To avoid confusion, I replaced all
instances of “extra” with “auxiliary" in both the comments and
macros, i.e. TEST_CUSTOM_AUX_DATA_DESC

--
Sami Imseih
Amazon Web Services (AWS)

Attachments:

v8-0001-Allow-cumulative-statistics-to-serialize-auxiliar.patchapplication/octet-stream; name=v8-0001-Allow-cumulative-statistics-to-serialize-auxiliar.patchDownload
From 0d9161a73f330e6f54157eb7548aa1cbc4aded55 Mon Sep 17 00:00:00 2001
From: Michael Paquier <michael@paquier.xyz>
Date: Fri, 12 Dec 2025 18:38:05 +0900
Subject: [PATCH v8 1/1] Allow cumulative statistics to serialize auxiliary
 data to disk.

Cumulative Statistics kinds can now write additional per-entry data to
the statistics file that doesn't fit in shared memory. This is useful
for statistics with variable-length auxiliary data.

Three new optional callbacks are added to PgStat_KindInfo:

* to_serialized_data: writes auxiliary data for an entry
* from_serialized_data: reads auxiliary data for an entry
* finish: performs actions after read/write/discard operations.

The finish callback is invoked after processing all entries of a kind,
allowing extensions to close file handles and clean up resources.

Tests are also added to test_custom_stats.pl

Discussion: https://postgr.es/m/CAA5RZ0s9SDOu+Z6veoJCHWk+kDeTktAtC-KY9fQ9Z6BJdDUirQ@mail.gmail.com
---
 src/backend/utils/activity/pgstat.c           |  50 ++-
 src/include/utils/pgstat_internal.h           |  47 ++
 .../test_custom_stats/t/001_custom_stats.pl   |  39 +-
 .../test_custom_var_stats--1.0.sql            |   7 +-
 .../test_custom_stats/test_custom_var_stats.c | 401 +++++++++++++++++-
 src/tools/pgindent/typedefs.list              |   1 +
 6 files changed, 524 insertions(+), 21 deletions(-)

diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index 8713c7a0483..c800e0bda48 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -523,6 +523,7 @@ pgstat_discard_stats(void)
 
 	/* NB: this needs to be done even in single user mode */
 
+	/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
 	ret = unlink(PGSTAT_STAT_PERMANENT_FILENAME);
 	if (ret != 0)
 	{
@@ -544,6 +545,15 @@ pgstat_discard_stats(void)
 								 PGSTAT_STAT_PERMANENT_FILENAME)));
 	}
 
+	/* Finish callbacks, if required */
+	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->finish)
+			kind_info->finish(STATS_DISCARD);
+	}
+
 	/*
 	 * Reset stats contents. This will set reset timestamps of fixed-numbered
 	 * stats to the current time (no variable stats exist).
@@ -1702,6 +1712,10 @@ pgstat_write_statsfile(void)
 		pgstat_write_chunk(fpout,
 						   pgstat_get_entry_data(ps->key.kind, shstats),
 						   pgstat_get_entry_len(ps->key.kind));
+
+		/* Write more data for the entry, if required */
+		if (kind_info->to_serialized_data)
+			kind_info->to_serialized_data(&ps->key, shstats, fpout);
 	}
 	dshash_seq_term(&hstat);
 
@@ -1734,6 +1748,15 @@ pgstat_write_statsfile(void)
 		/* durable_rename already emitted log message */
 		unlink(tmpfile);
 	}
+
+	/* Now, allow stats kinds to finalize the data writes */
+	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->finish)
+			kind_info->finish(STATS_WRITE);
+	}
 }
 
 /* helper for pgstat_read_statsfile() */
@@ -1871,6 +1894,7 @@ pgstat_read_statsfile(void)
 					PgStat_HashKey key;
 					PgStatShared_HashEntry *p;
 					PgStatShared_Common *header;
+					const PgStat_KindInfo *kind_info = NULL;
 
 					CHECK_FOR_INTERRUPTS();
 
@@ -1891,7 +1915,8 @@ pgstat_read_statsfile(void)
 							goto error;
 						}
 
-						if (!pgstat_get_kind_info(key.kind))
+						kind_info = pgstat_get_kind_info(key.kind);
+						if (!kind_info)
 						{
 							elog(WARNING, "could not find information of kind for entry %u/%u/%" PRIu64 " of type %c",
 								 key.kind, key.dboid,
@@ -1902,7 +1927,6 @@ pgstat_read_statsfile(void)
 					else
 					{
 						/* stats entry identified by name on disk (e.g. slots) */
-						const PgStat_KindInfo *kind_info = NULL;
 						PgStat_Kind kind;
 						NameData	name;
 
@@ -1996,6 +2020,18 @@ pgstat_read_statsfile(void)
 						goto error;
 					}
 
+					/* read more data for the entry, if required */
+					if (kind_info->from_serialized_data)
+					{
+						if (!kind_info->from_serialized_data(&key, header, fpin))
+						{
+							elog(WARNING, "could not read auxiliary data for entry %u/%u/%" PRIu64 " of type %c",
+								 key.kind, key.dboid,
+								 key.objid, t);
+							goto error;
+						}
+					}
+
 					break;
 				}
 			case PGSTAT_FILE_ENTRY_END:
@@ -2019,11 +2055,21 @@ pgstat_read_statsfile(void)
 	}
 
 done:
+	/* First, cleanup the main stats file, PGSTAT_STAT_PERMANENT_FILENAME */
 	FreeFile(fpin);
 
 	elog(DEBUG2, "removing permanent stats file \"%s\"", statfile);
 	unlink(statfile);
 
+	/* Let each stats kind run its finish callback, if required */
+	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->finish)
+			kind_info->finish(STATS_READ);
+	}
+
 	return;
 
 error:
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index ca1ba6420ca..9ff1bc3f4ff 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -63,6 +63,21 @@ typedef struct PgStat_HashKey
 								 * identifier. */
 } PgStat_HashKey;
 
+/*
+ * Tracks if the stats file is being read, written or discarded, used in
+ * combination with the finish callback.
+ *
+ * These states allow plugins that create auxiliary data files
+ * to determine the current operation and perform any necessary
+ * file cleanup.
+ */
+typedef enum PgStat_StatsFileOp
+{
+	STATS_WRITE,
+	STATS_READ,
+	STATS_DISCARD,
+} PgStat_StatsFileOp;
+
 /*
  * PgStat_HashKey should not have any padding.  Checking that the structure
  * size matches with the sum of each field is a check simple enough to
@@ -303,6 +318,38 @@ typedef struct PgStat_KindInfo
 									   const PgStatShared_Common *header, NameData *name);
 	bool		(*from_serialized_name) (const NameData *name, PgStat_HashKey *key);
 
+	/*
+	 * For variable-numbered stats: read or write additional data related to a
+	 * given entry, in the stats file or optionally in a different file.
+	 * Optional.
+	 *
+	 * to_serialized_data: write auxiliary data for an entry.
+	 *
+	 * from_serialized_data: read auxiliary data for an entry.  Returns true
+	 * on success, false on read error.
+	 *
+	 * "statfile" is a pointer to the on-disk stats file, named
+	 * PGSTAT_STAT_PERMANENT_FILENAME.  "key" is the hash key of the entry
+	 * just written or read.  "header" is a pointer to the stats data.
+	 */
+	void		(*to_serialized_data) (const PgStat_HashKey *key,
+									   const PgStatShared_Common *header,
+									   FILE *statfile);
+	bool		(*from_serialized_data) (const PgStat_HashKey *key,
+										 const PgStatShared_Common *header,
+										 FILE *statfile);
+
+	/*
+	 * For fixed-numbered or variable-numbered statistics.
+	 *
+	 * Perform custom actions when done processing the on-disk stats file
+	 * after all the stats entries have been processed.  Optional.
+	 *
+	 * "status" tracks the operation done for the on-disk stats file (read,
+	 * write, discard).
+	 */
+	void		(*finish) (PgStat_StatsFileOp status);
+
 	/*
 	 * For fixed-numbered statistics: Initialize shared memory state.
 	 *
diff --git a/src/test/modules/test_custom_stats/t/001_custom_stats.pl b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
index e528595cfb0..378ec22bbdf 100644
--- a/src/test/modules/test_custom_stats/t/001_custom_stats.pl
+++ b/src/test/modules/test_custom_stats/t/001_custom_stats.pl
@@ -29,13 +29,13 @@ $node->safe_psql('postgres', q(CREATE EXTENSION test_custom_fixed_stats));
 
 # Create entries for variable-sized stats.
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry1')));
+	q(select test_custom_stats_var_create('entry1', 'Test entry 1')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry2')));
+	q(select test_custom_stats_var_create('entry2', 'Test entry 2')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry3')));
+	q(select test_custom_stats_var_create('entry3', 'Test entry 3')));
 $node->safe_psql('postgres',
-	q(select test_custom_stats_var_create('entry4')));
+	q(select test_custom_stats_var_create('entry4', 'Test entry 4')));
 
 # Update counters: entry1=2, entry2=3, entry3=2, entry4=3, fixed=3
 $node->safe_psql('postgres',
@@ -65,16 +65,28 @@ $node->safe_psql('postgres', q(select test_custom_stats_fixed_update()));
 # Test data reports.
 my $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry1')));
-is($result, "entry1|2", "report for variable-sized data of entry1");
+is( $result,
+	"entry1|2|Test entry 1",
+	"report for variable-sized data of entry1");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry2')));
-is($result, "entry2|3", "report for variable-sized data of entry2");
+is( $result,
+	"entry2|3|Test entry 2",
+	"report for variable-sized data of entry2");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry3')));
-is($result, "entry3|2", "report for variable-sized data of entry3");
+is( $result,
+	"entry3|2|Test entry 3",
+	"report for variable-sized data of entry3");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry4')));
-is($result, "entry4|3", "report for variable-sized data of entry4");
+is( $result,
+	"entry4|3|Test entry 4",
+	"report for variable-sized data of entry4");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_fixed_report()));
 is($result, "3|", "report for fixed-sized stats");
@@ -97,7 +109,16 @@ $node->start();
 
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_var_report('entry1')));
-is($result, "entry1|2", "variable-sized stats persist after clean restart");
+is( $result,
+	"entry1|2|Test entry 1",
+	"variable-sized stats persist after clean restart");
+
+$result = $node->safe_psql('postgres',
+	q(select * from test_custom_stats_var_report('entry2')));
+is( $result,
+	"entry2|3|Test entry 2",
+	"variable-sized stats persist after clean restart");
+
 $result = $node->safe_psql('postgres',
 	q(select * from test_custom_stats_fixed_report()));
 is($result, "3|", "fixed-sized stats persist after clean restart");
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
index d5f82b5d546..5ed8cfc2dcf 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats--1.0.sql
@@ -3,7 +3,7 @@
 -- complain if script is sourced in psql, rather than via CREATE EXTENSION
 \echo Use "CREATE EXTENSION test_custom_var_stats" to load this file. \quit
 
-CREATE FUNCTION test_custom_stats_var_create(IN name TEXT)
+CREATE FUNCTION test_custom_stats_var_create(IN name TEXT, in description TEXT)
 RETURNS void
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_create'
 LANGUAGE C STRICT PARALLEL UNSAFE;
@@ -18,8 +18,9 @@ RETURNS void
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_drop'
 LANGUAGE C STRICT PARALLEL UNSAFE;
 
-
-CREATE FUNCTION test_custom_stats_var_report(INOUT name TEXT, OUT calls BIGINT)
+CREATE FUNCTION test_custom_stats_var_report(INOUT name TEXT,
+                                             OUT calls BIGINT,
+                                             OUT description TEXT)
 RETURNS SETOF record
 AS 'MODULE_PATHNAME', 'test_custom_stats_var_report'
 LANGUAGE C STRICT PARALLEL UNSAFE;
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats.c b/src/test/modules/test_custom_stats/test_custom_var_stats.c
index d4905ab4ee9..7bfe3ff1cbd 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats.c
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats.c
@@ -14,6 +14,7 @@
 
 #include "common/hashfn.h"
 #include "funcapi.h"
+#include "storage/dsm_registry.h"
 #include "utils/builtins.h"
 #include "utils/pgstat_internal.h"
 
@@ -22,6 +23,8 @@ PG_MODULE_MAGIC_EXT(
 					.version = PG_VERSION
 );
 
+#define TEST_CUSTOM_VAR_MAGIC_NUMBER (0xBEEFBEEF)
+
 /*--------------------------------------------------------------------------
  * Macros and constants
  *--------------------------------------------------------------------------
@@ -32,6 +35,9 @@ PG_MODULE_MAGIC_EXT(
  */
 #define PGSTAT_KIND_TEST_CUSTOM_VAR_STATS 25
 
+/* File paths for auxiliary data serialization */
+#define TEST_CUSTOM_AUX_DATA_DESC "pg_stat/test_custom_var_stats_desc.stats"
+
 /*
  * Hash statistic name to generate entry index for pgstat lookup.
  */
@@ -53,8 +59,23 @@ typedef struct PgStatShared_CustomVarEntry
 {
 	PgStatShared_Common header; /* standard pgstat entry header */
 	PgStat_StatCustomVarEntry stats;	/* custom statistics data */
+	dsa_pointer description;	/* pointer to description string in DSA */
 } PgStatShared_CustomVarEntry;
 
+/*--------------------------------------------------------------------------
+ * Global Variables
+ *--------------------------------------------------------------------------
+ */
+
+/* File handle for auxiliary data serialization */
+static FILE *fd_description = NULL;
+
+/* Current write offset in fd_description file */
+static long fd_description_offset = 0;
+
+/* DSA area for storing variable-length description strings */
+static dsa_area *custom_stats_description_dsa = NULL;
+
 /*--------------------------------------------------------------------------
  * Function prototypes
  *--------------------------------------------------------------------------
@@ -64,6 +85,19 @@ typedef struct PgStatShared_CustomVarEntry
 static bool test_custom_stats_var_flush_pending_cb(PgStat_EntryRef *entry_ref,
 												   bool nowait);
 
+/* Serialization callback: serialize auxiliary entry data */
+static void test_custom_stats_var_to_serialized_data(const PgStat_HashKey *key,
+													 const PgStatShared_Common *header,
+													 FILE *statfile);
+
+/* Deserialization callback: deserialize auxiliary entry data */
+static bool test_custom_stats_var_from_serialized_data(const PgStat_HashKey *key,
+													   const PgStatShared_Common *header,
+													   FILE *statfile);
+
+/* Cleanup callback: end of statistics file operations */
+static void test_custom_stats_var_finish(PgStat_StatsFileOp status);
+
 /*--------------------------------------------------------------------------
  * Custom kind configuration
  *--------------------------------------------------------------------------
@@ -80,6 +114,9 @@ static const PgStat_KindInfo custom_stats = {
 	.shared_data_len = sizeof(((PgStatShared_CustomVarEntry *) 0)->stats),
 	.pending_size = sizeof(PgStat_StatCustomVarEntry),
 	.flush_pending_cb = test_custom_stats_var_flush_pending_cb,
+	.to_serialized_data = test_custom_stats_var_to_serialized_data,
+	.from_serialized_data = test_custom_stats_var_from_serialized_data,
+	.finish = test_custom_stats_var_finish,
 };
 
 /*--------------------------------------------------------------------------
@@ -132,6 +169,310 @@ test_custom_stats_var_flush_pending_cb(PgStat_EntryRef *entry_ref, bool nowait)
 	return true;
 }
 
+/*
+ * test_custom_stats_var_to_serialized_data() -
+ *
+ * Serialize auxiliary data (descriptions) for custom statistics entries
+ * to a secondary statistics file. This is called while writing the statistics
+ * to disk.
+ *
+ * This callback writes a mix of data within the main pgstats file and a
+ * secondary statistics file.  The following data is written to the main file for
+ * each entry:
+ * - An arbitrary magic number.
+ * - An offset.  This is used to know the location we need to look at
+ * to retrieve the information from the second file.
+ *
+ * The following data is written to the secondary statistics file:
+ * - The entry key, cross-checked with the data from the main file
+ * when reloaded.
+ * - The length of the description.
+ * - The description data itself.
+ */
+static void
+test_custom_stats_var_to_serialized_data(const PgStat_HashKey *key,
+										 const PgStatShared_Common *header,
+										 FILE *statfile)
+{
+	char	   *description;
+	size_t		len;
+	PgStatShared_CustomVarEntry *entry = (PgStatShared_CustomVarEntry *) header;
+	bool		found;
+	uint32		magic_number = TEST_CUSTOM_VAR_MAGIC_NUMBER;
+
+	/*
+	 * First mark the main file with a magic number, keeping a trace that some
+	 * auxiliary data will exist in the secondary statistics file.
+	 */
+	pgstat_write_chunk_s(statfile, &magic_number);
+
+	/* Open statistics file for writing. */
+	if (!fd_description)
+	{
+		fd_description = AllocateFile(TEST_CUSTOM_AUX_DATA_DESC, PG_BINARY_W);
+		if (fd_description == NULL)
+		{
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not open statistics file \"%s\" for writing: %m",
+							TEST_CUSTOM_AUX_DATA_DESC)));
+			return;
+		}
+
+		/* Initialize offset for secondary statistics file. */
+		fd_description_offset = 0;
+	}
+
+	/* Write offset to the main data file */
+	pgstat_write_chunk_s(statfile, &fd_description_offset);
+
+	/*
+	 * First write the entry key to the secondary statistics file.  This will
+	 * be cross-checked with the key read from main stats file at loading
+	 * time.
+	 */
+	pgstat_write_chunk_s(fd_description, (PgStat_HashKey *) key);
+	fd_description_offset += sizeof(PgStat_HashKey);
+
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	/* Handle entries without descriptions */
+	if (!DsaPointerIsValid(entry->description) || !custom_stats_description_dsa)
+	{
+		/* length to description file */
+		len = 0;
+		pgstat_write_chunk_s(fd_description, &len);
+		fd_description_offset += sizeof(size_t);
+		return;
+	}
+
+	/*
+	 * Retrieve description from DSA, then write the length followed by the
+	 * description.
+	 */
+	description = dsa_get_address(custom_stats_description_dsa,
+								  entry->description);
+	len = strlen(description) + 1;
+	pgstat_write_chunk_s(fd_description, &len);
+	pgstat_write_chunk(fd_description, description, len);
+
+	/*
+	 * Update offset for next entry, counting for the length (size_t) of the
+	 * description and the description contents.
+	 */
+	fd_description_offset += len + sizeof(size_t);
+}
+
+/*
+ * test_custom_stats_var_from_serialized_data() -
+ *
+ * Read auxiliary data (descriptions) for custom statistics entries from
+ * the secondary statistics file.  This is called while loading the statistics
+ * at startup.
+ *
+ * See the top of test_custom_stats_var_to_serialized_data() for a
+ * detailed description of the data layout read here.
+ */
+static bool
+test_custom_stats_var_from_serialized_data(const PgStat_HashKey *key,
+										   const PgStatShared_Common *header,
+										   FILE *statfile)
+{
+	PgStatShared_CustomVarEntry *entry;
+	dsa_pointer dp;
+	size_t		len;
+	long		offset;
+	char	   *buffer;
+	bool		found;
+	uint32		magic_number = 0;
+	PgStat_HashKey file_key;
+
+	/* Check the magic number first, in the main file. */
+	if (!pgstat_read_chunk_s(statfile, &magic_number))
+	{
+		elog(WARNING, "failed to read magic number from statistics file");
+		return false;
+	}
+
+	if (magic_number != TEST_CUSTOM_VAR_MAGIC_NUMBER)
+	{
+		elog(WARNING, "found magic number %u from statistics file, should be %u",
+			 magic_number, TEST_CUSTOM_VAR_MAGIC_NUMBER);
+		return false;
+	}
+
+	/*
+	 * Read the offset from the main stats file, to be able to read the
+	 * auxiliary data from the secondary statistics file.
+	 */
+	if (!pgstat_read_chunk_s(statfile, &offset))
+	{
+		elog(WARNING, "failed to read metadata offset from statistics file");
+		return false;
+	}
+
+	/* Open statistics file for reading if not already open */
+	if (!fd_description)
+	{
+		fd_description = AllocateFile(TEST_CUSTOM_AUX_DATA_DESC, PG_BINARY_R);
+		if (fd_description == NULL)
+		{
+			if (errno != ENOENT)
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not open statistics file \"%s\" for reading: %m",
+								TEST_CUSTOM_AUX_DATA_DESC)));
+			pgstat_reset_of_kind(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS);
+			return false;
+		}
+	}
+
+	/* Read data from the secondary statistics file, at the specified offset */
+	if (fseek(fd_description, offset, SEEK_SET) != 0)
+	{
+		elog(WARNING, "failed to seek to offset %ld in description file", offset);
+		return false;
+	}
+
+	/* Read the hash key from the secondary statistics file */
+	if (!pgstat_read_chunk_s(fd_description, &file_key))
+	{
+		elog(WARNING, "failed to read hash key from file");
+		return false;
+	}
+
+	/* Check key consistency */
+	if (file_key.kind != key->kind ||
+		file_key.dboid != key->dboid ||
+		file_key.objid != key->objid)
+	{
+		elog(WARNING, "found entry key %u/%u/%" PRIu64 " not matching with %u/%u/%" PRIu64,
+			 file_key.kind, file_key.dboid, file_key.objid,
+			 key->kind, key->dboid, key->objid);
+		return false;
+	}
+
+	entry = (PgStatShared_CustomVarEntry *) header;
+
+	/* Read the description length and its data */
+	if (!pgstat_read_chunk_s(fd_description, &len))
+	{
+		elog(WARNING, "failed to read metadata length from statistics file");
+		return false;
+	}
+
+	/* Handle empty descriptions */
+	if (len == 0)
+	{
+		entry->description = InvalidDsaPointer;
+		return true;
+	}
+
+	/* Initialize DSA if needed */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	if (!custom_stats_description_dsa)
+	{
+		elog(WARNING, "could not access DSA for custom statistics descriptions");
+		return false;
+	}
+
+	buffer = palloc(len);
+	if (!pgstat_read_chunk(fd_description, buffer, len))
+	{
+		pfree(buffer);
+		elog(WARNING, "failed to read description from file");
+		return false;
+	}
+
+	/* Allocate space in DSA and copy the description */
+	dp = dsa_allocate(custom_stats_description_dsa, len);
+	memcpy(dsa_get_address(custom_stats_description_dsa, dp), buffer, len);
+	entry->description = dp;
+	pfree(buffer);
+
+	return true;
+}
+
+/*
+ * test_custom_stats_var_finish() -
+ *
+ * Cleanup function called at the end of statistics file operations.
+ * Handles closing files and cleanup based on the operation type.
+ */
+static void
+test_custom_stats_var_finish(PgStat_StatsFileOp status)
+{
+	switch (status)
+	{
+		case STATS_WRITE:
+			if (!fd_description)
+				return;
+
+			fd_description_offset = 0;
+
+			/* Check for write errors and cleanup if necessary */
+			if (ferror(fd_description))
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not write statistics file \"%s\": %m",
+								TEST_CUSTOM_AUX_DATA_DESC)));
+				FreeFile(fd_description);
+				unlink(TEST_CUSTOM_AUX_DATA_DESC);
+			}
+			else if (FreeFile(fd_description) < 0)
+			{
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not close statistics file \"%s\": %m",
+								TEST_CUSTOM_AUX_DATA_DESC)));
+				unlink(TEST_CUSTOM_AUX_DATA_DESC);
+			}
+			break;
+
+		case STATS_READ:
+			if (fd_description)
+				FreeFile(fd_description);
+
+			/* Remove the temporary statistics file after reading */
+			elog(DEBUG2, "removing statistics file \"%s\"", TEST_CUSTOM_AUX_DATA_DESC);
+			unlink(TEST_CUSTOM_AUX_DATA_DESC);
+			break;
+
+		case STATS_DISCARD:
+			{
+				int			ret;
+
+				/* Attempt to remove the statistics file */
+				ret = unlink(TEST_CUSTOM_AUX_DATA_DESC);
+				if (ret != 0)
+				{
+					if (errno == ENOENT)
+						elog(LOG,
+							 "didn't need to unlink permanent stats file \"%s\" - didn't exist",
+							 TEST_CUSTOM_AUX_DATA_DESC);
+					else
+						ereport(LOG,
+								(errcode_for_file_access(),
+								 errmsg("could not unlink permanent statistics file \"%s\": %m",
+										TEST_CUSTOM_AUX_DATA_DESC)));
+				}
+				else
+				{
+					ereport(LOG,
+							(errmsg_internal("unlinked permanent statistics file \"%s\"",
+											 TEST_CUSTOM_AUX_DATA_DESC)));
+				}
+			}
+			break;
+	}
+
+	fd_description = NULL;
+}
+
 /*--------------------------------------------------------------------------
  * Helper functions
  *--------------------------------------------------------------------------
@@ -162,8 +503,7 @@ test_custom_stats_var_fetch_entry(const char *stat_name)
  * test_custom_stats_var_create
  *		Create new custom statistic entry
  *
- * Initializes a zero-valued statistics entry in shared memory.
- * Validates name length against NAMEDATALEN limit.
+ * Initializes a statistics entry with the given name and description.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_create);
 Datum
@@ -172,6 +512,9 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 	PgStat_EntryRef *entry_ref;
 	PgStatShared_CustomVarEntry *shared_entry;
 	char	   *stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	char	   *description = text_to_cstring(PG_GETARG_TEXT_PP(1));
+	dsa_pointer dp = InvalidDsaPointer;
+	bool		found;
 
 	/* Validate name length first */
 	if (strlen(stat_name) >= NAMEDATALEN)
@@ -180,6 +523,20 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 				 errmsg("custom statistic name \"%s\" is too long", stat_name),
 				 errdetail("Name must be less than %d characters.", NAMEDATALEN)));
 
+	/* Initialize DSA and description provided */
+	if (!custom_stats_description_dsa)
+		custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+	if (!custom_stats_description_dsa)
+		ereport(ERROR,
+				(errmsg("could not access DSA for custom statistics descriptions")));
+
+	/* Allocate space in DSA and copy description */
+	dp = dsa_allocate(custom_stats_description_dsa, strlen(description) + 1);
+	memcpy(dsa_get_address(custom_stats_description_dsa, dp),
+		   description,
+		   strlen(description) + 1);
+
 	/* Create or get existing entry */
 	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
 											PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), true);
@@ -192,6 +549,9 @@ test_custom_stats_var_create(PG_FUNCTION_ARGS)
 	/* Zero-initialize statistics */
 	memset(&shared_entry->stats, 0, sizeof(shared_entry->stats));
 
+	/* Store description pointer */
+	shared_entry->description = dp;
+
 	pgstat_unlock_entry(entry_ref);
 
 	PG_RETURN_VOID();
@@ -226,8 +586,7 @@ test_custom_stats_var_update(PG_FUNCTION_ARGS)
  * test_custom_stats_var_drop
  *		Remove custom statistic entry
  *
- * Drops the named statistic from shared memory and requests
- * garbage collection if needed.
+ * Drops the named statistic from shared memory.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_drop);
 Datum
@@ -247,7 +606,7 @@ test_custom_stats_var_drop(PG_FUNCTION_ARGS)
  * test_custom_stats_var_report
  *		Retrieve custom statistic values
  *
- * Returns single row with statistic name and call count if the
+ * Returns single row with statistic name, call count, and description if the
  * statistic exists, otherwise returns no rows.
  */
 PG_FUNCTION_INFO_V1(test_custom_stats_var_report);
@@ -281,9 +640,13 @@ test_custom_stats_var_report(PG_FUNCTION_ARGS)
 
 	if (funcctx->call_cntr < funcctx->max_calls)
 	{
-		Datum		values[2];
-		bool		nulls[2] = {false, false};
+		Datum		values[3];
+		bool		nulls[3] = {false, false, false};
 		HeapTuple	tuple;
+		PgStat_EntryRef *entry_ref;
+		PgStatShared_CustomVarEntry *shared_entry;
+		char	   *description = NULL;
+		bool		found;
 
 		stat_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
 		stat_entry = test_custom_stats_var_fetch_entry(stat_name);
@@ -291,9 +654,33 @@ test_custom_stats_var_report(PG_FUNCTION_ARGS)
 		/* Return row only if entry exists */
 		if (stat_entry)
 		{
+			/* Get entry ref to access shared entry */
+			entry_ref = pgstat_get_entry_ref(PGSTAT_KIND_TEST_CUSTOM_VAR_STATS, InvalidOid,
+											 PGSTAT_CUSTOM_VAR_STATS_IDX(stat_name), false, NULL);
+
+			if (entry_ref)
+			{
+				shared_entry = (PgStatShared_CustomVarEntry *) entry_ref->shared_stats;
+
+				/* Get description from DSA if available */
+				if (DsaPointerIsValid(shared_entry->description))
+				{
+					if (!custom_stats_description_dsa)
+						custom_stats_description_dsa = GetNamedDSA("test_custom_stat_dsa", &found);
+
+					if (custom_stats_description_dsa)
+						description = dsa_get_address(custom_stats_description_dsa, shared_entry->description);
+				}
+			}
+
 			values[0] = PointerGetDatum(cstring_to_text(stat_name));
 			values[1] = Int64GetDatum(stat_entry->numcalls);
 
+			if (description)
+				values[2] = PointerGetDatum(cstring_to_text(description));
+			else
+				nulls[2] = true;
+
 			tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
 			SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
 		}
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 9dd65b10254..14f7f821749 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2269,6 +2269,7 @@ PgStat_StatFuncEntry
 PgStat_StatReplSlotEntry
 PgStat_StatSubEntry
 PgStat_StatTabEntry
+PgStat_StatsFileOp
 PgStat_SubXactStatus
 PgStat_TableCounts
 PgStat_TableStatus
-- 
2.43.0

#39Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#38)
Re: [Proposal] Adding callback support for custom statistics kinds

On Fri, Dec 12, 2025 at 06:41:20PM -0600, Sami Imseih wrote:

I made some changes as well, in v8:

1/ looks like b4cbc106a6ce snuck into v7. I fixed that.

Oops, sorry about that. I went one reset too deep.. I can see that
my local branch was also wrong, an a rebase fixed it immediately.

2/ After looking this over, I realized that “extra” and “auxiliary”
were being used interchangeably. To avoid confusion, I replaced all
instances of “extra” with “auxiliary" in both the comments and
macros, i.e. TEST_CUSTOM_AUX_DATA_DESC

I can see what you have changed in v8 compared to v7, in terms of the
elog(), the comments and TEST_CUSTOM_AUX_DATA_DESC. That works for
me. If somebody has a better idea for a name, these can always be
tweaked at will.
--
Michael

#40Sami Imseih
samimseih@gmail.com
In reply to: Michael Paquier (#39)
Re: [Proposal] Adding callback support for custom statistics kinds

I just remembered that we should document the new callbacks in [0]https://www.postgresql.org/docs/current/xfunc-c.html#XFUNC-ADDIN-CUSTOM-CUMULATIVE-STATISTICS with a
brief explanation of their purpose and a reference to test_custom_stats
as an example of usage. What do you think?

[0]: https://www.postgresql.org/docs/current/xfunc-c.html#XFUNC-ADDIN-CUSTOM-CUMULATIVE-STATISTICS
https://www.postgresql.org/docs/current/xfunc-c.html#XFUNC-ADDIN-CUSTOM-CUMULATIVE-STATISTICS

--
Sami Imseih
Amazon Web Services (AWS)

#41Sami Imseih
samimseih@gmail.com
In reply to: Sami Imseih (#40)
Re: [Proposal] Adding callback support for custom statistics kinds

I just remembered that we should document the new callbacks in [0] with a
brief explanation of their purpose and a reference to test_custom_stats
as an example of usage. What do you think?

oh, and I also realized that the documentation was updated incorrectly when
test_custom_stats was originally committed. Thought it was better to fix this
in a separate thread [0]/messages/by-id/CAA5RZ0s4heX926+ZNh63u12gLd9jgauU6yiirKc7xGo1G01PXQ@mail.gmail.com.

[0]: /messages/by-id/CAA5RZ0s4heX926+ZNh63u12gLd9jgauU6yiirKc7xGo1G01PXQ@mail.gmail.com

--
Sami Imseih
Amazon Web Services (AWS)

#42Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#40)
Re: [Proposal] Adding callback support for custom statistics kinds

On Sat, Dec 13, 2025 at 06:33:41PM -0600, Sami Imseih wrote:

I just remembered that we should document the new callbacks in [0] with a
brief explanation of their purpose and a reference to test_custom_stats
as an example of usage. What do you think?

I'd rather keep the documentation simpler, pointing only to the
code templates we have and pgstat_internal.h. One reason is that code
in the documentation tends to rot very easily, particularly when
applied to plugin APIs. If you think that some of the callbacks of
pgstat_internal.h deserve more documentation or explanation, let's do
that directly in the header.

Saying that, I have tweaked a bit more the patch this morning and
applied the result after splitting things in two: one for the core
backend changes and one for the tests of the new APIs. Some comments
and error strings have been simplified and I have noticed some more
inconsistencies after a follow-up read.

Another thing that I did not like is the use of "long" for the offset,
which is not portable. We have a drop-in portable replacement for
seeks and offsets: fseeko() and pgoff_t. That was in the test code,
but still let's keep things more portable in the long run without a
4-byte limitation on WIN32.

I guess that we are done for this thread then.
--
Michael

#43Peter Eisentraut
peter@eisentraut.org
In reply to: Sami Imseih (#38)
Re: [Proposal] Adding callback support for custom statistics kinds

On 13.12.25 01:41, Sami Imseih wrote:

Thanks for the updates!

- Less fwrite() and fread(), more read_chunk() and write_chunk(). We
are exposing these APIs, let's use them.

oops. That totally slipped my mind :( sorry about that.

- The callbacks are renamed, to be more generic: "finish" for the
end-of-operation actions and to/from_serialized_data.

At first I wasn’t a fan of the name “finish” for the callback.
I was thinking of calling it “finish_auxiliary”. But, we’re not
forcing callbacks to be used together, and there could perhaps
be cases where “finish" can be used on its own, so this is fine by me.

I made some changes as well, in v8:

1/ looks like b4cbc106a6ce snuck into v7. I fixed that.

2/ After looking this over, I realized that “extra” and “auxiliary”
were being used interchangeably. To avoid confusion, I replaced all
instances of “extra” with “auxiliary" in both the comments and
macros, i.e. TEST_CUSTOM_AUX_DATA_DESC

The function test_custom_stats_var_from_serialized_data() takes an
argument of type

const PgStatShared_Common *header

which is then later cast

entry = (PgStatShared_CustomVarEntry *) header;

where entry is defined as

PgStatShared_CustomVarEntry *entry;

So you are losing the const qualification here.

But fixing that by adding the const qualification to entry would not
work because what entry points to is later modified:

entry->description = InvalidDsaPointer;

So the header argument of the function should not be const qualified.

But the signature of that function is apparently determined by this new
callbacks API, so it cannot be changed in isolation.

So it seems to me that either the callbacks API needs some adjustments,
or this particular implementation of the callback function is incorrect.

#44Michael Paquier
michael@paquier.xyz
In reply to: Peter Eisentraut (#43)
1 attachment(s)
Re: [Proposal] Adding callback support for custom statistics kinds

On Wed, Dec 17, 2025 at 08:03:36AM +0100, Peter Eisentraut wrote:

So it seems to me that either the callbacks API needs some adjustments, or
this particular implementation of the callback function is incorrect.

Hmm, you are right that this is not aligned. This can be improved
with one change for each callback:
- It is OK with from_serialized_data() to manipulate the header data,
because we want to fill a portion of the shmem data with extra data
read from disk (the module wants to add a reference to a DSA stored in
the shmem entry, read from the second file). So we should discard the
const marker from the callback definition.
- The const usage is OK for to_serialized_data(): it is better to
encourage a policy where the header data cannot be manipulated. So
the const needs to be kept in the definition, but I also think that we
should change the module implementation so as the cast to
PgStatShared_CustomVarEntry is a const.

These changes result in the attached. Sami, what do you think?
--
Michael

Attachments:

custom-stats-const.patchtext/x-diff; charset=us-asciiDownload
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index 5c1ce4d3d6af..01db3b701bdf 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -335,7 +335,7 @@ typedef struct PgStat_KindInfo
 									   const PgStatShared_Common *header,
 									   FILE *statfile);
 	bool		(*from_serialized_data) (const PgStat_HashKey *key,
-										 const PgStatShared_Common *header,
+										 PgStatShared_Common *header,
 										 FILE *statfile);
 
 	/*
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats.c b/src/test/modules/test_custom_stats/test_custom_var_stats.c
index c71922dc4a8f..3dea38e3fe83 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats.c
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats.c
@@ -92,7 +92,7 @@ static void test_custom_stats_var_to_serialized_data(const PgStat_HashKey *key,
 
 /* Deserialization callback: read auxiliary entry data */
 static bool test_custom_stats_var_from_serialized_data(const PgStat_HashKey *key,
-													   const PgStatShared_Common *header,
+													   PgStatShared_Common *header,
 													   FILE *statfile);
 
 /* Finish callback: end of statistics file operations */
@@ -196,9 +196,10 @@ test_custom_stats_var_to_serialized_data(const PgStat_HashKey *key,
 {
 	char	   *description;
 	size_t		len;
-	PgStatShared_CustomVarEntry *entry = (PgStatShared_CustomVarEntry *) header;
 	bool		found;
 	uint32		magic_number = TEST_CUSTOM_VAR_MAGIC_NUMBER;
+	const PgStatShared_CustomVarEntry *entry =
+		(const PgStatShared_CustomVarEntry *) header;
 
 	/*
 	 * First mark the main file with a magic number, keeping a trace that some
@@ -276,7 +277,7 @@ test_custom_stats_var_to_serialized_data(const PgStat_HashKey *key,
  */
 static bool
 test_custom_stats_var_from_serialized_data(const PgStat_HashKey *key,
-										   const PgStatShared_Common *header,
+										   PgStatShared_Common *header,
 										   FILE *statfile)
 {
 	PgStatShared_CustomVarEntry *entry;
#45Sami Imseih
samimseih@gmail.com
In reply to: Michael Paquier (#44)
1 attachment(s)
Re: [Proposal] Adding callback support for custom statistics kinds

On Wed, Dec 17, 2025 at 08:03:36AM +0100, Peter Eisentraut wrote:

So it seems to me that either the callbacks API needs some adjustments, or
this particular implementation of the callback function is incorrect.

Hmm, you are right that this is not aligned. This can be improved
with one change for each callback:
- It is OK with from_serialized_data() to manipulate the header data,
because we want to fill a portion of the shmem data with extra data
read from disk (the module wants to add a reference to a DSA stored in
the shmem entry, read from the second file). So we should discard the
const marker from the callback definition.
- The const usage is OK for to_serialized_data(): it is better to
encourage a policy where the header data cannot be manipulated. So
the const needs to be kept in the definition, but I also think that we
should change the module implementation so as the cast to
PgStatShared_CustomVarEntry is a const.

These changes result in the attached. Sami, what do you think?

I agree. This was a miss during the review. Thanks for raising this.

The fix looks correct to me in which the from_serialized_data callback
is expected to modify the header, to reconstruct the entry and the
to_serialized_data is never expected to modify the header, since we
are only reading what is currently in stats. I can't think of a reason to
ever have to modify the entry while writing out to disk.

I got the attached patch ready with some additional comments in
the callback definitions to clarify the API contract. We only need
to call out the "header' nuance since it's a const in one callback
and not the other. "key" is self documenting being a const in both
cases.

--
Sami Imseih
Amazon Web Services (AWS)

Attachments:

v1-0001-Fix-const-correctness-in-pgstat-serialization-cal.patchapplication/octet-stream; name=v1-0001-Fix-const-correctness-in-pgstat-serialization-cal.patchDownload
From 6cd3a9ad9a31e9d1f8d57815b556642f0a7f2b7d Mon Sep 17 00:00:00 2001
From: Ubuntu <ubuntu@ip-172-31-46-230.ec2.internal>
Date: Wed, 17 Dec 2025 16:23:13 +0000
Subject: [PATCH v1 1/1] Fix const correctness in pgstat serialization
 callbacks

4ba012a8ed9c defined the header in from_serialized_data as const, even
though the callback may modify it when reconstructing entry state.
Also update the to_serialized_data callback in test_custom_stats to
make the header parameter const since it should not be modified.

This eliminates unsafe const casts and clarifies the API contract.

Reported-By: Peter Eisentraut <peter@eisentraut.org>
Author: Michael Paquier <michael@paquier.xyz>
Reviewed-by: Sami Imseih <samimseih@gmail.com>
Discussion: https://postgr.es/m/d87a93b0-19c7-4db6-b9c0-d6827e7b2da1%40eisentraut.org
---
 src/include/utils/pgstat_internal.h                        | 5 +++--
 src/test/modules/test_custom_stats/test_custom_var_stats.c | 6 +++---
 2 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index 5c1ce4d3d6a..67f7071fbfd 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -329,13 +329,14 @@ typedef struct PgStat_KindInfo
 	 *
 	 * "statfile" is a pointer to the on-disk stats file, named
 	 * PGSTAT_STAT_PERMANENT_FILENAME.  "key" is the hash key of the entry
-	 * just written or read.  "header" is a pointer to the stats data.
+	 * just written or read.  "header" is a pointer to the stats data; it may
+	 * be modified only in from_serialized_data to reconstruct entry state.
 	 */
 	void		(*to_serialized_data) (const PgStat_HashKey *key,
 									   const PgStatShared_Common *header,
 									   FILE *statfile);
 	bool		(*from_serialized_data) (const PgStat_HashKey *key,
-										 const PgStatShared_Common *header,
+										 PgStatShared_Common *header,
 										 FILE *statfile);
 
 	/*
diff --git a/src/test/modules/test_custom_stats/test_custom_var_stats.c b/src/test/modules/test_custom_stats/test_custom_var_stats.c
index c71922dc4a8..294085d6866 100644
--- a/src/test/modules/test_custom_stats/test_custom_var_stats.c
+++ b/src/test/modules/test_custom_stats/test_custom_var_stats.c
@@ -92,7 +92,7 @@ static void test_custom_stats_var_to_serialized_data(const PgStat_HashKey *key,
 
 /* Deserialization callback: read auxiliary entry data */
 static bool test_custom_stats_var_from_serialized_data(const PgStat_HashKey *key,
-													   const PgStatShared_Common *header,
+													   PgStatShared_Common *header,
 													   FILE *statfile);
 
 /* Finish callback: end of statistics file operations */
@@ -196,7 +196,7 @@ test_custom_stats_var_to_serialized_data(const PgStat_HashKey *key,
 {
 	char	   *description;
 	size_t		len;
-	PgStatShared_CustomVarEntry *entry = (PgStatShared_CustomVarEntry *) header;
+	const PgStatShared_CustomVarEntry *entry = (const PgStatShared_CustomVarEntry *) header;
 	bool		found;
 	uint32		magic_number = TEST_CUSTOM_VAR_MAGIC_NUMBER;
 
@@ -276,7 +276,7 @@ test_custom_stats_var_to_serialized_data(const PgStat_HashKey *key,
  */
 static bool
 test_custom_stats_var_from_serialized_data(const PgStat_HashKey *key,
-										   const PgStatShared_Common *header,
+										   PgStatShared_Common *header,
 										   FILE *statfile)
 {
 	PgStatShared_CustomVarEntry *entry;
-- 
2.43.0

#46Michael Paquier
michael@paquier.xyz
In reply to: Sami Imseih (#45)
Re: [Proposal] Adding callback support for custom statistics kinds

On Wed, Dec 17, 2025 at 11:01:01AM -0600, Sami Imseih wrote:

I got the attached patch ready with some additional comments in
the callback definitions to clarify the API contract. We only need
to call out the "header' nuance since it's a const in one callback
and not the other. "key" is self documenting being a const in both
cases.

The comment sounds like a good idea, so included it and applied the
result. Thanks for looking!
--
Michael