diff --git a/llms.txt b/llms.txt index 2a0187b..3cd5a02 100644 --- a/llms.txt +++ b/llms.txt @@ -27,6 +27,10 @@ Commands are organized into namespaces. **Always use the full command path.** | Index analysis | `pgcrate dba indexes` | | Query plan analysis | `pgcrate dba explain "SELECT..."` | | Disk usage | `pgcrate dba storage` | +| Stale statistics | `pgcrate dba stats-age` | +| Checkpoint health | `pgcrate dba checkpoints` | +| Autovacuum status | `pgcrate dba autovacuum-progress` | +| Config review | `pgcrate dba config` | | Describe table | `pgcrate inspect table ` | | Schema diff | `pgcrate inspect diff --to ` | | List extensions | `pgcrate inspect extensions` | @@ -45,7 +49,7 @@ pgcrate │ ├── locks # Blocking locks, long transactions │ ├── sequences # Sequence exhaustion │ ├── xid # Transaction ID wraparound -│ ├── indexes # Missing/unused/duplicate indexes +│ ├── indexes # Missing/unused/duplicate/FK-without-index │ ├── vacuum # Dead tuple ratios, vacuum health │ ├── bloat # Table/index bloat estimates │ ├── replication # Streaming replication status @@ -53,6 +57,10 @@ pgcrate │ ├── connections # Connection usage vs max_connections │ ├── explain # Query plan analysis │ ├── storage # Disk usage analysis +│ ├── stats-age # Tables with stale statistics +│ ├── checkpoints # Checkpoint frequency and health +│ ├── autovacuum-progress # Currently running autovacuum +│ ├── config # PostgreSQL configuration review │ └── fix # Remediation commands │ ├── sequence # Upgrade sequence type │ ├── index # Drop unused index @@ -837,13 +845,17 @@ pgcrate dba triage --include-fixes --json # Include recommended fix actions pgcrate dba locks # Blocking locks and long transactions pgcrate dba xid # Transaction ID wraparound analysis pgcrate dba sequences # Sequence exhaustion check -pgcrate dba indexes # Missing, unused, duplicate indexes +pgcrate dba indexes # Missing, unused, duplicate, FK-without-index pgcrate dba vacuum # Table bloat and vacuum health pgcrate dba bloat # Estimate table and index bloat pgcrate dba replication # Streaming replication health pgcrate dba queries # Top queries from pg_stat_statements pgcrate dba connections # Connection usage vs max_connections pgcrate dba storage # Disk usage (tables, indexes, TOAST) +pgcrate dba stats-age # Tables with stale statistics +pgcrate dba checkpoints # Checkpoint frequency and WAL health +pgcrate dba autovacuum-progress # Currently running autovacuum operations +pgcrate dba config # PostgreSQL configuration review # Query plan analysis pgcrate dba explain "SELECT * FROM users WHERE email = 'test@example.com'" @@ -957,6 +969,10 @@ Currently, `--json` is supported for these commands: - `dba fix bloat` - REINDEX result - `dba explain` - Query plan analysis - `dba storage` - Disk usage analysis +- `dba stats-age` - Statistics freshness analysis +- `dba checkpoints` - Checkpoint health analysis +- `dba autovacuum-progress` - Running autovacuum operations +- `dba config` - Configuration review with suggestions For unsupported commands, `--json` returns a JSON error: `"--json not supported for '' yet"`. diff --git a/src/commands/autovacuum_progress.rs b/src/commands/autovacuum_progress.rs new file mode 100644 index 0000000..7a1183c --- /dev/null +++ b/src/commands/autovacuum_progress.rs @@ -0,0 +1,245 @@ +//! Autovacuum-progress command: Show currently running autovacuum operations. +//! +//! Uses pg_stat_progress_vacuum (PostgreSQL 9.6+) to show: +//! - Which tables are being vacuumed +//! - Current phase and progress +//! - Dead tuples collected +//! +//! This is purely informational - no status thresholds. + +use anyhow::Result; +use serde::Serialize; +use tokio_postgres::Client; + +/// A currently running autovacuum operation +#[derive(Debug, Clone, Serialize)] +pub struct AutovacuumProgress { + pub pid: i32, + pub database: String, + pub table: String, + pub phase: String, + pub heap_blks_total: i64, + pub heap_blks_scanned: i64, + pub heap_blks_vacuumed: i64, + pub progress_pct: f64, + pub dead_tuples_collected: i64, + pub index_vacuum_count: i64, + pub running_seconds: f64, +} + +/// Full autovacuum progress results +#[derive(Debug, Serialize)] +pub struct AutovacuumProgressResult { + pub workers: Vec, + pub count: usize, +} + +/// Run autovacuum progress check +pub async fn run_autovacuum_progress(client: &Client) -> Result { + // Check if pg_stat_progress_vacuum exists (PostgreSQL 9.6+) + let version_check = r#" + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'pg_catalog' + AND table_name = 'pg_stat_progress_vacuum' + ) + "#; + + let has_view: bool = client.query_one(version_check, &[]).await?.get(0); + + if !has_view { + // Return empty result for older PostgreSQL versions + return Ok(AutovacuumProgressResult { + workers: Vec::new(), + count: 0, + }); + } + + // Check PG version for column name compatibility + // PG17+ renamed num_dead_tuples -> num_dead_item_ids + let version_query = "SELECT current_setting('server_version_num')::int"; + let version_num: i32 = client.query_one(version_query, &[]).await?.get(0); + + // Use version-appropriate column name for dead tuple count + let dead_tuple_col = if version_num >= 170000 { + "p.num_dead_item_ids" + } else { + "p.num_dead_tuples" + }; + + let query = format!( + r#" + SELECT + p.pid, + d.datname AS database, + p.relid::regclass::text AS table_name, + p.phase, + p.heap_blks_total, + p.heap_blks_scanned, + p.heap_blks_vacuumed, + p.index_vacuum_count, + {} AS num_dead_tuples, + a.query_start, + EXTRACT(EPOCH FROM (now() - a.query_start)) AS running_seconds + FROM pg_stat_progress_vacuum p + JOIN pg_stat_activity a ON a.pid = p.pid + JOIN pg_database d ON d.oid = p.datid + ORDER BY a.query_start + "#, + dead_tuple_col + ); + + let rows = client.query(&query, &[]).await?; + + let mut workers = Vec::new(); + for row in rows { + let heap_blks_total: i64 = row.get("heap_blks_total"); + let heap_blks_scanned: i64 = row.get("heap_blks_scanned"); + + let progress_pct = if heap_blks_total > 0 { + (100.0 * heap_blks_scanned as f64) / heap_blks_total as f64 + } else { + 0.0 + }; + + workers.push(AutovacuumProgress { + pid: row.get("pid"), + database: row.get("database"), + table: row.get("table_name"), + phase: row.get("phase"), + heap_blks_total, + heap_blks_scanned, + heap_blks_vacuumed: row.get("heap_blks_vacuumed"), + progress_pct, + dead_tuples_collected: row.get("num_dead_tuples"), + index_vacuum_count: row.get("index_vacuum_count"), + running_seconds: row.get("running_seconds"), + }); + } + + let count = workers.len(); + Ok(AutovacuumProgressResult { workers, count }) +} + +/// Format duration for display +fn format_duration(seconds: f64) -> String { + if seconds >= 3600.0 { + format!("{:.0} hours", seconds / 3600.0) + } else if seconds >= 60.0 { + let mins = (seconds / 60.0).floor(); + let secs = seconds % 60.0; + format!("{:.0}m {:.0}s", mins, secs) + } else { + format!("{:.0} seconds", seconds) + } +} + +/// Format large numbers +fn format_number(n: i64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.1}K", n as f64 / 1_000.0) + } else { + format!("{}", n) + } +} + +/// Print autovacuum progress in human-readable format +pub fn print_human(result: &AutovacuumProgressResult, _quiet: bool) { + println!("AUTOVACUUM IN PROGRESS"); + println!("======================"); + println!(); + + if result.workers.is_empty() { + println!("No autovacuum running. \u{2713} (all tables healthy)"); + return; + } + + println!( + "Currently running: {} autovacuum worker{}", + result.count, + if result.count == 1 { "" } else { "s" } + ); + println!(); + + for worker in &result.workers { + println!(" {}", worker.table); + println!(" Database: {}", worker.database); + println!(" Phase: {}", worker.phase); + println!(" Progress: {:.0}%", worker.progress_pct); + println!( + " Heap blocks: {} / {} scanned", + format_number(worker.heap_blks_scanned), + format_number(worker.heap_blks_total) + ); + println!( + " Dead tuples collected: {}", + format_number(worker.dead_tuples_collected) + ); + if worker.index_vacuum_count > 0 { + println!(" Index vacuum passes: {}", worker.index_vacuum_count); + } + println!( + " Running for: {}", + format_duration(worker.running_seconds) + ); + println!(" PID: {}", worker.pid); + println!(); + } +} + +/// Print autovacuum progress as JSON with schema versioning +pub fn print_json( + result: &AutovacuumProgressResult, + timeouts: Option, +) -> Result<()> { + use crate::output::{schema, DiagnosticOutput, Severity}; + + // This is purely informational, always healthy + let severity = Severity::Healthy; + + let output = match timeouts { + Some(t) => { + DiagnosticOutput::with_timeouts(schema::AUTOVACUUM_PROGRESS, result, severity, t) + } + None => DiagnosticOutput::new(schema::AUTOVACUUM_PROGRESS, result, severity), + }; + output.print()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_duration_hours() { + assert_eq!(format_duration(7200.0), "2 hours"); + } + + #[test] + fn test_format_duration_minutes() { + assert_eq!(format_duration(125.0), "2m 5s"); + } + + #[test] + fn test_format_duration_seconds() { + assert_eq!(format_duration(45.0), "45 seconds"); + } + + #[test] + fn test_format_number_millions() { + assert_eq!(format_number(2_500_000), "2.5M"); + } + + #[test] + fn test_format_number_thousands() { + assert_eq!(format_number(5_500), "5.5K"); + } + + #[test] + fn test_format_number_small() { + assert_eq!(format_number(42), "42"); + } +} diff --git a/src/commands/checkpoints.rs b/src/commands/checkpoints.rs new file mode 100644 index 0000000..f5783f2 --- /dev/null +++ b/src/commands/checkpoints.rs @@ -0,0 +1,400 @@ +//! Checkpoints command: Analyze checkpoint frequency and health. +//! +//! Frequent checkpoints indicate: +//! - checkpoint_timeout too low +//! - max_wal_size too small +//! - Heavy write workload overwhelming WAL +//! +//! This command helps diagnose WAL-related performance issues. + +use anyhow::Result; +use serde::Serialize; +use tokio_postgres::Client; + +/// Thresholds for checkpoint health +const REQUESTED_PCT_WARNING: f64 = 20.0; +const REQUESTED_PCT_CRITICAL: f64 = 50.0; +const BACKEND_WRITE_PCT_WARNING: f64 = 10.0; + +/// Checkpoint status level +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CheckpointStatus { + Healthy, + Warning, + Critical, +} + +impl CheckpointStatus { + pub fn emoji(&self) -> &'static str { + match self { + CheckpointStatus::Healthy => "✓", + CheckpointStatus::Warning => "⚠", + CheckpointStatus::Critical => "✗", + } + } +} + +/// Checkpoint statistics from pg_stat_bgwriter +#[derive(Debug, Clone, Serialize)] +pub struct CheckpointStats { + /// Number of scheduled checkpoints (timed) + pub checkpoints_timed: i64, + /// Number of requested checkpoints (forced, e.g., WAL full) + pub checkpoints_requested: i64, + /// Percentage of checkpoints that were forced + pub requested_pct: f64, + /// Total time spent writing checkpoint data (ms) + pub checkpoint_write_time_ms: f64, + /// Total time spent syncing checkpoint data (ms) + pub checkpoint_sync_time_ms: f64, + /// Buffers written during checkpoints + pub buffers_checkpoint: i64, + /// Buffers written by background writer + pub buffers_bgwriter: i64, + /// Buffers written directly by backends (bad for performance) + pub buffers_backend: i64, + /// Percentage of buffers written by backends + pub backend_write_pct: f64, + /// Times bgwriter stopped due to maxwritten_clean + pub maxwritten_clean: i64, + /// When stats were last reset + #[serde(skip_serializing_if = "Option::is_none")] + pub stats_since: Option, + /// Overall status + pub status: CheckpointStatus, + /// Specific warnings found + pub warnings: Vec, +} + +/// Full checkpoint analysis results +#[derive(Debug, Serialize)] +pub struct CheckpointsResult { + pub stats: CheckpointStats, + pub overall_status: CheckpointStatus, +} + +/// Run checkpoint analysis +pub async fn run_checkpoints(client: &Client) -> Result { + // Check PG version - PG17+ moved checkpoint stats to pg_stat_checkpointer + let version_query = "SELECT current_setting('server_version_num')::int"; + let version_num: i32 = client.query_one(version_query, &[]).await?.get(0); + + let ( + checkpoints_timed, + checkpoints_requested, + checkpoint_write_time, + checkpoint_sync_time, + buffers_checkpoint, + stats_reset, + ): ( + i64, + i64, + f64, + f64, + i64, + Option>, + ); + + if version_num >= 170000 { + // PostgreSQL 17+: checkpoint stats in pg_stat_checkpointer + let query = r#" + SELECT + num_timed, + num_requested, + write_time, + sync_time, + buffers_written, + stats_reset + FROM pg_stat_checkpointer + "#; + let row = client.query_one(query, &[]).await?; + checkpoints_timed = row.get("num_timed"); + checkpoints_requested = row.get("num_requested"); + checkpoint_write_time = row.get("write_time"); + checkpoint_sync_time = row.get("sync_time"); + buffers_checkpoint = row.get("buffers_written"); + stats_reset = row.get("stats_reset"); + } else { + // PostgreSQL <17: checkpoint stats in pg_stat_bgwriter + let query = r#" + SELECT + checkpoints_timed, + checkpoints_req, + checkpoint_write_time, + checkpoint_sync_time, + buffers_checkpoint, + stats_reset + FROM pg_stat_bgwriter + "#; + let row = client.query_one(query, &[]).await?; + checkpoints_timed = row.get("checkpoints_timed"); + checkpoints_requested = row.get("checkpoints_req"); + checkpoint_write_time = row.get("checkpoint_write_time"); + checkpoint_sync_time = row.get("checkpoint_sync_time"); + buffers_checkpoint = row.get("buffers_checkpoint"); + stats_reset = row.get("stats_reset"); + } + + // Buffer stats are always in pg_stat_bgwriter + let bgwriter_query = r#" + SELECT + buffers_clean, + buffers_alloc, + maxwritten_clean + FROM pg_stat_bgwriter + "#; + let bgwriter_row = client.query_one(bgwriter_query, &[]).await?; + let buffers_bgwriter: i64 = bgwriter_row.get("buffers_clean"); + let maxwritten_clean: i64 = bgwriter_row.get("maxwritten_clean"); + + // For backends writing, we need pg_stat_io in PG16+ or estimate from buffers_backend + // In PG17+, buffers_backend was removed - we'll use 0 as a fallback + let buffers_backend: i64 = if version_num >= 160000 { + // PG16+ has pg_stat_io but structure is complex; use 0 for now + 0 + } else { + // Try to get from pg_stat_bgwriter if available + let backend_query = "SELECT COALESCE((SELECT buffers_backend FROM pg_stat_bgwriter), 0)"; + client + .query_one(backend_query, &[]) + .await + .map(|r| r.get(0)) + .unwrap_or(0) + }; + + let total_checkpoints = checkpoints_timed + checkpoints_requested; + let requested_pct = if total_checkpoints > 0 { + (100.0 * checkpoints_requested as f64) / total_checkpoints as f64 + } else { + 0.0 + }; + + let total_buffers = buffers_checkpoint + buffers_bgwriter + buffers_backend; + let backend_write_pct = if total_buffers > 0 { + (100.0 * buffers_backend as f64) / total_buffers as f64 + } else { + 0.0 + }; + + // Determine status and collect warnings + let mut warnings = Vec::new(); + let mut status = CheckpointStatus::Healthy; + + if requested_pct >= REQUESTED_PCT_CRITICAL { + status = CheckpointStatus::Critical; + warnings.push(format!( + "{:.0}% of checkpoints are forced (requested) - max_wal_size likely too small", + requested_pct + )); + } else if requested_pct >= REQUESTED_PCT_WARNING { + status = CheckpointStatus::Warning; + warnings.push(format!( + "{:.0}% of checkpoints are forced - consider increasing max_wal_size", + requested_pct + )); + } + + if backend_write_pct >= BACKEND_WRITE_PCT_WARNING { + if status == CheckpointStatus::Healthy { + status = CheckpointStatus::Warning; + } + warnings.push(format!( + "Backends writing {:.0}% of buffers directly - bgwriter may need tuning", + backend_write_pct + )); + } + + if maxwritten_clean > 0 { + if status == CheckpointStatus::Healthy { + status = CheckpointStatus::Warning; + } + warnings.push(format!( + "bgwriter stopped {} times due to maxwritten_clean - consider increasing bgwriter_lru_maxpages", + maxwritten_clean + )); + } + + let stats = CheckpointStats { + checkpoints_timed, + checkpoints_requested, + requested_pct, + checkpoint_write_time_ms: checkpoint_write_time, + checkpoint_sync_time_ms: checkpoint_sync_time, + buffers_checkpoint, + buffers_bgwriter, + buffers_backend, + backend_write_pct, + maxwritten_clean, + stats_since: stats_reset.map(|t| t.to_rfc3339()), + status, + warnings, + }; + + Ok(CheckpointsResult { + stats, + overall_status: status, + }) +} + +/// Format bytes for display +fn format_bytes(bytes: i64) -> String { + // PostgreSQL buffer = 8KB + let total_bytes = bytes * 8192; + if total_bytes >= 1024 * 1024 * 1024 { + format!("{:.1} GB", total_bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } else if total_bytes >= 1024 * 1024 { + format!("{:.1} MB", total_bytes as f64 / (1024.0 * 1024.0)) + } else if total_bytes >= 1024 { + format!("{:.1} KB", total_bytes as f64 / 1024.0) + } else { + format!("{} bytes", total_bytes) + } +} + +/// Format duration for display +fn format_duration(ms: f64) -> String { + if ms >= 3600000.0 { + format!("{:.1} hours", ms / 3600000.0) + } else if ms >= 60000.0 { + format!("{:.1} min", ms / 60000.0) + } else if ms >= 1000.0 { + format!("{:.1} sec", ms / 1000.0) + } else { + format!("{:.0} ms", ms) + } +} + +/// Print checkpoints in human-readable format +pub fn print_human(result: &CheckpointsResult, _quiet: bool) { + let stats = &result.stats; + + println!("CHECKPOINT ANALYSIS"); + println!("==================="); + println!(); + + if let Some(ref since) = stats.stats_since { + println!("Statistics since: {}", since); + println!(); + } + + println!("Checkpoint Frequency:"); + let total = stats.checkpoints_timed + stats.checkpoints_requested; + println!(" Total checkpoints: {}", total); + println!( + " Timed (scheduled): {} ({:.0}%)", + stats.checkpoints_timed, + if total > 0 { + 100.0 - stats.requested_pct + } else { + 0.0 + } + ); + println!( + " Requested (forced): {} ({:.0}%)", + stats.checkpoints_requested, stats.requested_pct + ); + println!(); + + println!("Checkpoint Performance:"); + println!( + " Write time: {}", + format_duration(stats.checkpoint_write_time_ms) + ); + println!( + " Sync time: {}", + format_duration(stats.checkpoint_sync_time_ms) + ); + println!(); + + println!("Buffer Writes:"); + println!( + " By checkpoints: {}", + format_bytes(stats.buffers_checkpoint) + ); + println!( + " By bgwriter: {}", + format_bytes(stats.buffers_bgwriter) + ); + let backend_marker = if stats.backend_write_pct >= BACKEND_WRITE_PCT_WARNING { + " ⚠" + } else { + "" + }; + println!( + " By backends: {} ({:.0}%){}", + format_bytes(stats.buffers_backend), + stats.backend_write_pct, + backend_marker + ); + + // Warnings + if !stats.warnings.is_empty() { + println!(); + println!("{} Warnings:", stats.status.emoji()); + for warning in &stats.warnings { + println!(" - {}", warning); + } + } else { + println!(); + println!("{} Checkpoint health looks good", stats.status.emoji()); + } +} + +/// Print checkpoints as JSON with schema versioning +pub fn print_json( + result: &CheckpointsResult, + timeouts: Option, +) -> Result<()> { + use crate::output::{schema, DiagnosticOutput, Severity}; + + let severity = match result.overall_status { + CheckpointStatus::Healthy => Severity::Healthy, + CheckpointStatus::Warning => Severity::Warning, + CheckpointStatus::Critical => Severity::Critical, + }; + + let output = match timeouts { + Some(t) => DiagnosticOutput::with_timeouts(schema::CHECKPOINTS, result, severity, t), + None => DiagnosticOutput::new(schema::CHECKPOINTS, result, severity), + }; + output.print()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_bytes_gb() { + // 1GB / 8KB per buffer = 131072 buffers + assert_eq!(format_bytes(131072), "1.0 GB"); + } + + #[test] + fn test_format_bytes_mb() { + // 100MB / 8KB per buffer = 12800 buffers + assert_eq!(format_bytes(12800), "100.0 MB"); + } + + #[test] + fn test_format_duration_hours() { + assert_eq!(format_duration(7200000.0), "2.0 hours"); + } + + #[test] + fn test_format_duration_minutes() { + assert_eq!(format_duration(120000.0), "2.0 min"); + } + + #[test] + fn test_format_duration_seconds() { + assert_eq!(format_duration(5000.0), "5.0 sec"); + } + + #[test] + fn test_format_duration_ms() { + assert_eq!(format_duration(500.0), "500 ms"); + } +} diff --git a/src/commands/config.rs b/src/commands/config.rs new file mode 100644 index 0000000..8e3bde7 --- /dev/null +++ b/src/commands/config.rs @@ -0,0 +1,493 @@ +//! Config command: Review PostgreSQL configuration settings. +//! +//! Compares current settings against common best practices. Note that optimal +//! settings depend heavily on workload (OLTP vs OLAP vs mixed) and hardware. +//! +//! IMPORTANT: Recommendations are suggestions, not requirements. Always test +//! changes in non-production first. + +use anyhow::Result; +use serde::Serialize; +use tokio_postgres::Client; + +/// Status for config settings - never Critical (these are just suggestions) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ConfigStatus { + /// Setting looks reasonable + Ok, + /// Suggestion for potential improvement (not a problem) + Suggestion, +} + +impl ConfigStatus { + pub fn emoji(&self) -> &'static str { + match self { + ConfigStatus::Ok => "✓", + ConfigStatus::Suggestion => "⚠", + } + } +} + +/// A configuration setting with optional recommendation +#[derive(Debug, Clone, Serialize)] +pub struct ConfigSetting { + pub name: String, + pub current_value: String, + pub current_value_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recommendation: Option, + pub status: ConfigStatus, + pub category: String, + /// True if changing this setting requires a server restart + pub requires_restart: bool, +} + +/// Full configuration review results +#[derive(Debug, Serialize)] +pub struct ConfigResult { + pub settings: Vec, + pub postgres_version: String, + pub has_suggestions: bool, + /// Always included disclaimer + pub disclaimer: String, +} + +/// Settings we review and their categories +const SETTINGS_TO_CHECK: &[(&str, &str)] = &[ + // Memory + ("shared_buffers", "memory"), + ("effective_cache_size", "memory"), + ("work_mem", "memory"), + ("maintenance_work_mem", "memory"), + // Connections + ("max_connections", "connections"), + // WAL + ("wal_buffers", "wal"), + ("checkpoint_timeout", "wal"), + ("max_wal_size", "wal"), + ("min_wal_size", "wal"), + // Planner + ("random_page_cost", "planner"), + ("effective_io_concurrency", "planner"), + // Parallelism + ("max_worker_processes", "parallelism"), + ("max_parallel_workers", "parallelism"), + ("max_parallel_workers_per_gather", "parallelism"), +]; + +/// Parse PostgreSQL memory setting to bytes +fn parse_memory_setting(value: &str, unit: Option<&str>) -> Option { + let value = value.trim(); + + // If unit is provided separately (from pg_settings), use it + if let Some(u) = unit { + if let Ok(n) = value.parse::() { + return match u { + "8kB" => Some(n * 8 * 1024), + "kB" => Some(n * 1024), + "MB" => Some(n * 1024 * 1024), + "GB" => Some(n * 1024 * 1024 * 1024), + _ => Some(n), + }; + } + } + + // Try parsing with suffix + if let Some(n) = value.strip_suffix("GB") { + n.trim().parse::().ok().map(|v| v * 1024 * 1024 * 1024) + } else if let Some(n) = value.strip_suffix("MB") { + n.trim().parse::().ok().map(|v| v * 1024 * 1024) + } else if let Some(n) = value.strip_suffix("kB") { + n.trim().parse::().ok().map(|v| v * 1024) + } else { + value.parse::().ok() + } +} + +/// Format bytes for display +fn format_bytes(bytes: i64) -> String { + if bytes >= 1024 * 1024 * 1024 { + format!("{} GB", bytes / (1024 * 1024 * 1024)) + } else if bytes >= 1024 * 1024 { + format!("{} MB", bytes / (1024 * 1024)) + } else if bytes >= 1024 { + format!("{} KB", bytes / 1024) + } else { + format!("{} bytes", bytes) + } +} + +/// Simple word-wrap for long strings +fn wrap_text(text: &str, width: usize) -> Vec { + let mut lines = Vec::new(); + let mut current_line = String::new(); + + for word in text.split_whitespace() { + if current_line.is_empty() { + current_line = word.to_string(); + } else if current_line.len() + 1 + word.len() <= width { + current_line.push(' '); + current_line.push_str(word); + } else { + lines.push(current_line); + current_line = word.to_string(); + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + lines +} + +/// Generate recommendation for a setting +fn get_recommendation( + name: &str, + value: &str, + _unit: Option<&str>, + bytes: Option, +) -> Option<(String, ConfigStatus)> { + match name { + "shared_buffers" => { + // 128MB is the typical default, often too low for production + if let Some(b) = bytes { + if b <= 128 * 1024 * 1024 { + return Some(( + "Default value (128MB). For dedicated database servers, 25% of RAM (max ~8GB) is typical.".to_string(), + ConfigStatus::Suggestion, + )); + } + } + None + } + "effective_cache_size" => { + // Should be ~50-75% of available RAM + if let Some(b) = bytes { + if b <= 512 * 1024 * 1024 { + return Some(( + "Low value. Should typically be 50-75% of total system RAM.".to_string(), + ConfigStatus::Suggestion, + )); + } + } + None + } + "work_mem" => { + // 4MB is default, often fine but depends on query complexity + if let Some(b) = bytes { + if b <= 4 * 1024 * 1024 { + return Some(( + "Default value (4MB). Increase for complex sorts/hashes, but be careful: this is per-operation, not per-connection.".to_string(), + ConfigStatus::Ok, // Just informational, not a suggestion + )); + } + } + None + } + "maintenance_work_mem" => { + // 64MB is default, often too low for large tables + if let Some(b) = bytes { + if b <= 64 * 1024 * 1024 { + return Some(( + "Default value (64MB). For servers with large tables, 256MB-1GB can speed up VACUUM and index creation.".to_string(), + ConfigStatus::Suggestion, + )); + } + } + None + } + "max_connections" => { + // 100 is default, often either too low or too high + if let Ok(n) = value.parse::() { + if n > 200 { + return Some(( + "High value. Consider using a connection pooler (PgBouncer) instead of increasing max_connections.".to_string(), + ConfigStatus::Suggestion, + )); + } + } + None + } + "max_wal_size" => { + // 1GB is default, may cause frequent checkpoints under write load + if let Some(b) = bytes { + if b <= 1024 * 1024 * 1024 { + return Some(( + "Default value (1GB). For write-heavy workloads, 2-4GB can reduce checkpoint frequency.".to_string(), + ConfigStatus::Suggestion, + )); + } + } + None + } + "random_page_cost" => { + // 4.0 is default, tuned for spinning disks + if let Ok(n) = value.parse::() { + if n >= 4.0 { + return Some(( + "Default value (4.0) is tuned for spinning disks. For SSDs, 1.1-1.5 is more appropriate.".to_string(), + ConfigStatus::Suggestion, + )); + } + } + None + } + "effective_io_concurrency" => { + // 1 is default on many systems, too low for SSDs + if let Ok(n) = value.parse::() { + if n <= 1 { + return Some(( + "Default value (1). For SSDs, 200 is a common recommendation.".to_string(), + ConfigStatus::Suggestion, + )); + } + } + None + } + _ => None, + } +} + +/// Run configuration review +pub async fn run_config(client: &Client) -> Result { + // Get PostgreSQL version + let version_query = "SELECT version()"; + let version_row = client.query_one(version_query, &[]).await?; + let postgres_version: String = version_row.get(0); + + // Get settings + let names: Vec<&str> = SETTINGS_TO_CHECK.iter().map(|(n, _)| *n).collect(); + let query = r#" + SELECT name, setting, unit, short_desc, context + FROM pg_settings + WHERE name = ANY($1) + "#; + + let rows = client.query(query, &[&names]).await?; + + let mut settings = Vec::new(); + let mut has_suggestions = false; + + // Create a map for category lookup + let category_map: std::collections::HashMap<&str, &str> = + SETTINGS_TO_CHECK.iter().copied().collect(); + + for row in rows { + let name: String = row.get("name"); + let value: String = row.get("setting"); + let unit: Option = row.get("unit"); + let context: String = row.get("context"); + + // postmaster context means restart required; sighup means reload only + let requires_restart = context == "postmaster"; + + let bytes = parse_memory_setting(&value, unit.as_deref()); + let (recommendation, status) = get_recommendation(&name, &value, unit.as_deref(), bytes) + .unwrap_or((String::new(), ConfigStatus::Ok)); + + let recommendation = if recommendation.is_empty() { + None + } else { + if status == ConfigStatus::Suggestion { + has_suggestions = true; + } + Some(recommendation) + }; + + let category = category_map + .get(name.as_str()) + .unwrap_or(&"other") + .to_string(); + + // Format current value with unit for display + let display_value = if let Some(ref u) = unit { + if let Some(b) = bytes { + format!("{} ({})", value, format_bytes(b)) + } else { + format!("{} {}", value, u) + } + } else { + value.clone() + }; + + settings.push(ConfigSetting { + name, + current_value: display_value, + current_value_bytes: bytes, + unit, + recommendation, + status, + category, + requires_restart, + }); + } + + // Sort by category then name + settings.sort_by(|a, b| { + let cat_order = ["memory", "connections", "wal", "planner", "parallelism"]; + let a_idx = cat_order + .iter() + .position(|&c| c == a.category) + .unwrap_or(99); + let b_idx = cat_order + .iter() + .position(|&c| c == b.category) + .unwrap_or(99); + (a_idx, &a.name).cmp(&(b_idx, &b.name)) + }); + + Ok(ConfigResult { + settings, + postgres_version, + has_suggestions, + disclaimer: "Recommendations are suggestions based on common patterns. Optimal settings depend on your specific workload, hardware, and requirements. Always test changes in non-production first.".to_string(), + }) +} + +/// Print config in human-readable format +pub fn print_human(result: &ConfigResult, _quiet: bool) { + println!("CONFIGURATION REVIEW"); + println!("===================="); + println!(); + println!( + "PostgreSQL: {}", + result + .postgres_version + .lines() + .next() + .unwrap_or(&result.postgres_version) + ); + println!(); + + let mut current_category = String::new(); + + for setting in &result.settings { + // Print category header + if setting.category != current_category { + current_category = setting.category.clone(); + let header = match current_category.as_str() { + "memory" => "Memory Settings:", + "connections" => "Connection Settings:", + "wal" => "WAL Settings:", + "planner" => "Planner Settings:", + "parallelism" => "Parallelism Settings:", + _ => "Other Settings:", + }; + println!("{}", header); + } + + // Print setting (with restart indicator if needed) + let restart_marker = if setting.requires_restart { + " [restart]" + } else { + "" + }; + println!( + " {} {:30} {}{}", + setting.status.emoji(), + format!("{}:", setting.name), + setting.current_value, + restart_marker + ); + + // Print recommendation if any + if let Some(ref rec) = setting.recommendation { + // Wrap long recommendations + for line in wrap_text(rec, 60) { + println!(" {}", line); + } + } + } + + // Disclaimer + println!(); + println!("Note: {}", result.disclaimer); +} + +/// Print config as JSON with schema versioning +pub fn print_json( + result: &ConfigResult, + timeouts: Option, +) -> Result<()> { + use crate::output::{schema, DiagnosticOutput, Severity}; + + // Config review is never Critical - just suggestions + let severity = if result.has_suggestions { + Severity::Warning + } else { + Severity::Healthy + }; + + let output = match timeouts { + Some(t) => DiagnosticOutput::with_timeouts(schema::CONFIG, result, severity, t), + None => DiagnosticOutput::new(schema::CONFIG, result, severity), + }; + output.print()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_memory_setting_with_unit() { + // pg_settings returns value in 8kB units for shared_buffers + assert_eq!( + parse_memory_setting("16384", Some("8kB")), + Some(16384 * 8 * 1024) + ); + } + + #[test] + fn test_parse_memory_setting_mb() { + assert_eq!(parse_memory_setting("256MB", None), Some(256 * 1024 * 1024)); + } + + #[test] + fn test_parse_memory_setting_gb() { + assert_eq!( + parse_memory_setting("4GB", None), + Some(4 * 1024 * 1024 * 1024) + ); + } + + #[test] + fn test_format_bytes_gb() { + assert_eq!(format_bytes(2 * 1024 * 1024 * 1024), "2 GB"); + } + + #[test] + fn test_format_bytes_mb() { + assert_eq!(format_bytes(256 * 1024 * 1024), "256 MB"); + } + + #[test] + fn test_shared_buffers_default_suggestion() { + let (rec, status) = get_recommendation( + "shared_buffers", + "16384", + Some("8kB"), + Some(128 * 1024 * 1024), + ) + .unwrap(); + assert_eq!(status, ConfigStatus::Suggestion); + assert!(rec.contains("128MB")); + } + + #[test] + fn test_shared_buffers_large_ok() { + // 4GB shared_buffers should not get a suggestion + let result = get_recommendation( + "shared_buffers", + "524288", + Some("8kB"), + Some(4 * 1024 * 1024 * 1024), + ); + assert!(result.is_none()); + } +} diff --git a/src/commands/indexes.rs b/src/commands/indexes.rs index f1b89c3..5b1110a 100644 --- a/src/commands/indexes.rs +++ b/src/commands/indexes.rs @@ -4,6 +4,7 @@ //! - Missing indexes cause slow sequential scans //! - Unused indexes waste space and slow writes //! - Duplicate indexes provide no benefit over their counterparts +//! - Foreign keys without indexes cause slow DELETEs and JOINs use anyhow::Result; use serde::Serialize; @@ -13,6 +14,57 @@ use tokio_postgres::Client; const MIN_SEQ_SCANS_FOR_MISSING: i64 = 1000; const MIN_TABLE_SIZE_BYTES: i64 = 10 * 1024 * 1024; // 10MB +/// Thresholds for FK index recommendations (based on table row count) +const FK_CRITICAL_ROWS: i64 = 100_000; +const FK_WARNING_ROWS: i64 = 10_000; + +/// Status for FK index findings +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum FkIndexStatus { + /// Table has < 10K rows, missing index is low impact + Info, + /// Table has 10K-100K rows, missing index may cause issues + Warning, + /// Table has > 100K rows, missing index will cause performance problems + Critical, +} + +impl FkIndexStatus { + pub fn from_row_count(rows: i64) -> Self { + if rows >= FK_CRITICAL_ROWS { + FkIndexStatus::Critical + } else if rows >= FK_WARNING_ROWS { + FkIndexStatus::Warning + } else { + FkIndexStatus::Info + } + } + + pub fn emoji(&self) -> &'static str { + match self { + FkIndexStatus::Info => "ℹ", + FkIndexStatus::Warning => "⚠", + FkIndexStatus::Critical => "✗", + } + } +} + +/// A foreign key column without a supporting index +#[derive(Debug, Clone, Serialize)] +pub struct FkWithoutIndex { + pub schema: String, + pub table: String, + /// FK columns (may be composite) + pub columns: Vec, + pub ref_schema: String, + pub ref_table: String, + pub ref_columns: Vec, + pub constraint_name: String, + pub table_rows: i64, + pub status: FkIndexStatus, +} + /// A table that may need an index #[derive(Debug, Clone, Serialize)] pub struct MissingIndexCandidate { @@ -81,6 +133,7 @@ pub struct IndexesResult { pub missing: Vec, pub unused: Vec, pub duplicates: Vec, + pub fk_without_indexes: Vec, pub total_unused_bytes: i64, pub total_unused_size: String, pub total_duplicate_bytes: i64, @@ -313,6 +366,99 @@ pub async fn get_duplicate_indexes(client: &Client) -> Result Result> { + // This query: + // 1. Gets all FK constraints with their columns (handles composite FKs) + // 2. Checks if any index covers those columns as a leading prefix + // 3. Returns only FKs where no suitable index exists + let query = r#" + WITH fk_info AS ( + SELECT + c.conname AS constraint_name, + n.nspname AS schema_name, + t.relname AS table_name, + nf.nspname AS ref_schema, + tf.relname AS ref_table, + -- Get FK column names in order + array_agg(a.attname ORDER BY x.ordinality) AS fk_columns, + -- Get referenced column names in order + array_agg(af.attname ORDER BY x.ordinality) AS ref_columns, + -- Get FK column attnum array for index matching + c.conkey AS fk_attnum_array + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_class tf ON tf.oid = c.confrelid + JOIN pg_namespace nf ON nf.oid = tf.relnamespace + CROSS JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS x(fk_attnum, ref_attnum, ordinality) + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = x.fk_attnum + JOIN pg_attribute af ON af.attrelid = c.confrelid AND af.attnum = x.ref_attnum + WHERE c.contype = 'f' + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + GROUP BY c.conname, n.nspname, t.relname, nf.nspname, tf.relname, c.conkey + ), + indexed_fks AS ( + -- Check if any index covers the FK columns as a leading prefix + SELECT DISTINCT + fk.constraint_name + FROM fk_info fk + JOIN pg_class t2 ON t2.relname = fk.table_name + JOIN pg_namespace n2 ON n2.oid = t2.relnamespace AND n2.nspname = fk.schema_name + JOIN pg_index ix ON ix.indrelid = t2.oid + WHERE + -- Check that FK columns are a prefix of index columns + -- Convert int2vector to array for comparison, then slice to FK length + (SELECT array_agg(unnest) FROM unnest(ix.indkey) LIMIT array_length(fk.fk_attnum_array, 1)) + = fk.fk_attnum_array + ) + SELECT + fk.schema_name, + fk.table_name, + fk.fk_columns, + fk.ref_schema, + fk.ref_table, + fk.ref_columns, + fk.constraint_name, + COALESCE(s.n_live_tup, 0) AS table_rows + FROM fk_info fk + LEFT JOIN indexed_fks ifk ON ifk.constraint_name = fk.constraint_name + LEFT JOIN pg_stat_user_tables s ON s.schemaname = fk.schema_name AND s.relname = fk.table_name + WHERE ifk.constraint_name IS NULL -- Only show FKs without indexes + ORDER BY COALESCE(s.n_live_tup, 0) DESC + "#; + + let rows = client.query(query, &[]).await?; + + let mut results = Vec::new(); + for row in rows { + let table_rows: i64 = row.get("table_rows"); + let fk_columns: Vec = row.get("fk_columns"); + let ref_columns: Vec = row.get("ref_columns"); + + results.push(FkWithoutIndex { + schema: row.get("schema_name"), + table: row.get("table_name"), + columns: fk_columns, + ref_schema: row.get("ref_schema"), + ref_table: row.get("ref_table"), + ref_columns, + constraint_name: row.get("constraint_name"), + table_rows, + status: FkIndexStatus::from_row_count(table_rows), + }); + } + + Ok(results) +} + /// Run full index analysis pub async fn run_indexes( client: &Client, @@ -322,6 +468,7 @@ pub async fn run_indexes( let missing = get_missing_index_candidates(client, missing_limit).await?; let unused = get_unused_indexes(client, unused_limit).await?; let duplicates = get_duplicate_indexes(client).await?; + let fk_without_indexes = get_fk_without_indexes(client).await?; let total_unused_bytes: i64 = unused.iter().map(|u| u.index_size_bytes).sum(); let total_duplicate_bytes: i64 = duplicates.iter().map(|d| d.wasted_bytes).sum(); @@ -330,6 +477,7 @@ pub async fn run_indexes( missing, unused, duplicates, + fk_without_indexes, total_unused_bytes, total_unused_size: format_bytes(total_unused_bytes), total_duplicate_bytes, @@ -503,6 +651,70 @@ pub fn print_human(result: &IndexesResult, verbose: bool) { } } + // Foreign keys without indexes + if !result.fk_without_indexes.is_empty() { + has_output = true; + println!("FOREIGN KEYS WITHOUT INDEXES:"); + println!(); + + for fk in &result.fk_without_indexes { + let fk_cols = fk.columns.join(", "); + let ref_cols = fk.ref_columns.join(", "); + println!( + " {} {}.{}({}) → {}.{}({})", + fk.status.emoji(), + fk.schema, + fk.table, + fk_cols, + fk.ref_schema, + fk.ref_table, + ref_cols + ); + println!( + " table rows: ~{} constraint: {}", + format_number(fk.table_rows), + fk.constraint_name + ); + + if verbose { + // Show the CREATE INDEX statement + let idx_name = format!("idx_{}_{}_fk", fk.table, fk.columns.join("_")); + let cols = fk.columns.join(", "); + println!( + " suggested: CREATE INDEX {} ON {}.{} ({});", + idx_name, fk.schema, fk.table, cols + ); + } + } + println!(); + + // Count by severity + let critical_count = result + .fk_without_indexes + .iter() + .filter(|fk| fk.status == FkIndexStatus::Critical) + .count(); + let warning_count = result + .fk_without_indexes + .iter() + .filter(|fk| fk.status == FkIndexStatus::Warning) + .count(); + + if critical_count > 0 { + println!( + " ✗ {} FKs on large tables (>100K rows) - will cause slow DELETEs", + critical_count + ); + } + if warning_count > 0 { + println!( + " ⚠ {} FKs on medium tables (>10K rows) - may cause slow DELETEs", + warning_count + ); + } + println!(); + } + // Summary if has_output { println!("SUMMARY:"); @@ -524,6 +736,9 @@ pub fn print_human(result: &IndexesResult, verbose: bool) { if !result.missing.is_empty() { println!(" Missing candidates: {}", result.missing.len()); } + if !result.fk_without_indexes.is_empty() { + println!(" FKs without index: {}", result.fk_without_indexes.len()); + } } else { println!("No index issues found."); } @@ -617,9 +832,26 @@ pub fn print_json( use crate::output::{schema, DiagnosticOutput, Severity}; // Derive severity from findings + // FK without indexes on large tables is critical (causes slow DELETEs) // Missing indexes with high seq scans are concerning // Large amounts of wasted space from unused/duplicates also warrant attention - let severity = if result + let has_critical_fk = result + .fk_without_indexes + .iter() + .any(|fk| fk.status == FkIndexStatus::Critical); + + let has_warning_fk = result + .fk_without_indexes + .iter() + .any(|fk| fk.status == FkIndexStatus::Warning); + + let severity = if has_critical_fk { + // FK on table with >100K rows without index - will cause slow DELETEs + Severity::Critical + } else if has_warning_fk { + // FK on table with >10K rows without index + Severity::Warning + } else if result .missing .iter() .any(|m| m.seq_scan > 10000 && m.scan_ratio > 100.0) @@ -634,6 +866,7 @@ pub fn print_json( } else if !result.missing.is_empty() || !result.unused.is_empty() || !result.duplicates.is_empty() + || !result.fk_without_indexes.is_empty() { // Some findings - report as warning so automation knows there's something to review Severity::Warning @@ -692,4 +925,34 @@ mod tests { fn test_format_number_small() { assert_eq!(format_number(42), "42"); } + + #[test] + fn test_fk_index_status_info() { + assert_eq!(FkIndexStatus::from_row_count(0), FkIndexStatus::Info); + assert_eq!(FkIndexStatus::from_row_count(9_999), FkIndexStatus::Info); + } + + #[test] + fn test_fk_index_status_warning() { + assert_eq!( + FkIndexStatus::from_row_count(10_000), + FkIndexStatus::Warning + ); + assert_eq!( + FkIndexStatus::from_row_count(99_999), + FkIndexStatus::Warning + ); + } + + #[test] + fn test_fk_index_status_critical() { + assert_eq!( + FkIndexStatus::from_row_count(100_000), + FkIndexStatus::Critical + ); + assert_eq!( + FkIndexStatus::from_row_count(1_000_000), + FkIndexStatus::Critical + ); + } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1d83943..5daeede 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,10 +3,13 @@ //! Each submodule contains related command functions. mod anonymize; +pub mod autovacuum_progress; pub mod bloat; mod bootstrap; pub mod cache; pub mod capabilities; +pub mod checkpoints; +pub mod config; pub mod connections; pub mod context; mod db; @@ -26,6 +29,7 @@ mod seed; pub mod sequences; mod snapshot; mod sql_cmd; +pub mod stats_age; pub mod storage; pub mod triage; pub mod vacuum; diff --git a/src/commands/stats_age.rs b/src/commands/stats_age.rs new file mode 100644 index 0000000..6876a31 --- /dev/null +++ b/src/commands/stats_age.rs @@ -0,0 +1,298 @@ +//! Stats-age command: Identify tables with stale statistics. +//! +//! PostgreSQL's query planner relies on table statistics to estimate row counts +//! and choose optimal join strategies. Stale statistics lead to poor query plans. + +use anyhow::Result; +use serde::Serialize; +use tokio_postgres::Client; + +/// Default thresholds (in days) +const STATS_WARNING_DAYS: f64 = 7.0; +const STATS_CRITICAL_DAYS: f64 = 30.0; +const MIN_ROWS_TO_CARE: i64 = 1000; + +/// Statistics freshness status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum StatsStatus { + Healthy, + Warning, + Critical, +} + +impl StatsStatus { + pub fn from_days(days: Option) -> Self { + match days { + None => StatsStatus::Critical, // Never analyzed + Some(d) if d >= STATS_CRITICAL_DAYS => StatsStatus::Critical, + Some(d) if d >= STATS_WARNING_DAYS => StatsStatus::Warning, + Some(_) => StatsStatus::Healthy, + } + } + + pub fn emoji(&self) -> &'static str { + match self { + StatsStatus::Healthy => "✓", + StatsStatus::Warning => "⚠", + StatsStatus::Critical => "✗", + } + } +} + +/// Information about a table's statistics age +#[derive(Debug, Clone, Serialize)] +pub struct TableStatsAge { + pub schema: String, + pub table: String, + pub row_estimate: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_analyze: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_autoanalyze: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub days_since_analyze: Option, + pub status: StatsStatus, +} + +/// Full stats-age results +#[derive(Debug, Serialize)] +pub struct StatsAgeResult { + pub tables: Vec, + pub overall_status: StatsStatus, + pub stale_count: usize, + pub never_analyzed_count: usize, +} + +/// Run stats-age analysis +pub async fn run_stats_age( + client: &Client, + threshold_days: Option, + limit: usize, +) -> Result { + let threshold = threshold_days.unwrap_or(STATS_WARNING_DAYS); + + let query = r#" + SELECT + schemaname, + relname, + n_live_tup AS row_estimate, + last_analyze, + last_autoanalyze, + (EXTRACT(EPOCH FROM (now() - GREATEST(last_analyze, last_autoanalyze))) / 86400.0)::float8 AS days_since_analyze + FROM pg_stat_user_tables + WHERE n_live_tup > $1 -- Only tables with meaningful data + ORDER BY + CASE WHEN GREATEST(last_analyze, last_autoanalyze) IS NULL THEN 0 ELSE 1 END, + GREATEST(last_analyze, last_autoanalyze) ASC NULLS FIRST + LIMIT $2 + "#; + + let rows = client + .query(query, &[&MIN_ROWS_TO_CARE, &(limit as i64)]) + .await?; + + let mut tables = Vec::new(); + for row in rows { + let last_analyze: Option> = row.get("last_analyze"); + let last_autoanalyze: Option> = row.get("last_autoanalyze"); + let days_since: Option = row.get("days_since_analyze"); + + let status = StatsStatus::from_days(days_since); + + tables.push(TableStatsAge { + schema: row.get("schemaname"), + table: row.get("relname"), + row_estimate: row.get("row_estimate"), + last_analyze: last_analyze.map(|t| t.to_rfc3339()), + last_autoanalyze: last_autoanalyze.map(|t| t.to_rfc3339()), + days_since_analyze: days_since, + status, + }); + } + + // Filter to only show tables exceeding threshold (or never analyzed) + let tables: Vec<_> = tables + .into_iter() + .filter(|t| t.days_since_analyze.is_none_or(|d| d >= threshold)) + .collect(); + + let stale_count = tables + .iter() + .filter(|t| t.status == StatsStatus::Warning || t.status == StatsStatus::Critical) + .count(); + + let never_analyzed_count = tables + .iter() + .filter(|t| t.days_since_analyze.is_none()) + .count(); + + let overall_status = tables + .iter() + .map(|t| &t.status) + .max_by_key(|s| match s { + StatsStatus::Healthy => 0, + StatsStatus::Warning => 1, + StatsStatus::Critical => 2, + }) + .cloned() + .unwrap_or(StatsStatus::Healthy); + + Ok(StatsAgeResult { + tables, + overall_status, + stale_count, + never_analyzed_count, + }) +} + +/// Format days for display +fn format_days(days: Option) -> String { + match days { + None => "never".to_string(), + Some(d) if d < 1.0 => format!("{:.0} hours", d * 24.0), + Some(d) if d < 7.0 => format!("{:.1} days", d), + Some(d) => format!("{:.0} days", d), + } +} + +/// Print stats-age in human-readable format +pub fn print_human(result: &StatsAgeResult, quiet: bool) { + if result.tables.is_empty() { + if !quiet { + println!("All tables have fresh statistics (analyzed within threshold)."); + } + return; + } + + println!("STATISTICS AGE"); + println!("=============="); + println!(); + println!("Tables with oldest statistics:"); + println!(); + + for t in &result.tables { + let age_str = format_days(t.days_since_analyze); + let status_label = match t.status { + StatsStatus::Critical => " (CRITICAL)", + StatsStatus::Warning => "", + StatsStatus::Healthy => "", + }; + println!( + " {} {}.{:<30} last analyzed: {}{}", + t.status.emoji(), + t.schema, + t.table, + age_str, + status_label + ); + } + + // Summary + println!(); + if result.never_analyzed_count > 0 { + println!( + " ✗ {} tables have NEVER been analyzed", + result.never_analyzed_count + ); + } + if result.stale_count > result.never_analyzed_count { + println!( + " ⚠ {} tables have stale statistics (>{:.0} days)", + result.stale_count - result.never_analyzed_count, + STATS_WARNING_DAYS + ); + } + + // Recommendations + let critical_tables: Vec<_> = result + .tables + .iter() + .filter(|t| t.status == StatsStatus::Critical) + .collect(); + + if !critical_tables.is_empty() && !quiet { + println!(); + println!("Recommendation: Run ANALYZE on tables with stale statistics:"); + for t in critical_tables.iter().take(5) { + println!(" ANALYZE {}.{};", t.schema, t.table); + } + if critical_tables.len() > 5 { + println!(" ... and {} more tables", critical_tables.len() - 5); + } + } +} + +/// Print stats-age as JSON with schema versioning +pub fn print_json( + result: &StatsAgeResult, + timeouts: Option, +) -> Result<()> { + use crate::output::{schema, DiagnosticOutput, Severity}; + + let severity = match result.overall_status { + StatsStatus::Healthy => Severity::Healthy, + StatsStatus::Warning => Severity::Warning, + StatsStatus::Critical => Severity::Critical, + }; + + let output = match timeouts { + Some(t) => DiagnosticOutput::with_timeouts(schema::STATS_AGE, result, severity, t), + None => DiagnosticOutput::new(schema::STATS_AGE, result, severity), + }; + output.print()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stats_status_healthy() { + assert_eq!(StatsStatus::from_days(Some(3.0)), StatsStatus::Healthy); + } + + #[test] + fn test_stats_status_warning() { + assert_eq!(StatsStatus::from_days(Some(10.0)), StatsStatus::Warning); + } + + #[test] + fn test_stats_status_critical() { + assert_eq!(StatsStatus::from_days(Some(45.0)), StatsStatus::Critical); + } + + #[test] + fn test_stats_status_never_analyzed() { + assert_eq!(StatsStatus::from_days(None), StatsStatus::Critical); + } + + #[test] + fn test_stats_status_boundary() { + assert_eq!(StatsStatus::from_days(Some(6.9)), StatsStatus::Healthy); + assert_eq!(StatsStatus::from_days(Some(7.0)), StatsStatus::Warning); + assert_eq!(StatsStatus::from_days(Some(29.9)), StatsStatus::Warning); + assert_eq!(StatsStatus::from_days(Some(30.0)), StatsStatus::Critical); + } + + #[test] + fn test_format_days_never() { + assert_eq!(format_days(None), "never"); + } + + #[test] + fn test_format_days_hours() { + assert_eq!(format_days(Some(0.5)), "12 hours"); + } + + #[test] + fn test_format_days_days() { + assert_eq!(format_days(Some(3.5)), "3.5 days"); + } + + #[test] + fn test_format_days_weeks() { + assert_eq!(format_days(Some(14.0)), "14 days"); + } +} diff --git a/src/main.rs b/src/main.rs index c021543..0c84344 100644 --- a/src/main.rs +++ b/src/main.rs @@ -833,6 +833,21 @@ enum DbaCommands { #[command(subcommand)] command: FixCommands, }, + /// Show tables with stale statistics (may cause poor query plans) + StatsAge { + /// Warning threshold in days (default: 7) + #[arg(long, value_name = "DAYS")] + threshold: Option, + /// Number of tables to show (default: 20) + #[arg(long, default_value = "20")] + limit: usize, + }, + /// Analyze checkpoint frequency and health + Checkpoints, + /// Show currently running autovacuum operations + AutovacuumProgress, + /// Review PostgreSQL configuration settings + Config, } /// Schema and permission inspection commands @@ -1922,6 +1937,76 @@ async fn run(cli: Cli, output: &Output) -> Result<()> { commands::indexes::print_human(&result, cli.verbose); } } + + DbaCommands::StatsAge { threshold, limit } => { + let result = + commands::stats_age::run_stats_age(client, threshold, limit).await?; + + if cli.json { + commands::stats_age::print_json(&result, timeouts)?; + } else { + commands::stats_age::print_human(&result, cli.quiet); + } + + // Exit code based on status + if let Some(code) = exit_codes::for_finding( + cli.json, + result.overall_status == commands::stats_age::StatsStatus::Critical, + result.overall_status == commands::stats_age::StatsStatus::Warning, + ) { + std::process::exit(code); + } + } + + DbaCommands::Checkpoints => { + let result = commands::checkpoints::run_checkpoints(client).await?; + + if cli.json { + commands::checkpoints::print_json(&result, timeouts)?; + } else { + commands::checkpoints::print_human(&result, cli.quiet); + } + + // Exit code based on status + if let Some(code) = exit_codes::for_finding( + cli.json, + result.overall_status == commands::checkpoints::CheckpointStatus::Critical, + result.overall_status == commands::checkpoints::CheckpointStatus::Warning, + ) { + std::process::exit(code); + } + } + + DbaCommands::AutovacuumProgress => { + let result = + commands::autovacuum_progress::run_autovacuum_progress(client).await?; + + if cli.json { + commands::autovacuum_progress::print_json(&result, timeouts)?; + } else { + commands::autovacuum_progress::print_human(&result, cli.quiet); + } + // No exit code - this is purely informational + } + + DbaCommands::Config => { + let result = commands::config::run_config(client).await?; + + if cli.json { + commands::config::print_json(&result, timeouts)?; + } else { + commands::config::print_human(&result, cli.quiet); + } + + // Exit code based on status (warning if has suggestions, never critical) + if let Some(code) = exit_codes::for_finding( + cli.json, + false, // Never critical + result.has_suggestions, + ) { + std::process::exit(code); + } + } } } Commands::Inspect { command } => { diff --git a/src/output.rs b/src/output.rs index b8eaa46..2a9aa43 100644 --- a/src/output.rs +++ b/src/output.rs @@ -434,6 +434,10 @@ pub mod schema { pub const CONNECTIONS: &str = "pgcrate.diagnostics.connections"; pub const EXPLAIN: &str = "pgcrate.diagnostics.explain"; pub const STORAGE: &str = "pgcrate.diagnostics.storage"; + pub const STATS_AGE: &str = "pgcrate.diagnostics.stats_age"; + pub const CHECKPOINTS: &str = "pgcrate.diagnostics.checkpoints"; + pub const AUTOVACUUM_PROGRESS: &str = "pgcrate.diagnostics.autovacuum_progress"; + pub const CONFIG: &str = "pgcrate.diagnostics.config"; } // ============================================================================= diff --git a/tests/diagnostics/maintenance.rs b/tests/diagnostics/maintenance.rs new file mode 100644 index 0000000..35d3a62 --- /dev/null +++ b/tests/diagnostics/maintenance.rs @@ -0,0 +1,566 @@ +//! Integration tests for new diagnostic commands: +//! - stats-age: Tables with stale statistics +//! - checkpoints: Checkpoint frequency and health +//! - autovacuum-progress: Currently running autovacuum +//! - config: PostgreSQL configuration review + +use crate::common::{parse_json, stdout, TestDatabase, TestProject}; + +// ============================================================================ +// stats-age +// ============================================================================ + +#[test] +fn test_stats_age_runs() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + // stats-age should run without error + let output = project.run_pgcrate(&["dba", "stats-age"]); + + assert!( + output.status.code().unwrap_or(99) <= 2, + "stats-age should return valid exit code" + ); +} + +#[test] +fn test_stats_age_json_structure() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + let output = project.run_pgcrate(&["dba", "stats-age", "--json"]); + + let json = parse_json(&output); + assert!(json.is_object(), "Should return JSON object"); + + // Check envelope + assert!(json.get("schema_id").is_some(), "Should have schema_id"); + assert!(json.get("severity").is_some(), "Should have severity"); + + // Check data structure + let data = json.get("data").expect("Should have data field"); + assert!( + data.get("tables").is_some(), + "Should have data.tables: {}", + json + ); + assert!( + data.get("overall_status").is_some(), + "Should have data.overall_status: {}", + json + ); +} + +#[test] +fn test_stats_age_respects_threshold() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + // With very low threshold (0.001 days = ~1.4 minutes), all tables should pass + let output = project.run_pgcrate(&["dba", "stats-age", "--threshold", "0.001"]); + + // Should succeed (exit 0 = healthy) + assert!( + output.status.code().unwrap_or(99) <= 1, + "Fresh tables should be healthy with low threshold" + ); +} + +#[test] +fn test_stats_age_respects_limit() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + // Create multiple tables for the test + for i in 1..=5 { + db.run_sql_ok(&format!( + "CREATE TABLE stats_test_{} (id SERIAL PRIMARY KEY, data TEXT)", + i + )); + // Insert some data so tables have rows + db.run_sql_ok(&format!( + "INSERT INTO stats_test_{} (data) VALUES ('test')", + i + )); + } + + let output = project.run_pgcrate(&["dba", "stats-age", "--limit", "2", "--json"]); + + let out = stdout(&output); + let json: serde_json::Value = serde_json::from_str(&out) + .unwrap_or_else(|e| panic!("Invalid JSON: {}\nOutput: {}", e, out)); + + if let Some(data) = json.get("data") { + if let Some(tables) = data.get("tables").and_then(|t| t.as_array()) { + assert!( + tables.len() <= 2, + "Should respect --limit 2, got {} tables", + tables.len() + ); + } + } +} + +// ============================================================================ +// checkpoints +// ============================================================================ + +#[test] +fn test_checkpoints_runs() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + // checkpoints should run without error + let output = project.run_pgcrate(&["dba", "checkpoints"]); + + assert!( + output.status.code().unwrap_or(99) <= 2, + "checkpoints should return valid exit code" + ); +} + +#[test] +fn test_checkpoints_json_structure() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + let output = project.run_pgcrate(&["dba", "checkpoints", "--json"]); + + let json = parse_json(&output); + assert!(json.is_object(), "Should return JSON object"); + + // Check envelope + assert!(json.get("schema_id").is_some(), "Should have schema_id"); + assert_eq!( + json.get("schema_id").and_then(|s| s.as_str()), + Some("pgcrate.diagnostics.checkpoints") + ); + + // Check data structure + let data = json.get("data").expect("Should have data field"); + assert!( + data.get("stats").is_some(), + "Should have data.stats: {}", + json + ); + assert!( + data.get("overall_status").is_some(), + "Should have data.overall_status: {}", + json + ); +} + +#[test] +fn test_checkpoints_shows_metrics() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + let output = project.run_pgcrate(&["dba", "checkpoints"]); + + let out = stdout(&output); + + // Should show checkpoint statistics + assert!( + out.contains("checkpoint") || out.contains("Checkpoint"), + "Should show checkpoint info: {}", + out + ); +} + +// ============================================================================ +// autovacuum-progress +// ============================================================================ + +#[test] +fn test_autovacuum_progress_runs() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + // autovacuum-progress should run without error + let output = project.run_pgcrate(&["dba", "autovacuum-progress"]); + + // This command always returns 0 (informational only) + assert!( + output.status.success(), + "autovacuum-progress should succeed" + ); +} + +#[test] +fn test_autovacuum_progress_json_structure() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + let output = project.run_pgcrate(&["dba", "autovacuum-progress", "--json"]); + + let json = parse_json(&output); + assert!(json.is_object(), "Should return JSON object"); + + // Check envelope + assert!(json.get("schema_id").is_some(), "Should have schema_id"); + assert_eq!( + json.get("schema_id").and_then(|s| s.as_str()), + Some("pgcrate.diagnostics.autovacuum_progress") + ); + // Should always be healthy (informational) + assert_eq!( + json.get("severity").and_then(|s| s.as_str()), + Some("healthy") + ); + + // Check data structure + let data = json.get("data").expect("Should have data field"); + assert!( + data.get("workers").is_some(), + "Should have data.workers: {}", + json + ); + assert!( + data.get("count").is_some(), + "Should have data.count: {}", + json + ); +} + +#[test] +fn test_autovacuum_progress_empty_is_ok() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + let output = project.run_pgcrate(&["dba", "autovacuum-progress"]); + + let out = stdout(&output); + + // When no autovacuum is running, should show friendly message + assert!( + out.contains("No autovacuum") || out.contains("autovacuum") || out.contains("running"), + "Should show autovacuum status: {}", + out + ); +} + +// ============================================================================ +// config +// ============================================================================ + +#[test] +fn test_config_runs() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + // config should run without error + let output = project.run_pgcrate(&["dba", "config"]); + + assert!( + output.status.code().unwrap_or(99) <= 1, + "config should return valid exit code (0 or 1 for suggestions)" + ); +} + +#[test] +fn test_config_json_structure() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + let output = project.run_pgcrate(&["dba", "config", "--json"]); + + let json = parse_json(&output); + assert!(json.is_object(), "Should return JSON object"); + + // Check envelope + assert!(json.get("schema_id").is_some(), "Should have schema_id"); + assert_eq!( + json.get("schema_id").and_then(|s| s.as_str()), + Some("pgcrate.diagnostics.config") + ); + + // Should never be critical (always healthy or warning) + let severity = json.get("severity").and_then(|s| s.as_str()); + assert!( + severity == Some("healthy") || severity == Some("warning"), + "config should never be critical, got: {:?}", + severity + ); + + // Check data structure + let data = json.get("data").expect("Should have data field"); + assert!( + data.get("settings").is_some(), + "Should have data.settings: {}", + json + ); + assert!( + data.get("disclaimer").is_some(), + "Should have data.disclaimer: {}", + json + ); + assert!( + data.get("postgres_version").is_some(), + "Should have data.postgres_version: {}", + json + ); +} + +#[test] +fn test_config_shows_settings() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + let output = project.run_pgcrate(&["dba", "config"]); + + let out = stdout(&output); + + // Should show key settings + assert!( + out.contains("shared_buffers") || out.contains("memory") || out.contains("Memory"), + "Should show memory settings: {}", + out + ); +} + +#[test] +fn test_config_includes_disclaimer() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + let output = project.run_pgcrate(&["dba", "config"]); + + let out = stdout(&output); + + // Should include safety disclaimer + assert!( + out.contains("suggestion") + || out.contains("Recommendation") + || out.contains("test") + || out.contains("Note"), + "Should include disclaimer or note: {}", + out + ); +} + +#[test] +fn test_config_includes_requires_restart() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + let output = project.run_pgcrate(&["dba", "config", "--json"]); + + let out = stdout(&output); + let json: serde_json::Value = serde_json::from_str(&out) + .unwrap_or_else(|e| panic!("Invalid JSON: {}\nOutput: {}", e, out)); + + let data = json.get("data").expect("Should have data field"); + let settings = data + .get("settings") + .and_then(|s| s.as_array()) + .expect("Should have settings array"); + + // All settings should have requires_restart field + for setting in settings { + assert!( + setting.get("requires_restart").is_some(), + "Setting should have requires_restart field: {}", + setting + ); + } + + // shared_buffers should require restart (postmaster context) + let shared_buffers = settings + .iter() + .find(|s| s.get("name").and_then(|n| n.as_str()) == Some("shared_buffers")); + if let Some(sb) = shared_buffers { + assert_eq!( + sb.get("requires_restart").and_then(|r| r.as_bool()), + Some(true), + "shared_buffers should require restart" + ); + } +} + +// ============================================================================ +// FK index detection (extended tests) +// ============================================================================ + +#[test] +fn test_fk_index_json_includes_fk_without_indexes() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + // Create tables with FK but no index + db.run_sql_ok( + "CREATE TABLE fk_parent ( + id SERIAL PRIMARY KEY, + name TEXT + )", + ); + db.run_sql_ok( + "CREATE TABLE fk_child ( + id SERIAL PRIMARY KEY, + parent_id INTEGER REFERENCES fk_parent(id), + data TEXT + )", + ); + + let output = project.run_pgcrate(&["dba", "indexes", "--json"]); + + let out = stdout(&output); + let json: serde_json::Value = serde_json::from_str(&out) + .unwrap_or_else(|e| panic!("Invalid JSON: {}\nOutput: {}", e, out)); + + let data = json.get("data").expect("Should have data field"); + assert!( + data.get("fk_without_indexes").is_some(), + "Should have data.fk_without_indexes: {}", + json + ); +} + +#[test] +fn test_fk_composite_key_detection() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + // Create tables with composite FK but no matching index + db.run_sql_ok( + "CREATE TABLE composite_parent ( + tenant_id INTEGER, + id INTEGER, + name TEXT, + PRIMARY KEY (tenant_id, id) + )", + ); + db.run_sql_ok( + "CREATE TABLE composite_child ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER, + parent_id INTEGER, + data TEXT, + FOREIGN KEY (tenant_id, parent_id) REFERENCES composite_parent(tenant_id, id) + )", + ); + // Note: No index on (tenant_id, parent_id) - should be flagged + + let output = project.run_pgcrate(&["dba", "indexes", "--json"]); + + let out = stdout(&output); + let json: serde_json::Value = serde_json::from_str(&out) + .unwrap_or_else(|e| panic!("Invalid JSON: {}\nOutput: {}", e, out)); + + let data = json.get("data").expect("Should have data field"); + let fk_list = data + .get("fk_without_indexes") + .and_then(|f| f.as_array()) + .expect("Should have fk_without_indexes array"); + + // Should detect the composite FK without index + let has_composite = fk_list + .iter() + .any(|fk| fk.to_string().contains("composite_child")); + assert!( + has_composite, + "Should detect composite FK without index: {:?}", + fk_list + ); +} + +#[test] +fn test_fk_composite_key_with_index_not_flagged() { + skip_if_no_db!(); + let db = TestDatabase::new(); + let project = TestProject::from_fixture("with_migrations", &db); + + project.run_pgcrate_ok(&["migrate", "up"]); + + // Create tables with composite FK that HAS a matching index + db.run_sql_ok( + "CREATE TABLE comp_indexed_parent ( + tenant_id INTEGER, + id INTEGER, + name TEXT, + PRIMARY KEY (tenant_id, id) + )", + ); + db.run_sql_ok( + "CREATE TABLE comp_indexed_child ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER, + parent_id INTEGER, + data TEXT, + FOREIGN KEY (tenant_id, parent_id) REFERENCES comp_indexed_parent(tenant_id, id) + )", + ); + // Add index covering the FK columns + db.run_sql_ok( + "CREATE INDEX comp_indexed_child_fk_idx ON comp_indexed_child(tenant_id, parent_id)", + ); + + let output = project.run_pgcrate(&["dba", "indexes", "--json"]); + + let out = stdout(&output); + let json: serde_json::Value = serde_json::from_str(&out) + .unwrap_or_else(|e| panic!("Invalid JSON: {}\nOutput: {}", e, out)); + + let data = json.get("data").expect("Should have data field"); + if let Some(fk_list) = data.get("fk_without_indexes").and_then(|f| f.as_array()) { + let has_comp_indexed = fk_list + .iter() + .any(|fk| fk.to_string().contains("comp_indexed_child")); + assert!( + !has_comp_indexed, + "FK with matching index should not be flagged: {:?}", + fk_list + ); + } +} diff --git a/tests/diagnostics/mod.rs b/tests/diagnostics/mod.rs index ded4bcb..9c79ffb 100644 --- a/tests/diagnostics/mod.rs +++ b/tests/diagnostics/mod.rs @@ -3,5 +3,6 @@ mod bloat; mod fix; mod indexes; mod locks; +mod maintenance; mod replication; mod sequences_scenarios;