From 202358600db563adc6fdbcd381c0cd4e29547035 Mon Sep 17 00:00:00 2001 From: Shinya Kato 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 Reviewed-by: Shihao Zhong 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 + + + toast_relid oid + + + OID of this table's TOAST table, or NULL if none + (see ) + + + + + + toast_n_dead_tup bigint + + + Estimated number of dead tuples in this table's + TOAST table (if any). Note that + TOAST tuples are chunks of long values, so this + is not comparable to the number of dead rows in the table itself. + + + + + + toast_last_autovacuum timestamp with time zone + + + Last time at which this table's TOAST table (if + any) was vacuumed by the autovacuum daemon. The autovacuum daemon + processes a TOAST table independently of its main + table, so this can differ significantly from + last_autovacuum. + + + + + + toast_autovacuum_count bigint + + + Number of times this table's TOAST table (if any) + has been vacuumed by the autovacuum daemon + + + stats_reset timestamp with time zone 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