From f73cfe1dd6c87e07a0a050a030fda76206f2caaf 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 v1] 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, computed
by joining pg_class on reltoastrelid and calling the existing
statistics access functions on the TOAST relation's OID.  No new
counters or statistics functions are introduced.

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:
Discussion: https://postgr.es/m/
---
 doc/src/sgml/monitoring.sgml         | 45 ++++++++++++++++++++++++++++
 src/backend/catalog/system_views.sql |  7 ++++-
 src/test/regress/expected/rules.out  | 17 +++++++++--
 src/test/regress/expected/stats.out  | 39 ++++++++++++++++++++++++
 src/test/regress/sql/stats.sql       | 30 +++++++++++++++++++
 5 files changed, 135 insertions(+), 3 deletions(-)

diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml
index f1422826d29..7b7036d3a67 100644
--- a/doc/src/sgml/monitoring.sgml
+++ b/doc/src/sgml/monitoring.sgml
@@ -4792,6 +4792,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 090281a03dd..714f161bbcd 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -747,12 +747,17 @@ 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(T.oid) AS toast_n_dead_tup,
+            pg_stat_get_last_autovacuum_time(T.oid) AS toast_last_autovacuum,
+            pg_stat_get_autovacuum_count(T.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
+         LEFT JOIN pg_class T ON C.reltoastrelid = T.oid
          LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
     WHERE C.relkind IN ('r', 't', 'm', 'p')
-    GROUP BY C.oid, N.nspname, C.relname;
+    GROUP BY C.oid, N.nspname, C.relname, T.oid;
 
 CREATE VIEW pg_stat_xact_all_tables AS
     SELECT
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 6a3341356da..1de8592aaf9 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -1846,12 +1846,17 @@ 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(t.oid) AS toast_n_dead_tup,
+    pg_stat_get_last_autovacuum_time(t.oid) AS toast_last_autovacuum,
+    pg_stat_get_autovacuum_count(t.oid) AS toast_autovacuum_count,
     pg_stat_get_stat_reset_time(c.oid) AS stats_reset
-   FROM ((pg_class c
+   FROM (((pg_class c
      LEFT JOIN pg_index i ON ((c.oid = i.indrelid)))
+     LEFT JOIN pg_class t ON ((c.reltoastrelid = t.oid)))
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace)))
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char", 'p'::"char"]))
-  GROUP BY c.oid, n.nspname, c.relname;
+  GROUP BY c.oid, n.nspname, c.relname, t.oid;
 pg_stat_archiver| SELECT archived_count,
     last_archived_wal,
     last_archived_time,
@@ -2368,6 +2373,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 +2432,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 e230356de13..ecd0ede961c 100644
--- a/src/test/regress/expected/stats.out
+++ b/src/test/regress/expected/stats.out
@@ -269,6 +269,45 @@ FROM prevstats AS pr;
 
 COMMIT;
 ----
+-- 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)
+
+-- toast_relid links to the correct TOAST table
+SELECT st.toast_relid = c.reltoastrelid AS toast_relid_matches,
+       st.toast_n_dead_tup > 0 AS toast_n_dead_tup_positive,
+       st.toast_autovacuum_count >= 0 AS toast_autovacuum_count_ok
+  FROM pg_stat_user_tables st JOIN pg_class c ON c.oid = st.relid
+ WHERE st.relname = 'stats_toast_test';
+ toast_relid_matches | toast_n_dead_tup_positive | toast_autovacuum_count_ok 
+---------------------+---------------------------+---------------------------
+ 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 relname = 'stats_toast_test_none';
+ 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 4c265d1245c..f73cdda21a6 100644
--- a/src/test/regress/sql/stats.sql
+++ b/src/test/regress/sql/stats.sql
@@ -139,6 +139,36 @@ FROM prevstats AS pr;
 
 COMMIT;
 
+----
+-- 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();
+
+-- toast_relid links to the correct TOAST table
+SELECT st.toast_relid = c.reltoastrelid AS toast_relid_matches,
+       st.toast_n_dead_tup > 0 AS toast_n_dead_tup_positive,
+       st.toast_autovacuum_count >= 0 AS toast_autovacuum_count_ok
+  FROM pg_stat_user_tables st JOIN pg_class c ON c.oid = st.relid
+ WHERE st.relname = 'stats_toast_test';
+
+-- 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 relname = 'stats_toast_test_none';
+
+DROP TABLE stats_toast_test;
+DROP TABLE stats_toast_test_none;
+
 ----
 -- Basic tests for track_functions
 ---
-- 
2.47.3

