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