Skip to main content

sbol_db_postgres/repo/
pg_stats.rs

1//! Postgres maintenance/observability views.
2//!
3//! Thin wrappers around the `pg_stat_*`, `pg_locks`, and `pg_stat_statements`
4//! catalog views that the lab UI's maintenance page reads on Postgres. The
5//! queries are intentionally small and bounded — these read-only handlers are
6//! polled every ~15 seconds by an open browser tab, so each method does at
7//! most a single index-friendly catalog scan.
8//!
9//! sbol-db doesn't use Postgres schemas as a domain concept (no tenancy /
10//! isolation surface in the UI), so every table-shaped query here filters
11//! to `schemaname = 'public'`. If we ever introduce a real multi-schema
12//! verb, lift this filter and re-introduce the schema parameter.
13//!
14//! `pg_stat_statements` is optional: callers should check
15//! [`PgStatsRepository::has_pg_stat_statements`] before calling
16//! [`PgStatsRepository::slow_queries`].
17
18/// Hard-coded user schema. The whole codebase writes into `public`; the
19/// pg_stats helpers filter to it so the lab UI doesn't surface stray
20/// extension-owned schemas (or the catalog's own).
21const USER_SCHEMA: &str = "public";
22
23use async_trait::async_trait;
24use sbol_db_core::DomainError;
25use sbol_db_storage::DbStats;
26use sqlx::Row;
27
28pub use sbol_db_storage::{
29    Activity, BlockingLock, DatabaseSize, IncomingForeignKey, IndexStats, OutgoingForeignKey,
30    RelationalColumn, RelationalSchema, RelationalTable, SlowQuery, TableColumn, TableSchema,
31    TableStats,
32};
33
34use crate::repo::db_err;
35use crate::PgPool;
36
37#[derive(Clone)]
38pub struct PgStatsRepository {
39    pool: PgPool,
40}
41
42impl PgStatsRepository {
43    pub fn new(pool: PgPool) -> Self {
44        Self { pool }
45    }
46
47    /// Every table in the user schema with its columns, for the lab's schema
48    /// browser and SQL-editor click-to-insert sidebar. `_sqlx_migrations` is
49    /// sqlx's own bookkeeping table and is filtered out.
50    pub async fn schema_overview(&self) -> Result<RelationalSchema, DomainError> {
51        let rows = sqlx::query(
52            r#"
53            SELECT table_name, column_name, data_type, udt_name, is_nullable, ordinal_position
54            FROM information_schema.columns
55            WHERE table_schema = $1
56              AND table_name <> '_sqlx_migrations'
57            ORDER BY table_name, ordinal_position
58            "#,
59        )
60        .bind(USER_SCHEMA)
61        .fetch_all(&self.pool)
62        .await
63        .map_err(db_err)?;
64
65        let mut current: Option<RelationalTable> = None;
66        let mut tables: Vec<RelationalTable> = Vec::new();
67        for row in rows {
68            let table_name: String = row.try_get("table_name").map_err(db_err)?;
69            let column_name: String = row.try_get("column_name").map_err(db_err)?;
70            let data_type: String = row.try_get("data_type").map_err(db_err)?;
71            let udt_name: String = row.try_get("udt_name").map_err(db_err)?;
72            let is_nullable: String = row.try_get("is_nullable").map_err(db_err)?;
73
74            // information_schema reports `USER-DEFINED`/`ARRAY` for domains and
75            // arrays; the underlying udt name is the useful label.
76            let column_type = if data_type == "USER-DEFINED" || data_type == "ARRAY" {
77                udt_name
78            } else {
79                data_type
80            };
81            let column = RelationalColumn {
82                name: column_name,
83                column_type,
84                nullable: is_nullable == "YES",
85            };
86
87            match &mut current {
88                Some(t) if t.name == table_name => t.columns.push(column),
89                _ => {
90                    if let Some(prev) = current.take() {
91                        tables.push(prev);
92                    }
93                    current = Some(RelationalTable {
94                        name: table_name,
95                        columns: vec![column],
96                    });
97                }
98            }
99        }
100        if let Some(last) = current.take() {
101            tables.push(last);
102        }
103
104        Ok(RelationalSchema { tables })
105    }
106
107    pub async fn database_size(&self) -> Result<DatabaseSize, DomainError> {
108        let row = sqlx::query(
109            r#"
110            SELECT current_database()::text                          AS db,
111                   pg_database_size(current_database())::bigint      AS bytes
112            "#,
113        )
114        .fetch_one(&self.pool)
115        .await
116        .map_err(db_err)?;
117        Ok(DatabaseSize {
118            database: row.try_get("db").map_err(db_err)?,
119            total_bytes: row.try_get("bytes").map_err(db_err)?,
120        })
121    }
122
123    pub async fn table_stats(
124        &self,
125        limit: i64,
126        offset: i64,
127    ) -> Result<Vec<TableStats>, DomainError> {
128        let rows = sqlx::query(
129            r#"
130            SELECT
131                s.relname::text                             AS name,
132                COALESCE(c.reltuples, 0)::bigint            AS rows_estimate,
133                pg_total_relation_size(c.oid)::bigint       AS total_bytes,
134                pg_indexes_size(c.oid)::bigint              AS index_bytes,
135                s.n_live_tup::bigint                        AS n_live_tup,
136                s.n_dead_tup::bigint                        AS n_dead_tup,
137                s.last_vacuum,
138                s.last_autovacuum,
139                s.last_analyze
140            FROM pg_stat_user_tables s
141            JOIN pg_class c ON c.oid = s.relid
142            WHERE s.schemaname = $1
143            ORDER BY pg_total_relation_size(c.oid) DESC
144            LIMIT $2 OFFSET $3
145            "#,
146        )
147        .bind(USER_SCHEMA)
148        .bind(limit)
149        .bind(offset)
150        .fetch_all(&self.pool)
151        .await
152        .map_err(db_err)?;
153
154        rows.into_iter()
155            .map(|r| {
156                Ok::<_, DomainError>(TableStats {
157                    name: r.try_get("name").map_err(db_err)?,
158                    rows_estimate: r.try_get("rows_estimate").map_err(db_err)?,
159                    total_bytes: r.try_get("total_bytes").map_err(db_err)?,
160                    index_bytes: r.try_get("index_bytes").map_err(db_err)?,
161                    n_live_tup: r.try_get("n_live_tup").map_err(db_err)?,
162                    n_dead_tup: r.try_get("n_dead_tup").map_err(db_err)?,
163                    last_vacuum: r.try_get("last_vacuum").map_err(db_err)?,
164                    last_autovacuum: r.try_get("last_autovacuum").map_err(db_err)?,
165                    last_analyze: r.try_get("last_analyze").map_err(db_err)?,
166                })
167            })
168            .collect()
169    }
170
171    pub async fn index_stats(&self, limit: i64) -> Result<Vec<IndexStats>, DomainError> {
172        let rows = sqlx::query(
173            r#"
174            SELECT
175                s.relname::text                        AS table_name,
176                s.indexrelname::text                   AS index_name,
177                COALESCE(s.idx_scan, 0)::bigint        AS idx_scan,
178                pg_relation_size(s.indexrelid)::bigint AS bytes
179            FROM pg_stat_user_indexes s
180            WHERE s.schemaname = $1
181            ORDER BY pg_relation_size(s.indexrelid) DESC
182            LIMIT $2
183            "#,
184        )
185        .bind(USER_SCHEMA)
186        .bind(limit)
187        .fetch_all(&self.pool)
188        .await
189        .map_err(db_err)?;
190
191        rows.into_iter()
192            .map(|r| {
193                Ok::<_, DomainError>(IndexStats {
194                    table: r.try_get("table_name").map_err(db_err)?,
195                    index: r.try_get("index_name").map_err(db_err)?,
196                    idx_scan: r.try_get("idx_scan").map_err(db_err)?,
197                    bytes: r.try_get("bytes").map_err(db_err)?,
198                })
199            })
200            .collect()
201    }
202
203    pub async fn activity(&self, limit: i64) -> Result<Vec<Activity>, DomainError> {
204        let rows = sqlx::query(
205            r#"
206            SELECT
207                pid,
208                application_name,
209                state,
210                wait_event_type,
211                wait_event,
212                query_start,
213                EXTRACT(EPOCH FROM (now() - query_start))::float8 AS duration_secs,
214                LEFT(query, 500) AS query,
215                client_addr::text AS client_addr
216            FROM pg_stat_activity
217            WHERE pid <> pg_backend_pid()
218              AND state IS NOT NULL
219              AND query IS NOT NULL
220              AND query != ''
221              AND backend_type = 'client backend'
222            ORDER BY query_start ASC NULLS LAST
223            LIMIT $1
224            "#,
225        )
226        .bind(limit)
227        .fetch_all(&self.pool)
228        .await
229        .map_err(db_err)?;
230
231        rows.into_iter()
232            .map(|r| {
233                Ok::<_, DomainError>(Activity {
234                    pid: r.try_get("pid").map_err(db_err)?,
235                    application_name: r.try_get("application_name").map_err(db_err)?,
236                    state: r.try_get("state").map_err(db_err)?,
237                    wait_event_type: r.try_get("wait_event_type").map_err(db_err)?,
238                    wait_event: r.try_get("wait_event").map_err(db_err)?,
239                    query: r.try_get("query").map_err(db_err)?,
240                    query_start: r.try_get("query_start").map_err(db_err)?,
241                    duration_secs: r.try_get("duration_secs").map_err(db_err)?,
242                    client_addr: r.try_get("client_addr").map_err(db_err)?,
243                })
244            })
245            .collect()
246    }
247
248    pub async fn blocking_locks(&self) -> Result<Vec<BlockingLock>, DomainError> {
249        let rows = sqlx::query(
250            r#"
251            SELECT
252                kl.pid           AS blocker_pid,
253                LEFT(ka.query, 500) AS blocker_query,
254                bl.pid           AS blocked_pid,
255                LEFT(ba.query, 500) AS blocked_query,
256                bl.mode,
257                bl.locktype
258            FROM pg_locks bl
259            JOIN pg_stat_activity ba ON ba.pid = bl.pid
260            JOIN pg_locks kl
261                ON kl.locktype = bl.locktype
262               AND kl.granted
263               AND ((bl.relation = kl.relation AND bl.relation IS NOT NULL)
264                    OR (bl.objid = kl.objid AND bl.objid IS NOT NULL)
265                    OR (bl.transactionid = kl.transactionid AND bl.transactionid IS NOT NULL))
266               AND bl.pid <> kl.pid
267            JOIN pg_stat_activity ka ON ka.pid = kl.pid
268            WHERE NOT bl.granted
269            ORDER BY blocked_pid
270            LIMIT 50
271            "#,
272        )
273        .fetch_all(&self.pool)
274        .await
275        .map_err(db_err)?;
276
277        rows.into_iter()
278            .map(|r| {
279                Ok::<_, DomainError>(BlockingLock {
280                    blocker_pid: r.try_get("blocker_pid").map_err(db_err)?,
281                    blocker_query: r.try_get("blocker_query").map_err(db_err)?,
282                    blocked_pid: r.try_get("blocked_pid").map_err(db_err)?,
283                    blocked_query: r.try_get("blocked_query").map_err(db_err)?,
284                    mode: r.try_get("mode").map_err(db_err)?,
285                    locktype: r.try_get("locktype").map_err(db_err)?,
286                })
287            })
288            .collect()
289    }
290
291    /// Returns true when the `pg_stat_statements` extension is installed.
292    /// Cheap (a single index lookup against `pg_extension`); callers should
293    /// check this before calling [`Self::slow_queries`].
294    pub async fn has_pg_stat_statements(&self) -> Result<bool, DomainError> {
295        let row = sqlx::query(
296            r#"
297            SELECT EXISTS(
298              SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'
299            ) AS present
300            "#,
301        )
302        .fetch_one(&self.pool)
303        .await
304        .map_err(db_err)?;
305        row.try_get::<bool, _>("present").map_err(db_err)
306    }
307
308    /// Full schema introspection for a single relation. Returns `None`
309    /// when the table doesn't exist (or isn't a relation/matview/
310    /// partitioned table). Each query is one indexed catalog lookup,
311    /// fine for an on-demand drill-down view.
312    pub async fn table_schema(&self, name: &str) -> Result<Option<TableSchema>, DomainError> {
313        let exists_row = sqlx::query(
314            r#"
315            SELECT obj_description(c.oid, 'pg_class') AS comment
316            FROM pg_class c
317            JOIN pg_namespace n ON n.oid = c.relnamespace
318            WHERE n.nspname = $1
319              AND c.relname = $2
320              AND c.relkind IN ('r','m','p')
321            "#,
322        )
323        .bind(USER_SCHEMA)
324        .bind(name)
325        .fetch_optional(&self.pool)
326        .await
327        .map_err(db_err)?;
328        let Some(exists_row) = exists_row else {
329            return Ok(None);
330        };
331        let comment: Option<String> = exists_row.try_get("comment").map_err(db_err)?;
332
333        let column_rows = sqlx::query(
334            r#"
335            WITH tbl AS (
336              SELECT c.oid
337              FROM pg_class c
338              JOIN pg_namespace n ON n.oid = c.relnamespace
339              WHERE n.nspname = $1 AND c.relname = $2
340            ),
341            pk AS (
342              SELECT unnest(con.conkey) AS attnum
343              FROM pg_constraint con
344              JOIN tbl ON con.conrelid = tbl.oid
345              WHERE con.contype = 'p'
346            )
347            SELECT
348              a.attname::text                                        AS name,
349              pg_catalog.format_type(a.atttypid, a.atttypmod)::text  AS column_type,
350              (NOT a.attnotnull)                                     AS nullable,
351              pg_get_expr(d.adbin, d.adrelid)                        AS default_expr,
352              a.attnum::int                                          AS ordinal,
353              pg_catalog.col_description(a.attrelid, a.attnum)       AS comment,
354              EXISTS (SELECT 1 FROM pk WHERE pk.attnum = a.attnum)   AS is_primary_key
355            FROM tbl
356            JOIN pg_attribute a ON a.attrelid = tbl.oid
357            LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
358            WHERE a.attnum > 0
359              AND NOT a.attisdropped
360            ORDER BY a.attnum
361            "#,
362        )
363        .bind(USER_SCHEMA)
364        .bind(name)
365        .fetch_all(&self.pool)
366        .await
367        .map_err(db_err)?;
368
369        let columns: Vec<TableColumn> = column_rows
370            .into_iter()
371            .map(|r| {
372                Ok::<_, DomainError>(TableColumn {
373                    name: r.try_get("name").map_err(db_err)?,
374                    column_type: r.try_get("column_type").map_err(db_err)?,
375                    nullable: r.try_get("nullable").map_err(db_err)?,
376                    default_expr: r.try_get("default_expr").map_err(db_err)?,
377                    ordinal: r.try_get("ordinal").map_err(db_err)?,
378                    comment: r.try_get("comment").map_err(db_err)?,
379                    is_primary_key: r.try_get("is_primary_key").map_err(db_err)?,
380                })
381            })
382            .collect::<Result<_, _>>()?;
383
384        let fk_out_rows = sqlx::query(
385            r#"
386            SELECT
387              con.conname::text       AS name,
388              (
389                SELECT array_agg(a.attname::text ORDER BY array_position(con.conkey, a.attnum))
390                FROM pg_attribute a
391                WHERE a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
392              )                       AS columns,
393              tcl.relname::text       AS target_table,
394              (
395                SELECT array_agg(a.attname::text ORDER BY array_position(con.confkey, a.attnum))
396                FROM pg_attribute a
397                WHERE a.attrelid = con.confrelid AND a.attnum = ANY(con.confkey)
398              )                       AS target_columns
399            FROM pg_constraint con
400            JOIN pg_class    scl ON scl.oid = con.conrelid
401            JOIN pg_namespace sns ON sns.oid = scl.relnamespace
402            JOIN pg_class    tcl ON tcl.oid = con.confrelid
403            JOIN pg_namespace tns ON tns.oid = tcl.relnamespace
404            WHERE con.contype = 'f'
405              AND sns.nspname = $1
406              AND scl.relname = $2
407              AND tns.nspname = $1
408            ORDER BY con.conname
409            "#,
410        )
411        .bind(USER_SCHEMA)
412        .bind(name)
413        .fetch_all(&self.pool)
414        .await
415        .map_err(db_err)?;
416
417        let foreign_keys_out: Vec<OutgoingForeignKey> = fk_out_rows
418            .into_iter()
419            .map(|r| {
420                Ok::<_, DomainError>(OutgoingForeignKey {
421                    name: r.try_get("name").map_err(db_err)?,
422                    columns: r
423                        .try_get::<Option<Vec<String>>, _>("columns")
424                        .map_err(db_err)?
425                        .unwrap_or_default(),
426                    target_table: r.try_get("target_table").map_err(db_err)?,
427                    target_columns: r
428                        .try_get::<Option<Vec<String>>, _>("target_columns")
429                        .map_err(db_err)?
430                        .unwrap_or_default(),
431                })
432            })
433            .collect::<Result<_, _>>()?;
434
435        let fk_in_rows = sqlx::query(
436            r#"
437            SELECT
438              con.conname::text       AS name,
439              scl.relname::text       AS source_table,
440              (
441                SELECT array_agg(a.attname::text ORDER BY array_position(con.conkey, a.attnum))
442                FROM pg_attribute a
443                WHERE a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
444              )                       AS source_columns,
445              (
446                SELECT array_agg(a.attname::text ORDER BY array_position(con.confkey, a.attnum))
447                FROM pg_attribute a
448                WHERE a.attrelid = con.confrelid AND a.attnum = ANY(con.confkey)
449              )                       AS target_columns
450            FROM pg_constraint con
451            JOIN pg_class    scl ON scl.oid = con.conrelid
452            JOIN pg_namespace sns ON sns.oid = scl.relnamespace
453            JOIN pg_class    tcl ON tcl.oid = con.confrelid
454            JOIN pg_namespace tns ON tns.oid = tcl.relnamespace
455            WHERE con.contype = 'f'
456              AND tns.nspname = $1
457              AND tcl.relname = $2
458              AND sns.nspname = $1
459            ORDER BY scl.relname, con.conname
460            "#,
461        )
462        .bind(USER_SCHEMA)
463        .bind(name)
464        .fetch_all(&self.pool)
465        .await
466        .map_err(db_err)?;
467
468        let foreign_keys_in: Vec<IncomingForeignKey> = fk_in_rows
469            .into_iter()
470            .map(|r| {
471                Ok::<_, DomainError>(IncomingForeignKey {
472                    name: r.try_get("name").map_err(db_err)?,
473                    source_table: r.try_get("source_table").map_err(db_err)?,
474                    source_columns: r
475                        .try_get::<Option<Vec<String>>, _>("source_columns")
476                        .map_err(db_err)?
477                        .unwrap_or_default(),
478                    target_columns: r
479                        .try_get::<Option<Vec<String>>, _>("target_columns")
480                        .map_err(db_err)?
481                        .unwrap_or_default(),
482                })
483            })
484            .collect::<Result<_, _>>()?;
485
486        Ok(Some(TableSchema {
487            name: name.to_owned(),
488            comment,
489            columns,
490            foreign_keys_out,
491            foreign_keys_in,
492        }))
493    }
494
495    pub async fn slow_queries(&self, limit: i64) -> Result<Vec<SlowQuery>, DomainError> {
496        let rows = sqlx::query(
497            r#"
498            SELECT
499                queryid::text                    AS queryid,
500                LEFT(query, 500)                 AS query,
501                calls::bigint                    AS calls,
502                total_exec_time::float8          AS total_exec_ms,
503                mean_exec_time::float8           AS mean_exec_ms,
504                rows::bigint                     AS rows
505            FROM pg_stat_statements
506            ORDER BY total_exec_time DESC
507            LIMIT $1
508            "#,
509        )
510        .bind(limit)
511        .fetch_all(&self.pool)
512        .await
513        .map_err(db_err)?;
514
515        rows.into_iter()
516            .map(|r| {
517                Ok::<_, DomainError>(SlowQuery {
518                    queryid: r.try_get("queryid").map_err(db_err)?,
519                    query: r.try_get("query").map_err(db_err)?,
520                    calls: r.try_get("calls").map_err(db_err)?,
521                    total_exec_ms: r.try_get("total_exec_ms").map_err(db_err)?,
522                    mean_exec_ms: r.try_get("mean_exec_ms").map_err(db_err)?,
523                    rows: r.try_get("rows").map_err(db_err)?,
524                })
525            })
526            .collect()
527    }
528}
529
530/// The introspection capability, delegating to the inherent query methods.
531/// Explicit `PgStatsRepository::method(self, ...)` paths avoid resolving back
532/// to the trait method and recursing.
533#[async_trait]
534impl DbStats for PgStatsRepository {
535    async fn schema_overview(&self) -> Result<RelationalSchema, DomainError> {
536        PgStatsRepository::schema_overview(self).await
537    }
538    async fn database_size(&self) -> Result<DatabaseSize, DomainError> {
539        PgStatsRepository::database_size(self).await
540    }
541    async fn table_stats(&self, limit: i64, offset: i64) -> Result<Vec<TableStats>, DomainError> {
542        PgStatsRepository::table_stats(self, limit, offset).await
543    }
544    async fn index_stats(&self, limit: i64) -> Result<Vec<IndexStats>, DomainError> {
545        PgStatsRepository::index_stats(self, limit).await
546    }
547    async fn activity(&self, limit: i64) -> Result<Vec<Activity>, DomainError> {
548        PgStatsRepository::activity(self, limit).await
549    }
550    async fn blocking_locks(&self) -> Result<Vec<BlockingLock>, DomainError> {
551        PgStatsRepository::blocking_locks(self).await
552    }
553    async fn table_schema(&self, name: &str) -> Result<Option<TableSchema>, DomainError> {
554        PgStatsRepository::table_schema(self, name).await
555    }
556    async fn has_slow_query_stats(&self) -> Result<bool, DomainError> {
557        PgStatsRepository::has_pg_stat_statements(self).await
558    }
559    async fn slow_queries(&self, limit: i64) -> Result<Vec<SlowQuery>, DomainError> {
560        PgStatsRepository::slow_queries(self, limit).await
561    }
562    async fn optimize(&self) -> Result<(), DomainError> {
563        // ANALYZE refreshes planner statistics without the heavy, blocking
564        // rewrite a full VACUUM does; autovacuum handles the rest.
565        sqlx::query("ANALYZE")
566            .execute(&self.pool)
567            .await
568            .map_err(db_err)?;
569        Ok(())
570    }
571}