Skip to main content

idb/cli/
health.rs

1//! CLI implementation for the `inno health` subcommand.
2//!
3//! Computes per-index B+Tree health metrics (fill factor, fragmentation,
4//! garbage ratio, tree depth) by scanning all INDEX pages in a tablespace.
5//! Optionally computes bloat scores (A-F grades) and cardinality estimates.
6
7use std::collections::HashMap;
8use std::io::Write;
9use std::time::Instant;
10
11use crate::cli::wprintln;
12use crate::innodb::health;
13use crate::innodb::record::walk_compact_records;
14use crate::innodb::sdi;
15use crate::util::prometheus as prom;
16use crate::IdbError;
17
18/// Options for the `inno health` subcommand.
19pub struct HealthOptions {
20    /// Path to the InnoDB tablespace file (.ibd).
21    pub file: String,
22    /// Show per-level page counts and total records.
23    pub verbose: bool,
24    /// Output in JSON format.
25    pub json: bool,
26    /// Output as CSV.
27    pub csv: bool,
28    /// Output in Prometheus exposition format.
29    pub prometheus: bool,
30    /// Compute index bloat scores.
31    pub bloat: bool,
32    /// Estimate cardinality of leading index columns.
33    pub cardinality: bool,
34    /// Number of leaf pages to sample per index for cardinality.
35    pub sample_size: usize,
36    /// Override the auto-detected page size.
37    pub page_size: Option<u32>,
38    /// Path to MySQL keyring file for decrypting encrypted tablespaces.
39    pub keyring: Option<String>,
40    /// Use memory-mapped I/O for file access.
41    pub mmap: bool,
42}
43
44/// Analyze B+Tree health metrics for all indexes in a tablespace.
45pub fn execute(opts: &HealthOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
46    if opts.prometheus && (opts.json || opts.csv) {
47        return Err(IdbError::Argument(
48            "--prometheus cannot be combined with JSON or CSV output".to_string(),
49        ));
50    }
51
52    let start = Instant::now();
53
54    let mut ts = crate::cli::open_tablespace(&opts.file, opts.page_size, opts.mmap)?;
55
56    if let Some(ref keyring_path) = opts.keyring {
57        crate::cli::setup_decryption(&mut ts, keyring_path)?;
58    }
59
60    let page_size = ts.page_size();
61    let total_file_pages = ts.page_count();
62
63    // Single-pass collection
64    let mut snapshots = Vec::new();
65    let mut empty_pages = 0u64;
66    let mut rtree_pages = 0u64;
67    let mut lob_pages = 0u64;
68    let mut undo_pages = 0u64;
69
70    // Delete-mark counting (per index_id: deleted, total walked)
71    let mut delete_counts: HashMap<u64, (u64, u64)> = HashMap::new();
72    // Leaf page numbers per index (for cardinality sampling)
73    let mut leaf_pages_by_index: HashMap<u64, Vec<u64>> = HashMap::new();
74
75    ts.for_each_page(|page_num, data| {
76        if data.iter().all(|&b| b == 0) {
77            empty_pages += 1;
78        } else if let Some(snap) = health::extract_index_page_snapshot(data, page_num) {
79            // Track leaf pages for cardinality sampling
80            if snap.level == 0 && opts.cardinality {
81                leaf_pages_by_index
82                    .entry(snap.index_id)
83                    .or_default()
84                    .push(snap.page_number);
85            }
86
87            // Count delete-marked records for bloat scoring (leaf pages only)
88            if snap.level == 0 && opts.bloat {
89                let recs = walk_compact_records(data);
90                let total = recs.len() as u64;
91                let deleted = recs.iter().filter(|r| r.header.delete_mark()).count() as u64;
92                let entry = delete_counts.entry(snap.index_id).or_insert((0, 0));
93                entry.0 += deleted;
94                entry.1 += total;
95            }
96
97            snapshots.push(snap);
98        }
99
100        // Count special page types
101        if let Some(fil) = crate::innodb::page::FilHeader::parse(data) {
102            use crate::innodb::page_types::PageType;
103            match fil.page_type {
104                PageType::Rtree | PageType::EncryptedRtree => rtree_pages += 1,
105                PageType::Blob
106                | PageType::ZBlob
107                | PageType::ZBlob2
108                | PageType::LobFirst
109                | PageType::LobData
110                | PageType::LobIndex
111                | PageType::ZlobFirst
112                | PageType::ZlobData
113                | PageType::ZlobFrag
114                | PageType::ZlobFragEntry
115                | PageType::ZlobIndex => lob_pages += 1,
116                PageType::UndoLog => undo_pages += 1,
117                _ => {}
118            }
119        }
120        Ok(())
121    })?;
122
123    let mut report = health::analyze_health(
124        snapshots,
125        page_size,
126        total_file_pages,
127        empty_pages,
128        &opts.file,
129    );
130    report.summary.rtree_pages = rtree_pages;
131    report.summary.lob_pages = lob_pages;
132    report.summary.undo_pages = undo_pages;
133
134    // Best-effort SDI index name resolution
135    resolve_index_names(
136        &opts.file,
137        opts.page_size,
138        opts.mmap,
139        &opts.keyring,
140        &mut report,
141    );
142
143    // Bloat scoring
144    if opts.bloat {
145        for idx in &mut report.indexes {
146            let (deleted, total) = delete_counts.get(&idx.index_id).copied().unwrap_or((0, 0));
147            let delete_mark_ratio = if total > 0 {
148                deleted as f64 / total as f64
149            } else {
150                0.0
151            };
152            idx.delete_marked_records = Some(deleted);
153            idx.total_walked_records = Some(total);
154            idx.bloat = Some(health::score_bloat(idx, delete_mark_ratio));
155        }
156    }
157
158    // Cardinality estimation (separate pass — needs random page access).
159    // Only runs on the clustered (primary) index because extract_column_layout
160    // returns the PK column layout. Secondary index leaf pages have a different
161    // physical format (key cols + PK suffix) that requires a separate layout.
162    if opts.cardinality {
163        let columns_opt = crate::innodb::export::extract_column_layout(&mut ts);
164        if let Some((columns, clustered_index_id)) = columns_opt {
165            let col_name = columns.first().map(|c| c.name.clone()).unwrap_or_default();
166            if let Some(idx) = report
167                .indexes
168                .iter_mut()
169                .find(|i| i.index_id == clustered_index_id)
170            {
171                if let Some(leaf_pages) = leaf_pages_by_index.get(&idx.index_id) {
172                    idx.cardinality = health::estimate_cardinality(
173                        &mut ts,
174                        leaf_pages,
175                        &columns,
176                        &col_name,
177                        page_size,
178                        opts.sample_size,
179                    );
180                }
181            }
182        }
183    }
184
185    let duration_secs = start.elapsed().as_secs_f64();
186
187    if opts.prometheus {
188        print_prometheus(writer, &report, duration_secs)?;
189        return Ok(());
190    }
191
192    if opts.json {
193        wprintln!(
194            writer,
195            "{}",
196            serde_json::to_string_pretty(&report).map_err(|e| IdbError::Parse(e.to_string()))?
197        )?;
198    } else if opts.csv {
199        print_csv(writer, &report, opts.bloat, opts.cardinality)?;
200    } else {
201        print_text(writer, &report, opts.verbose)?;
202    }
203
204    Ok(())
205}
206
207/// Try to resolve index names from SDI metadata (best-effort, display-only).
208fn resolve_index_names(
209    file: &str,
210    page_size: Option<u32>,
211    mmap: bool,
212    keyring: &Option<String>,
213    report: &mut health::HealthReport,
214) {
215    let resolve = || -> Result<std::collections::HashMap<u64, String>, IdbError> {
216        let mut ts = crate::cli::open_tablespace(file, page_size, mmap)?;
217        if let Some(ref kp) = keyring {
218            crate::cli::setup_decryption(&mut ts, kp)?;
219        }
220        let sdi_pages = sdi::find_sdi_pages(&mut ts)?;
221        if sdi_pages.is_empty() {
222            return Ok(std::collections::HashMap::new());
223        }
224        let records = sdi::extract_sdi_from_pages(&mut ts, &sdi_pages)?;
225        let mut name_map = std::collections::HashMap::new();
226        for rec in &records {
227            if rec.sdi_type == 1 {
228                name_map.extend(sdi::build_index_name_map(&rec.data));
229            }
230        }
231        Ok(name_map)
232    };
233
234    if let Ok(name_map) = resolve() {
235        for idx in &mut report.indexes {
236            if let Some(name) = name_map.get(&idx.index_id) {
237                idx.index_name = Some(name.clone());
238            }
239        }
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Text output
245// ---------------------------------------------------------------------------
246
247fn print_text(
248    writer: &mut dyn Write,
249    report: &health::HealthReport,
250    verbose: bool,
251) -> Result<(), IdbError> {
252    wprintln!(writer, "Tablespace Health Report: {}", report.file)?;
253    wprintln!(writer)?;
254
255    if report.indexes.is_empty() {
256        wprintln!(writer, "No INDEX pages found.")?;
257        wprintln!(writer)?;
258    }
259
260    for idx in &report.indexes {
261        let name = idx.index_name.as_deref().unwrap_or("(unknown)");
262        wprintln!(writer, "Index {} ({}):", idx.index_id, name)?;
263        wprintln!(writer, "  Tree depth:     {}", idx.tree_depth)?;
264        wprintln!(
265            writer,
266            "  Pages:          {} total ({} leaf, {} non-leaf)",
267            idx.total_pages,
268            idx.leaf_pages,
269            idx.non_leaf_pages
270        )?;
271        wprintln!(
272            writer,
273            "  Fill factor:    avg {:.0}%, min {:.0}%, max {:.0}%",
274            idx.avg_fill_factor * 100.0,
275            idx.min_fill_factor * 100.0,
276            idx.max_fill_factor * 100.0
277        )?;
278        wprintln!(
279            writer,
280            "  Garbage:        {:.1}% ({} bytes)",
281            idx.avg_garbage_ratio * 100.0,
282            idx.total_garbage_bytes
283        )?;
284        wprintln!(
285            writer,
286            "  Fragmentation:  {:.1}%",
287            idx.fragmentation * 100.0
288        )?;
289
290        if verbose {
291            wprintln!(writer, "  Total records:  {}", idx.total_records)?;
292            if idx.empty_leaf_pages > 0 {
293                wprintln!(writer, "  Empty leaves:   {}", idx.empty_leaf_pages)?;
294            }
295        }
296
297        // Bloat score
298        if let Some(ref bloat) = idx.bloat {
299            wprintln!(
300                writer,
301                "  Bloat:          {} ({:.2})",
302                bloat.grade,
303                bloat.score
304            )?;
305            if let Some(ref rec) = bloat.recommendation {
306                wprintln!(writer, "                  {}", rec)?;
307            }
308        }
309
310        // Cardinality
311        if let Some(ref card) = idx.cardinality {
312            wprintln!(
313                writer,
314                "  Cardinality:    ~{} distinct (column: {}, {}/{} pages, {:.0}% confidence)",
315                card.estimated_distinct,
316                card.column_name,
317                card.sampled_pages,
318                card.total_leaf_pages,
319                card.confidence * 100.0
320            )?;
321        }
322
323        wprintln!(writer)?;
324    }
325
326    // Summary
327    wprintln!(writer, "Summary:")?;
328    wprintln!(
329        writer,
330        "  Total pages:      {} ({} INDEX, {} non-INDEX, {} empty)",
331        report.summary.total_pages,
332        report.summary.index_pages,
333        report.summary.non_index_pages,
334        report.summary.empty_pages
335    )?;
336    wprintln!(
337        writer,
338        "  Page size:        {} bytes",
339        report.summary.page_size
340    )?;
341    wprintln!(writer, "  Indexes:          {}", report.summary.index_count)?;
342    if report.summary.rtree_pages > 0 {
343        wprintln!(writer, "  RTREE pages:      {}", report.summary.rtree_pages)?;
344    }
345    if report.summary.lob_pages > 0 {
346        wprintln!(writer, "  LOB/BLOB pages:   {}", report.summary.lob_pages)?;
347    }
348    if report.summary.undo_pages > 0 {
349        wprintln!(writer, "  UNDO pages:       {}", report.summary.undo_pages)?;
350    }
351    wprintln!(
352        writer,
353        "  Avg fill factor:  {:.0}%",
354        report.summary.avg_fill_factor * 100.0
355    )?;
356    wprintln!(
357        writer,
358        "  Avg garbage:      {:.1}%",
359        report.summary.avg_garbage_ratio * 100.0
360    )?;
361    wprintln!(
362        writer,
363        "  Avg fragmentation: {:.1}%",
364        report.summary.avg_fragmentation * 100.0
365    )?;
366
367    Ok(())
368}
369
370// ---------------------------------------------------------------------------
371// CSV output
372// ---------------------------------------------------------------------------
373
374fn print_csv(
375    writer: &mut dyn Write,
376    report: &health::HealthReport,
377    bloat: bool,
378    cardinality: bool,
379) -> Result<(), IdbError> {
380    let mut header = String::from(
381        "index_id,index_name,tree_depth,total_pages,leaf_pages,avg_fill_factor,garbage_ratio,fragmentation",
382    );
383    if bloat {
384        header.push_str(",bloat_score,bloat_grade,delete_marked,total_walked");
385    }
386    if cardinality {
387        header.push_str(",est_cardinality,cardinality_confidence");
388    }
389    wprintln!(writer, "{}", header)?;
390
391    for idx in &report.indexes {
392        let mut row = format!(
393            "{},{},{},{},{},{},{},{}",
394            idx.index_id,
395            crate::cli::csv_escape(idx.index_name.as_deref().unwrap_or("")),
396            idx.tree_depth,
397            idx.total_pages,
398            idx.leaf_pages,
399            idx.avg_fill_factor,
400            idx.avg_garbage_ratio,
401            idx.fragmentation
402        );
403        if bloat {
404            if let Some(ref b) = idx.bloat {
405                row.push_str(&format!(
406                    ",{},{},{}",
407                    b.score,
408                    b.grade,
409                    idx.delete_marked_records.unwrap_or(0)
410                ));
411                row.push_str(&format!(",{}", idx.total_walked_records.unwrap_or(0)));
412            } else {
413                row.push_str(",,,,");
414            }
415        }
416        if cardinality {
417            if let Some(ref c) = idx.cardinality {
418                row.push_str(&format!(",{},{}", c.estimated_distinct, c.confidence));
419            } else {
420                row.push_str(",,");
421            }
422        }
423        wprintln!(writer, "{}", row)?;
424    }
425    Ok(())
426}
427
428// ---------------------------------------------------------------------------
429// Prometheus output
430// ---------------------------------------------------------------------------
431
432fn print_prometheus(
433    writer: &mut dyn Write,
434    report: &health::HealthReport,
435    duration_secs: f64,
436) -> Result<(), IdbError> {
437    let file = &report.file;
438
439    // innodb_pages
440    wprintln!(
441        writer,
442        "{}",
443        prom::help_line("innodb_pages", "Total pages in the tablespace by type")
444    )?;
445    wprintln!(writer, "{}", prom::type_line("innodb_pages", "gauge"))?;
446    wprintln!(
447        writer,
448        "{}",
449        prom::format_gauge_int(
450            "innodb_pages",
451            &[("file", file), ("type", "index")],
452            report.summary.index_pages
453        )
454    )?;
455    wprintln!(
456        writer,
457        "{}",
458        prom::format_gauge_int(
459            "innodb_pages",
460            &[("file", file), ("type", "non_index")],
461            report.summary.non_index_pages
462        )
463    )?;
464    wprintln!(
465        writer,
466        "{}",
467        prom::format_gauge_int(
468            "innodb_pages",
469            &[("file", file), ("type", "empty")],
470            report.summary.empty_pages
471        )
472    )?;
473
474    // innodb_fill_factor
475    wprintln!(
476        writer,
477        "{}",
478        prom::help_line("innodb_fill_factor", "Average B+Tree fill factor per index")
479    )?;
480    wprintln!(writer, "{}", prom::type_line("innodb_fill_factor", "gauge"))?;
481    for idx in &report.indexes {
482        let id_str = idx.index_id.to_string();
483        let index_label = idx.index_name.as_deref().unwrap_or(&id_str);
484        wprintln!(
485            writer,
486            "{}",
487            prom::format_gauge(
488                "innodb_fill_factor",
489                &[("file", file), ("index", index_label)],
490                idx.avg_fill_factor
491            )
492        )?;
493    }
494
495    // innodb_fragmentation_ratio
496    wprintln!(
497        writer,
498        "{}",
499        prom::help_line(
500            "innodb_fragmentation_ratio",
501            "Leaf-level fragmentation ratio per index"
502        )
503    )?;
504    wprintln!(
505        writer,
506        "{}",
507        prom::type_line("innodb_fragmentation_ratio", "gauge")
508    )?;
509    for idx in &report.indexes {
510        let id_str = idx.index_id.to_string();
511        let index_label = idx.index_name.as_deref().unwrap_or(&id_str);
512        wprintln!(
513            writer,
514            "{}",
515            prom::format_gauge(
516                "innodb_fragmentation_ratio",
517                &[("file", file), ("index", index_label)],
518                idx.fragmentation
519            )
520        )?;
521    }
522
523    // innodb_garbage_ratio
524    wprintln!(
525        writer,
526        "{}",
527        prom::help_line("innodb_garbage_ratio", "Average garbage ratio per index")
528    )?;
529    wprintln!(
530        writer,
531        "{}",
532        prom::type_line("innodb_garbage_ratio", "gauge")
533    )?;
534    for idx in &report.indexes {
535        let id_str = idx.index_id.to_string();
536        let index_label = idx.index_name.as_deref().unwrap_or(&id_str);
537        wprintln!(
538            writer,
539            "{}",
540            prom::format_gauge(
541                "innodb_garbage_ratio",
542                &[("file", file), ("index", index_label)],
543                idx.avg_garbage_ratio
544            )
545        )?;
546    }
547
548    // innodb_index_pages
549    wprintln!(
550        writer,
551        "{}",
552        prom::help_line("innodb_index_pages", "Total pages per index")
553    )?;
554    wprintln!(writer, "{}", prom::type_line("innodb_index_pages", "gauge"))?;
555    for idx in &report.indexes {
556        let id_str = idx.index_id.to_string();
557        let index_label = idx.index_name.as_deref().unwrap_or(&id_str);
558        wprintln!(
559            writer,
560            "{}",
561            prom::format_gauge_int(
562                "innodb_index_pages",
563                &[("file", file), ("index", index_label)],
564                idx.total_pages
565            )
566        )?;
567    }
568
569    // innodb_bloat_score (only when bloat data present)
570    let has_bloat = report.indexes.iter().any(|i| i.bloat.is_some());
571    if has_bloat {
572        wprintln!(
573            writer,
574            "{}",
575            prom::help_line("innodb_bloat_score", "Index bloat score (0.0-1.0)")
576        )?;
577        wprintln!(writer, "{}", prom::type_line("innodb_bloat_score", "gauge"))?;
578        for idx in &report.indexes {
579            if let Some(ref b) = idx.bloat {
580                let id_str = idx.index_id.to_string();
581                let index_label = idx.index_name.as_deref().unwrap_or(&id_str);
582                wprintln!(
583                    writer,
584                    "{}",
585                    prom::format_gauge(
586                        "innodb_bloat_score",
587                        &[("file", file), ("index", index_label)],
588                        b.score
589                    )
590                )?;
591            }
592        }
593
594        wprintln!(
595            writer,
596            "{}",
597            prom::help_line(
598                "innodb_delete_mark_ratio",
599                "Ratio of delete-marked records per index"
600            )
601        )?;
602        wprintln!(
603            writer,
604            "{}",
605            prom::type_line("innodb_delete_mark_ratio", "gauge")
606        )?;
607        for idx in &report.indexes {
608            if let Some(ref b) = idx.bloat {
609                let id_str = idx.index_id.to_string();
610                let index_label = idx.index_name.as_deref().unwrap_or(&id_str);
611                wprintln!(
612                    writer,
613                    "{}",
614                    prom::format_gauge(
615                        "innodb_delete_mark_ratio",
616                        &[("file", file), ("index", index_label)],
617                        b.components.delete_mark_ratio
618                    )
619                )?;
620            }
621        }
622    }
623
624    // innodb_scan_duration_seconds
625    wprintln!(
626        writer,
627        "{}",
628        prom::help_line(
629            "innodb_scan_duration_seconds",
630            "Time spent scanning the tablespace"
631        )
632    )?;
633    wprintln!(
634        writer,
635        "{}",
636        prom::type_line("innodb_scan_duration_seconds", "gauge")
637    )?;
638    wprintln!(
639        writer,
640        "{}",
641        prom::format_gauge(
642            "innodb_scan_duration_seconds",
643            &[("file", file)],
644            duration_secs
645        )
646    )?;
647
648    Ok(())
649}