From 202358600db563adc6fdbcd381c0cd4e29547035 Mon Sep 17 00:00:00 2001
From: Shinya Kato <shinya11.kato@gmail.com>
Date: Fri, 31 Jul 2026 23:21:14 +0900
Subject: [PATCH v2] Add TOAST statistics columns to pg_stat_all_tables

Statistics for TOAST tables have been visible in pg_stat_all_tables
as separate rows in the pg_toast schema since commit 87808aef05c, but
there has been no way to reach them from the owning table's row.
Worse, pg_stat_user_tables filters out the pg_toast schema entirely,
so monitoring based on that view cannot see TOAST bloat at all.
Since autovacuum processes a TOAST table independently of its main
table, a table can look perfectly healthy (n_dead_tup = 0, recently
autovacuumed) while its TOAST table has accumulated a large amount of
dead tuples.

Expose the linkage on the owning table's row, following the precedent
of the toast_blks_read and toast_blks_hit columns in
pg_statio_all_tables: add toast_relid, toast_n_dead_tup,
toast_last_autovacuum, and toast_autovacuum_count columns.  They are
computed by passing the TOAST table's OID to the existing statistics
access functions, so no new counters or functions are introduced.
Tables without a TOAST table show NULL.

The TOAST values are deliberately not folded into the main table's
own counters, since TOAST tuples are chunks of long values rather
than user rows and mixing the units would make the numbers
meaningless.

Bump catalog version.

Author: Shinya Kato <shinya11.kato@gmail.com>
Reviewed-by: Shihao Zhong <zhong950419@gmail.com>
Discussion: https://postgr.es/m/CAOzEurTwO+2=geAgAZVha5aTx1dzbi8PN4X9W27KzqiMKvDw1g@mail.gmail.com
---
 doc/src/sgml/monitoring.sgml         | 45 +++++++++++++++++++++++++++
 src/backend/catalog/system_views.sql |  4 +++
 src/test/regress/expected/rules.out  | 12 ++++++++
 src/test/regress/expected/stats.out  | 46 ++++++++++++++++++++++++++++
 src/test/regress/sql/stats.sql       | 37 ++++++++++++++++++++++
 5 files changed, 144 insertions(+)

diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml
index b403fb990a7..56ae3ab7ff8 100644
--- a/doc/src/sgml/monitoring.sgml
+++ b/doc/src/sgml/monitoring.sgml
@@ -4790,6 +4790,51 @@ description | Waiting for a newly initialized WAL file to reach durable storage
       </para></entry>
      </row>
 
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>toast_relid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of this table's <acronym>TOAST</acronym> table, or NULL if none
+       (see <xref linkend="storage-toast"/>)
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>toast_n_dead_tup</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Estimated number of dead tuples in this table's
+       <acronym>TOAST</acronym> table (if any).  Note that
+       <acronym>TOAST</acronym> tuples are chunks of long values, so this
+       is not comparable to the number of dead rows in the table itself.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>toast_last_autovacuum</structfield> <type>timestamp with time zone</type>
+      </para>
+      <para>
+       Last time at which this table's <acronym>TOAST</acronym> table (if
+       any) was vacuumed by the autovacuum daemon.  The autovacuum daemon
+       processes a <acronym>TOAST</acronym> table independently of its main
+       table, so this can differ significantly from
+       <structfield>last_autovacuum</structfield>.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>toast_autovacuum_count</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of times this table's <acronym>TOAST</acronym> table (if any)
+       has been vacuumed by the autovacuum daemon
+      </para></entry>
+     </row>
+
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
         <structfield>stats_reset</structfield> <type>timestamp with time zone</type>
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 8612d99a890..cd549543111 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -747,6 +747,10 @@ CREATE VIEW pg_stat_all_tables AS
             pg_stat_get_total_autovacuum_time(C.oid) AS total_autovacuum_time,
             pg_stat_get_total_analyze_time(C.oid) AS total_analyze_time,
             pg_stat_get_total_autoanalyze_time(C.oid) AS total_autoanalyze_time,
+            NULLIF(C.reltoastrelid, 0) AS toast_relid,
+            pg_stat_get_dead_tuples(NULLIF(C.reltoastrelid, 0)) AS toast_n_dead_tup,
+            pg_stat_get_last_autovacuum_time(NULLIF(C.reltoastrelid, 0)) AS toast_last_autovacuum,
+            pg_stat_get_autovacuum_count(NULLIF(C.reltoastrelid, 0)) AS toast_autovacuum_count,
             pg_stat_get_stat_reset_time(C.oid) AS stats_reset
     FROM pg_class C LEFT JOIN
          pg_index I ON C.oid = I.indrelid
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 1a29d46213e..14df3e6dbbd 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -1846,6 +1846,10 @@ pg_stat_all_tables| SELECT c.oid AS relid,
     pg_stat_get_total_autovacuum_time(c.oid) AS total_autovacuum_time,
     pg_stat_get_total_analyze_time(c.oid) AS total_analyze_time,
     pg_stat_get_total_autoanalyze_time(c.oid) AS total_autoanalyze_time,
+    NULLIF(c.reltoastrelid, (0)::oid) AS toast_relid,
+    pg_stat_get_dead_tuples(NULLIF(c.reltoastrelid, (0)::oid)) AS toast_n_dead_tup,
+    pg_stat_get_last_autovacuum_time(NULLIF(c.reltoastrelid, (0)::oid)) AS toast_last_autovacuum,
+    pg_stat_get_autovacuum_count(NULLIF(c.reltoastrelid, (0)::oid)) AS toast_autovacuum_count,
     pg_stat_get_stat_reset_time(c.oid) AS stats_reset
    FROM ((pg_class c
      LEFT JOIN pg_index i ON ((c.oid = i.indrelid)))
@@ -2368,6 +2372,10 @@ pg_stat_sys_tables| SELECT relid,
     total_autovacuum_time,
     total_analyze_time,
     total_autoanalyze_time,
+    toast_relid,
+    toast_n_dead_tup,
+    toast_last_autovacuum,
+    toast_autovacuum_count,
     stats_reset
    FROM pg_stat_all_tables
   WHERE ((schemaname = ANY (ARRAY['pg_catalog'::name, 'information_schema'::name])) OR (schemaname ~ '^pg_toast'::text));
@@ -2423,6 +2431,10 @@ pg_stat_user_tables| SELECT relid,
     total_autovacuum_time,
     total_analyze_time,
     total_autoanalyze_time,
+    toast_relid,
+    toast_n_dead_tup,
+    toast_last_autovacuum,
+    toast_autovacuum_count,
     stats_reset
    FROM pg_stat_all_tables
   WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text));
diff --git a/src/test/regress/expected/stats.out b/src/test/regress/expected/stats.out
index bed65cd0281..6ab452d7d10 100644
--- a/src/test/regress/expected/stats.out
+++ b/src/test/regress/expected/stats.out
@@ -332,6 +332,52 @@ SELECT idx_scan, idx_tup_read, idx_tup_fetch FROM pg_stat_all_indexes
 
 DROP TABLE test_idx_tup_read;
 ----
+-- Check that TOAST statistics are visible from the owning table's row.
+-- Use temporary tables so that autovacuum can't interfere.
+----
+CREATE TEMPORARY TABLE stats_toast_test (a text);
+ALTER TABLE stats_toast_test ALTER COLUMN a SET STORAGE EXTERNAL;
+INSERT INTO stats_toast_test SELECT repeat('a', 10000) FROM generate_series(1, 5);
+DELETE FROM stats_toast_test;
+CREATE TEMPORARY TABLE stats_toast_test_none (a int);
+INSERT INTO stats_toast_test_none VALUES (1);
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+-- The toast_ columns must report the statistics of the TOAST table itself,
+-- which is visible as its own row in pg_stat_all_tables.
+SELECT st.toast_relid = c.reltoastrelid AS toast_relid_matches,
+       st.toast_n_dead_tup > 0 AS toast_n_dead_tup_positive,
+       st.toast_n_dead_tup = t.n_dead_tup AS n_dead_tup_matches,
+       st.toast_last_autovacuum IS NOT DISTINCT FROM t.last_autovacuum
+         AS last_autovacuum_matches,
+       st.toast_autovacuum_count = t.autovacuum_count
+         AS autovacuum_count_matches
+  FROM pg_stat_user_tables st
+       JOIN pg_class c ON c.oid = st.relid
+       JOIN pg_stat_all_tables t ON t.relid = st.toast_relid
+ WHERE st.relid = 'stats_toast_test'::regclass;
+ toast_relid_matches | toast_n_dead_tup_positive | n_dead_tup_matches | last_autovacuum_matches | autovacuum_count_matches 
+---------------------+---------------------------+--------------------+-------------------------+--------------------------
+ t                   | t                         | t                  | t                       | t
+(1 row)
+
+-- a table without a TOAST table reports NULLs
+SELECT toast_relid, toast_n_dead_tup, toast_last_autovacuum,
+       toast_autovacuum_count
+  FROM pg_stat_user_tables
+ WHERE relid = 'stats_toast_test_none'::regclass;
+ toast_relid | toast_n_dead_tup | toast_last_autovacuum | toast_autovacuum_count 
+-------------+------------------+-----------------------+------------------------
+             |                  |                       |                       
+(1 row)
+
+DROP TABLE stats_toast_test;
+DROP TABLE stats_toast_test_none;
+----
 -- Basic tests for track_functions
 ---
 CREATE FUNCTION stats_test_func1() RETURNS VOID LANGUAGE plpgsql AS $$BEGIN END;$$;
diff --git a/src/test/regress/sql/stats.sql b/src/test/regress/sql/stats.sql
index 737b11cb0d2..66b61814af1 100644
--- a/src/test/regress/sql/stats.sql
+++ b/src/test/regress/sql/stats.sql
@@ -171,6 +171,43 @@ SELECT idx_scan, idx_tup_read, idx_tup_fetch FROM pg_stat_all_indexes
 
 DROP TABLE test_idx_tup_read;
 
+----
+-- Check that TOAST statistics are visible from the owning table's row.
+-- Use temporary tables so that autovacuum can't interfere.
+----
+CREATE TEMPORARY TABLE stats_toast_test (a text);
+ALTER TABLE stats_toast_test ALTER COLUMN a SET STORAGE EXTERNAL;
+INSERT INTO stats_toast_test SELECT repeat('a', 10000) FROM generate_series(1, 5);
+DELETE FROM stats_toast_test;
+
+CREATE TEMPORARY TABLE stats_toast_test_none (a int);
+INSERT INTO stats_toast_test_none VALUES (1);
+
+SELECT pg_stat_force_next_flush();
+
+-- The toast_ columns must report the statistics of the TOAST table itself,
+-- which is visible as its own row in pg_stat_all_tables.
+SELECT st.toast_relid = c.reltoastrelid AS toast_relid_matches,
+       st.toast_n_dead_tup > 0 AS toast_n_dead_tup_positive,
+       st.toast_n_dead_tup = t.n_dead_tup AS n_dead_tup_matches,
+       st.toast_last_autovacuum IS NOT DISTINCT FROM t.last_autovacuum
+         AS last_autovacuum_matches,
+       st.toast_autovacuum_count = t.autovacuum_count
+         AS autovacuum_count_matches
+  FROM pg_stat_user_tables st
+       JOIN pg_class c ON c.oid = st.relid
+       JOIN pg_stat_all_tables t ON t.relid = st.toast_relid
+ WHERE st.relid = 'stats_toast_test'::regclass;
+
+-- a table without a TOAST table reports NULLs
+SELECT toast_relid, toast_n_dead_tup, toast_last_autovacuum,
+       toast_autovacuum_count
+  FROM pg_stat_user_tables
+ WHERE relid = 'stats_toast_test_none'::regclass;
+
+DROP TABLE stats_toast_test;
+DROP TABLE stats_toast_test_none;
+
 ----
 -- Basic tests for track_functions
 ---
-- 
2.47.3

