use crate::collectors::{Collector, i64_to_f64, util::{PG_CATALOG, INFORMATION_SCHEMA}};
use anyhow::Result;
use futures::future::BoxFuture;
use prometheus::{Gauge, Opts, Registry};
use sqlx::PgPool;
#[derive(Clone)]
pub struct UnusedIndexCollector {
unused_count: Gauge,
unused_size_bytes: Gauge,
invalid_count: Gauge,
}
impl Default for UnusedIndexCollector {
fn default() -> Self {
Self::new()
}
}
impl UnusedIndexCollector {
#[must_use]
#[allow(clippy::expect_used)]
pub fn new() -> Self {
Self {
unused_count: Gauge::with_opts(Opts::new(
"pg_index_unused_count",
"Number of indexes that have never been scanned (idx_scan = 0, excluding primary/unique constraints)",
))
.expect("Failed to create pg_index_unused_count"),
unused_size_bytes: Gauge::with_opts(Opts::new(
"pg_index_unused_size_bytes",
"Total size in bytes of unused indexes",
))
.expect("Failed to create pg_index_unused_size_bytes"),
invalid_count: Gauge::with_opts(Opts::new(
"pg_index_invalid_count",
"Number of invalid indexes from failed CREATE INDEX CONCURRENTLY operations",
))
.expect("Failed to create pg_index_invalid_count"),
}
}
}
impl Collector for UnusedIndexCollector {
fn name(&self) -> &'static str {
"index_unused"
}
fn register_metrics(&self, registry: &Registry) -> Result<()> {
registry.register(Box::new(self.unused_count.clone()))?;
registry.register(Box::new(self.unused_size_bytes.clone()))?;
registry.register(Box::new(self.invalid_count.clone()))?;
Ok(())
}
fn collect<'a>(&'a self, pool: &'a PgPool) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
let unused_query = format!(
r"
SELECT
COUNT(*)::BIGINT as unused_count,
COALESCE(SUM(pg_relation_size(s.indexrelid)), 0)::BIGINT as unused_size_bytes
FROM pg_stat_user_indexes s
JOIN pg_index i ON s.indexrelid = i.indexrelid
WHERE s.idx_scan = 0
AND NOT i.indisprimary
AND NOT i.indisunique
AND s.schemaname NOT IN ('{PG_CATALOG}', '{INFORMATION_SCHEMA}')
"
);
let (unused_count, unused_size_bytes): (i64, i64) =
sqlx::query_as(&unused_query).fetch_one(pool).await?;
let invalid_query = format!(
r"
SELECT COUNT(*)::BIGINT as invalid_count
FROM pg_index i
JOIN pg_class c ON i.indexrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE NOT i.indisvalid
AND n.nspname NOT IN ('{PG_CATALOG}', '{INFORMATION_SCHEMA}')
"
);
let (invalid_count,): (i64,) = sqlx::query_as(&invalid_query).fetch_one(pool).await?;
self.unused_count.set(i64_to_f64(unused_count));
self.unused_size_bytes.set(i64_to_f64(unused_size_bytes));
self.invalid_count.set(i64_to_f64(invalid_count));
Ok(())
})
}
fn enabled_by_default(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[allow(clippy::expect_used)]
async fn test_unused_index_collector_collects_from_database() {
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| String::new());
if database_url.is_empty() {
eprintln!("Skipping test: DATABASE_URL not set");
return;
}
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.expect("Failed to connect to database");
let collector = UnusedIndexCollector::new();
let registry = Registry::new();
let result_reg = collector.register_metrics(®istry);
assert!(result_reg.is_ok(), "Failed to register metrics");
let result = collector.collect(&pool).await;
assert!(
result.is_ok(),
"Collection should succeed: {:?}",
result.err()
);
let metrics = registry.gather();
assert!(!metrics.is_empty(), "Should have collected metrics");
let metric_names: Vec<String> = metrics.iter().map(|m| m.name().to_string()).collect();
assert!(metric_names.contains(&"pg_index_unused_count".to_string()));
assert!(metric_names.contains(&"pg_index_unused_size_bytes".to_string()));
assert!(metric_names.contains(&"pg_index_invalid_count".to_string()));
}
#[test]
fn test_unused_index_collector_name() {
let collector = UnusedIndexCollector::new();
assert_eq!(collector.name(), "index_unused");
}
}