Skip to main content

micromegas/servers/
pg_stats.rs

1//! Periodic self-observability collector for the metadata Postgres:
2//! samples the standard `pg_stat_*` catalog views (plus index/table sizes) and
3//! emits the readings as micromegas metrics through the process's own tracing
4//! sink. This turns questions that today can only be answered by connecting
5//! to the DB (e.g. *which indexes are dead weight*) into hard evidence
6//! queryable via FlightSQL.
7//!
8//! The collector emits **raw cumulative counters** exactly as Postgres reports
9//! them and never resets any statistic (no `pg_stat_reset*` call anywhere in
10//! this module) — deltas and rates are a query-time concern, and
11//! `pg_db_stats_reset_timestamp` lets that analysis segment on counter-reset
12//! boundaries.
13//!
14//! Modeled on `object-cache-srv::saturation_monitor`: a pure sampling function
15//! (`collect_pg_stats`) split from the scheduling plumbing (`PgStatsTask`), so
16//! it can be driven directly by tests.
17
18use anyhow::{Context, Result};
19use async_trait::async_trait;
20use chrono::{DateTime, Utc};
21use micromegas_analytics::lakehouse::lakehouse_context::LakehouseContext;
22use micromegas_tracing::prelude::*;
23use sqlx::Row;
24use sqlx::postgres::PgRow;
25use std::sync::Arc;
26
27use super::cron_task::TaskCallback;
28
29/// Reads a `bigint` counter column as `u64`, defaulting to `0` when it's
30/// `NULL`. Postgres's `pg_stat_user_tables`/`pg_stat_user_indexes` counters
31/// can read `NULL` rather than `0` on a freshly created relation, before the
32/// stats collector's first report — this is a real, observed condition (not
33/// a defensive guess), not merely a schema nullability quirk.
34fn get_counter(row: &PgRow, col: &str) -> Result<u64> {
35    let value: Option<i64> = row.try_get(col).with_context(|| col.to_string())?;
36    Ok(value.unwrap_or(0) as u64)
37}
38
39/// Per-index metrics: `pg_stat_user_indexes` joined with
40/// `pg_relation_size(indexrelid)`, tagged `{table, index}`.
41async fn collect_index_stats(pool: &sqlx::PgPool) -> Result<()> {
42    let rows = instrument_named!(
43        sqlx::query(
44            "SELECT relname AS table_name,
45                indexrelname AS index_name,
46                idx_scan,
47                idx_tup_read,
48                idx_tup_fetch,
49                pg_relation_size(indexrelid) AS index_size_bytes
50         FROM pg_stat_user_indexes",
51        )
52        .fetch_all(pool),
53        "sql_select_pg_stat_user_indexes"
54    )
55    .await
56    .with_context(|| "querying pg_stat_user_indexes")?;
57
58    for row in rows {
59        let table: String = row.try_get("table_name").with_context(|| "table_name")?;
60        let index: String = row.try_get("index_name").with_context(|| "index_name")?;
61        imetric!(
62            "pg_index_scans",
63            "count",
64            table = table,
65            index = index,
66            get_counter(&row, "idx_scan")?
67        );
68        imetric!(
69            "pg_index_tuples_read",
70            "count",
71            table = table,
72            index = index,
73            get_counter(&row, "idx_tup_read")?
74        );
75        imetric!(
76            "pg_index_tuples_fetched",
77            "count",
78            table = table,
79            index = index,
80            get_counter(&row, "idx_tup_fetch")?
81        );
82        imetric!(
83            "pg_index_size_bytes",
84            "bytes",
85            table = table,
86            index = index,
87            get_counter(&row, "index_size_bytes")?
88        );
89    }
90    Ok(())
91}
92
93/// Per-table metrics: `pg_stat_user_tables`, tagged `{table}`.
94async fn collect_table_stats(pool: &sqlx::PgPool) -> Result<()> {
95    let rows = instrument_named!(
96        sqlx::query(
97            "SELECT relname AS table_name,
98                seq_scan,
99                idx_scan,
100                n_live_tup,
101                n_dead_tup,
102                n_tup_ins,
103                n_tup_upd,
104                n_tup_del,
105                extract(epoch from now() - last_autovacuum)::float8 AS seconds_since_autovacuum
106         FROM pg_stat_user_tables",
107        )
108        .fetch_all(pool),
109        "sql_select_pg_stat_user_tables"
110    )
111    .await
112    .with_context(|| "querying pg_stat_user_tables")?;
113
114    for row in rows {
115        let table: String = row.try_get("table_name").with_context(|| "table_name")?;
116        imetric!(
117            "pg_table_seq_scans",
118            "count",
119            table = table,
120            get_counter(&row, "seq_scan")?
121        );
122        imetric!(
123            "pg_table_idx_scans",
124            "count",
125            table = table,
126            get_counter(&row, "idx_scan")?
127        );
128        imetric!(
129            "pg_table_live_tuples",
130            "count",
131            table = table,
132            get_counter(&row, "n_live_tup")?
133        );
134        imetric!(
135            "pg_table_dead_tuples",
136            "count",
137            table = table,
138            get_counter(&row, "n_dead_tup")?
139        );
140        imetric!(
141            "pg_table_tuples_inserted",
142            "count",
143            table = table,
144            get_counter(&row, "n_tup_ins")?
145        );
146        imetric!(
147            "pg_table_tuples_updated",
148            "count",
149            table = table,
150            get_counter(&row, "n_tup_upd")?
151        );
152        imetric!(
153            "pg_table_tuples_deleted",
154            "count",
155            table = table,
156            get_counter(&row, "n_tup_del")?
157        );
158
159        let seconds_since_autovacuum: Option<f64> = row
160            .try_get("seconds_since_autovacuum")
161            .with_context(|| "seconds_since_autovacuum")?;
162        if let Some(age) = seconds_since_autovacuum {
163            fmetric!(
164                "pg_table_seconds_since_autovacuum",
165                "seconds",
166                table = table,
167                age
168            );
169        }
170    }
171    Ok(())
172}
173
174/// Database-wide metrics: `pg_stat_database` filtered to `current_database()`
175/// (single row), untagged.
176async fn collect_database_stats(pool: &sqlx::PgPool) -> Result<()> {
177    let row = instrument_named!(
178        sqlx::query(
179            "SELECT blks_hit,
180                blks_read,
181                xact_commit,
182                xact_rollback,
183                deadlocks,
184                temp_bytes,
185                extract(epoch from stats_reset)::float8 AS stats_reset_epoch
186         FROM pg_stat_database
187         WHERE datname = current_database()",
188        )
189        .fetch_one(pool),
190        "sql_select_pg_stat_database"
191    )
192    .await
193    .with_context(|| "querying pg_stat_database")?;
194
195    imetric!("pg_db_blocks_hit", "count", get_counter(&row, "blks_hit")?);
196    imetric!(
197        "pg_db_blocks_read",
198        "count",
199        get_counter(&row, "blks_read")?
200    );
201    imetric!(
202        "pg_db_xact_commit",
203        "count",
204        get_counter(&row, "xact_commit")?
205    );
206    imetric!(
207        "pg_db_xact_rollback",
208        "count",
209        get_counter(&row, "xact_rollback")?
210    );
211    imetric!("pg_db_deadlocks", "count", get_counter(&row, "deadlocks")?);
212    imetric!(
213        "pg_db_temp_bytes",
214        "bytes",
215        get_counter(&row, "temp_bytes")?
216    );
217
218    let stats_reset_epoch: Option<f64> = row
219        .try_get("stats_reset_epoch")
220        .with_context(|| "stats_reset_epoch")?;
221    if let Some(epoch) = stats_reset_epoch {
222        fmetric!("pg_db_stats_reset_timestamp", "seconds", epoch);
223    }
224    Ok(())
225}
226
227/// Point-in-time activity metrics: connection counts per `state` and the age
228/// of the oldest in-progress transaction, both from `pg_stat_activity`
229/// filtered to `current_database()`.
230async fn collect_activity_stats(pool: &sqlx::PgPool) -> Result<()> {
231    let state_rows = instrument_named!(
232        sqlx::query(
233            "SELECT coalesce(state, 'unknown') AS state, count(*) AS connections
234         FROM pg_stat_activity
235         WHERE datname = current_database()
236         GROUP BY 1",
237        )
238        .fetch_all(pool),
239        "sql_select_pg_stat_activity_by_state"
240    )
241    .await
242    .with_context(|| "querying pg_stat_activity (by state)")?;
243
244    for row in state_rows {
245        let state: String = row.try_get("state").with_context(|| "state")?;
246        imetric!(
247            "pg_activity_connections",
248            "count",
249            state = state,
250            get_counter(&row, "connections")?
251        );
252    }
253
254    let oldest_xact_row = instrument_named!(
255        sqlx::query(
256            "SELECT extract(epoch from now() - min(xact_start))::float8 AS oldest_xact_age_seconds
257         FROM pg_stat_activity
258         WHERE datname = current_database()",
259        )
260        .fetch_one(pool),
261        "sql_select_pg_stat_activity_oldest_xact"
262    )
263    .await
264    .with_context(|| "querying pg_stat_activity (oldest xact)")?;
265
266    let oldest_xact_age_seconds: Option<f64> = oldest_xact_row
267        .try_get("oldest_xact_age_seconds")
268        .with_context(|| "oldest_xact_age_seconds")?;
269    if let Some(age) = oldest_xact_age_seconds {
270        fmetric!("pg_activity_oldest_xact_age_seconds", "seconds", age);
271    }
272    Ok(())
273}
274
275/// Client-side connection-pool gauges. No query: read directly off the
276/// `sqlx::PgPool` handle.
277fn collect_pool_stats(pool: &sqlx::PgPool) {
278    imetric!("pg_pool_size", "count", pool.size() as u64);
279    imetric!("pg_pool_idle", "count", pool.num_idle() as u64);
280}
281
282/// Samples all `pg_stat_*` families and emits them as metrics. Never issues a
283/// `pg_stat_reset*` call — only `SELECT`s.
284///
285/// If a single catalog query fails, it's logged and the others still run (a
286/// transient catalog hiccup shouldn't drop the whole tick); the first error
287/// encountered is returned at the end via `anyhow` context.
288pub async fn collect_pg_stats(pool: &sqlx::PgPool) -> Result<()> {
289    let mut first_err: Option<anyhow::Error> = None;
290
291    if let Err(e) = collect_index_stats(pool).await {
292        error!("pg_stats: {e:?}");
293        first_err.get_or_insert(e);
294    }
295    if let Err(e) = collect_table_stats(pool).await {
296        error!("pg_stats: {e:?}");
297        first_err.get_or_insert(e);
298    }
299    if let Err(e) = collect_database_stats(pool).await {
300        error!("pg_stats: {e:?}");
301        first_err.get_or_insert(e);
302    }
303    if let Err(e) = collect_activity_stats(pool).await {
304        error!("pg_stats: {e:?}");
305        first_err.get_or_insert(e);
306    }
307    collect_pool_stats(pool);
308
309    match first_err {
310        Some(e) => Err(e),
311        None => Ok(()),
312    }
313}
314
315/// Cron task wiring: runs `collect_pg_stats` against the lakehouse's metadata
316/// DB pool once per tick.
317pub struct PgStatsTask {
318    pub lakehouse: Arc<LakehouseContext>,
319}
320
321#[async_trait]
322impl TaskCallback for PgStatsTask {
323    #[span_fn]
324    async fn run(&self, _task_scheduled_time: DateTime<Utc>) -> Result<()> {
325        collect_pg_stats(&self.lakehouse.lake().db_pool).await
326    }
327}