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
6use std::io::Write;
7use std::time::Instant;
8
9use crate::cli::wprintln;
10use crate::innodb::health;
11use crate::innodb::schema::SdiEnvelope;
12use crate::innodb::sdi;
13use crate::util::prometheus as prom;
14use crate::IdbError;
15
16/// Options for the `inno health` subcommand.
17pub struct HealthOptions {
18    /// Path to the InnoDB tablespace file (.ibd).
19    pub file: String,
20    /// Show per-level page counts and total records.
21    pub verbose: bool,
22    /// Output in JSON format.
23    pub json: bool,
24    /// Output as CSV.
25    pub csv: bool,
26    /// Output in Prometheus exposition format.
27    pub prometheus: bool,
28    /// Override the auto-detected page size.
29    pub page_size: Option<u32>,
30    /// Path to MySQL keyring file for decrypting encrypted tablespaces.
31    pub keyring: Option<String>,
32    /// Use memory-mapped I/O for file access.
33    pub mmap: bool,
34}
35
36/// Analyze B+Tree health metrics for all indexes in a tablespace.
37pub fn execute(opts: &HealthOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
38    if opts.prometheus && (opts.json || opts.csv) {
39        return Err(IdbError::Argument(
40            "--prometheus cannot be combined with JSON or CSV output".to_string(),
41        ));
42    }
43
44    let start = Instant::now();
45
46    let mut ts = crate::cli::open_tablespace(&opts.file, opts.page_size, opts.mmap)?;
47
48    if let Some(ref keyring_path) = opts.keyring {
49        crate::cli::setup_decryption(&mut ts, keyring_path)?;
50    }
51
52    let page_size = ts.page_size();
53    let total_file_pages = ts.page_count();
54
55    // Single-pass collection
56    let mut snapshots = Vec::new();
57    let mut empty_pages = 0u64;
58    let mut rtree_pages = 0u64;
59    let mut lob_pages = 0u64;
60    let mut undo_pages = 0u64;
61
62    ts.for_each_page(|page_num, data| {
63        if data.iter().all(|&b| b == 0) {
64            empty_pages += 1;
65        } else if let Some(snap) = health::extract_index_page_snapshot(data, page_num) {
66            snapshots.push(snap);
67        }
68
69        // Count special page types
70        if let Some(fil) = crate::innodb::page::FilHeader::parse(data) {
71            use crate::innodb::page_types::PageType;
72            match fil.page_type {
73                PageType::Rtree | PageType::EncryptedRtree => rtree_pages += 1,
74                PageType::Blob
75                | PageType::ZBlob
76                | PageType::ZBlob2
77                | PageType::LobFirst
78                | PageType::LobData
79                | PageType::LobIndex
80                | PageType::ZlobFirst
81                | PageType::ZlobData
82                | PageType::ZlobFrag
83                | PageType::ZlobFragEntry
84                | PageType::ZlobIndex => lob_pages += 1,
85                PageType::UndoLog => undo_pages += 1,
86                _ => {}
87            }
88        }
89        Ok(())
90    })?;
91
92    let mut report = health::analyze_health(
93        snapshots,
94        page_size,
95        total_file_pages,
96        empty_pages,
97        &opts.file,
98    );
99    report.summary.rtree_pages = rtree_pages;
100    report.summary.lob_pages = lob_pages;
101    report.summary.undo_pages = undo_pages;
102
103    // Best-effort SDI index name resolution
104    resolve_index_names(
105        &opts.file,
106        opts.page_size,
107        opts.mmap,
108        &opts.keyring,
109        &mut report,
110    );
111
112    let duration_secs = start.elapsed().as_secs_f64();
113
114    if opts.prometheus {
115        print_prometheus(writer, &report, duration_secs)?;
116        return Ok(());
117    }
118
119    if opts.json {
120        wprintln!(
121            writer,
122            "{}",
123            serde_json::to_string_pretty(&report).map_err(|e| IdbError::Parse(e.to_string()))?
124        )?;
125    } else if opts.csv {
126        wprintln!(
127            writer,
128            "index_id,index_name,tree_depth,total_pages,leaf_pages,avg_fill_factor,garbage_ratio,fragmentation"
129        )?;
130        for idx in &report.indexes {
131            wprintln!(
132                writer,
133                "{},{},{},{},{},{},{},{}",
134                idx.index_id,
135                crate::cli::csv_escape(idx.index_name.as_deref().unwrap_or("")),
136                idx.tree_depth,
137                idx.total_pages,
138                idx.leaf_pages,
139                idx.avg_fill_factor,
140                idx.avg_garbage_ratio,
141                idx.fragmentation
142            )?;
143        }
144    } else {
145        print_text(writer, &report, opts.verbose)?;
146    }
147
148    Ok(())
149}
150
151/// Try to resolve index names from SDI metadata (best-effort, display-only).
152fn resolve_index_names(
153    file: &str,
154    page_size: Option<u32>,
155    mmap: bool,
156    keyring: &Option<String>,
157    report: &mut health::HealthReport,
158) {
159    let resolve = || -> Result<std::collections::HashMap<u64, String>, IdbError> {
160        let mut ts = crate::cli::open_tablespace(file, page_size, mmap)?;
161        if let Some(ref kp) = keyring {
162            crate::cli::setup_decryption(&mut ts, kp)?;
163        }
164        let sdi_pages = sdi::find_sdi_pages(&mut ts)?;
165        if sdi_pages.is_empty() {
166            return Ok(std::collections::HashMap::new());
167        }
168        let records = sdi::extract_sdi_from_pages(&mut ts, &sdi_pages)?;
169        let mut name_map = std::collections::HashMap::new();
170        for rec in &records {
171            if rec.sdi_type == 1 {
172                if let Ok(envelope) = serde_json::from_str::<SdiEnvelope>(&rec.data) {
173                    for dd_idx in &envelope.dd_object.indexes {
174                        // InnoDB assigns se_private_data with index IDs; the SDI
175                        // JSON index ordering matches the clustered index first,
176                        // then secondary indexes. We can't directly map dd_idx to
177                        // index_id without parsing se_private_data, but we can use
178                        // the name if we find a matching pattern.
179                        // Use se_private_data parsing to extract "id=N"
180                        if let Some(id) = parse_se_private_id(&rec.data, &dd_idx.name) {
181                            name_map.insert(id, dd_idx.name.clone());
182                        }
183                    }
184                }
185            }
186        }
187        Ok(name_map)
188    };
189
190    if let Ok(name_map) = resolve() {
191        for idx in &mut report.indexes {
192            if let Some(name) = name_map.get(&idx.index_id) {
193                idx.index_name = Some(name.clone());
194            }
195        }
196    }
197}
198
199/// Parse an index ID from SDI JSON se_private_data field.
200///
201/// The JSON contains `"se_private_data": "id=N;root=M;..."` for each index.
202/// We search the raw JSON for the pattern matching this index name.
203fn parse_se_private_id(sdi_json: &str, name: &str) -> Option<u64> {
204    // Parse the full JSON to extract se_private_data for the named index
205    let val: serde_json::Value = serde_json::from_str(sdi_json).ok()?;
206    let indexes = val.get("dd_object")?.get("indexes")?.as_array()?;
207    for idx in indexes {
208        let idx_name = idx.get("name")?.as_str()?;
209        if idx_name == name {
210            let se_data = idx.get("se_private_data")?.as_str()?;
211            for part in se_data.split(';') {
212                if let Some(id_str) = part.strip_prefix("id=") {
213                    return id_str.parse::<u64>().ok();
214                }
215            }
216        }
217    }
218    None
219}
220
221/// Print health report as human-readable text.
222fn print_text(
223    writer: &mut dyn Write,
224    report: &health::HealthReport,
225    verbose: bool,
226) -> Result<(), IdbError> {
227    wprintln!(writer, "Tablespace Health Report: {}", report.file)?;
228    wprintln!(writer)?;
229
230    if report.indexes.is_empty() {
231        wprintln!(writer, "No INDEX pages found.")?;
232        wprintln!(writer)?;
233    }
234
235    for idx in &report.indexes {
236        let name = idx.index_name.as_deref().unwrap_or("(unknown)");
237        wprintln!(writer, "Index {} ({}):", idx.index_id, name)?;
238        wprintln!(writer, "  Tree depth:     {}", idx.tree_depth)?;
239        wprintln!(
240            writer,
241            "  Pages:          {} total ({} leaf, {} non-leaf)",
242            idx.total_pages,
243            idx.leaf_pages,
244            idx.non_leaf_pages
245        )?;
246        wprintln!(
247            writer,
248            "  Fill factor:    avg {:.0}%, min {:.0}%, max {:.0}%",
249            idx.avg_fill_factor * 100.0,
250            idx.min_fill_factor * 100.0,
251            idx.max_fill_factor * 100.0
252        )?;
253        wprintln!(
254            writer,
255            "  Garbage:        {:.1}% ({} bytes)",
256            idx.avg_garbage_ratio * 100.0,
257            idx.total_garbage_bytes
258        )?;
259        wprintln!(
260            writer,
261            "  Fragmentation:  {:.1}%",
262            idx.fragmentation * 100.0
263        )?;
264
265        if verbose {
266            wprintln!(writer, "  Total records:  {}", idx.total_records)?;
267            if idx.empty_leaf_pages > 0 {
268                wprintln!(writer, "  Empty leaves:   {}", idx.empty_leaf_pages)?;
269            }
270        }
271
272        wprintln!(writer)?;
273    }
274
275    // Summary
276    wprintln!(writer, "Summary:")?;
277    wprintln!(
278        writer,
279        "  Total pages:      {} ({} INDEX, {} non-INDEX, {} empty)",
280        report.summary.total_pages,
281        report.summary.index_pages,
282        report.summary.non_index_pages,
283        report.summary.empty_pages
284    )?;
285    wprintln!(
286        writer,
287        "  Page size:        {} bytes",
288        report.summary.page_size
289    )?;
290    wprintln!(writer, "  Indexes:          {}", report.summary.index_count)?;
291    if report.summary.rtree_pages > 0 {
292        wprintln!(writer, "  RTREE pages:      {}", report.summary.rtree_pages)?;
293    }
294    if report.summary.lob_pages > 0 {
295        wprintln!(writer, "  LOB/BLOB pages:   {}", report.summary.lob_pages)?;
296    }
297    if report.summary.undo_pages > 0 {
298        wprintln!(writer, "  UNDO pages:       {}", report.summary.undo_pages)?;
299    }
300    wprintln!(
301        writer,
302        "  Avg fill factor:  {:.0}%",
303        report.summary.avg_fill_factor * 100.0
304    )?;
305    wprintln!(
306        writer,
307        "  Avg garbage:      {:.1}%",
308        report.summary.avg_garbage_ratio * 100.0
309    )?;
310    wprintln!(
311        writer,
312        "  Avg fragmentation: {:.1}%",
313        report.summary.avg_fragmentation * 100.0
314    )?;
315
316    Ok(())
317}
318
319/// Print health report as Prometheus exposition format.
320fn print_prometheus(
321    writer: &mut dyn Write,
322    report: &health::HealthReport,
323    duration_secs: f64,
324) -> Result<(), IdbError> {
325    let file = &report.file;
326
327    // innodb_pages
328    wprintln!(
329        writer,
330        "{}",
331        prom::help_line("innodb_pages", "Total pages in the tablespace by type")
332    )?;
333    wprintln!(writer, "{}", prom::type_line("innodb_pages", "gauge"))?;
334    wprintln!(
335        writer,
336        "{}",
337        prom::format_gauge_int(
338            "innodb_pages",
339            &[("file", file), ("type", "index")],
340            report.summary.index_pages
341        )
342    )?;
343    wprintln!(
344        writer,
345        "{}",
346        prom::format_gauge_int(
347            "innodb_pages",
348            &[("file", file), ("type", "non_index")],
349            report.summary.non_index_pages
350        )
351    )?;
352    wprintln!(
353        writer,
354        "{}",
355        prom::format_gauge_int(
356            "innodb_pages",
357            &[("file", file), ("type", "empty")],
358            report.summary.empty_pages
359        )
360    )?;
361
362    // innodb_fill_factor
363    wprintln!(
364        writer,
365        "{}",
366        prom::help_line("innodb_fill_factor", "Average B+Tree fill factor per index")
367    )?;
368    wprintln!(writer, "{}", prom::type_line("innodb_fill_factor", "gauge"))?;
369    for idx in &report.indexes {
370        let id_str = idx.index_id.to_string();
371        let index_label = idx.index_name.as_deref().unwrap_or(&id_str);
372        wprintln!(
373            writer,
374            "{}",
375            prom::format_gauge(
376                "innodb_fill_factor",
377                &[("file", file), ("index", index_label)],
378                idx.avg_fill_factor
379            )
380        )?;
381    }
382
383    // innodb_fragmentation_ratio
384    wprintln!(
385        writer,
386        "{}",
387        prom::help_line(
388            "innodb_fragmentation_ratio",
389            "Leaf-level fragmentation ratio per index"
390        )
391    )?;
392    wprintln!(
393        writer,
394        "{}",
395        prom::type_line("innodb_fragmentation_ratio", "gauge")
396    )?;
397    for idx in &report.indexes {
398        let id_str = idx.index_id.to_string();
399        let index_label = idx.index_name.as_deref().unwrap_or(&id_str);
400        wprintln!(
401            writer,
402            "{}",
403            prom::format_gauge(
404                "innodb_fragmentation_ratio",
405                &[("file", file), ("index", index_label)],
406                idx.fragmentation
407            )
408        )?;
409    }
410
411    // innodb_garbage_ratio
412    wprintln!(
413        writer,
414        "{}",
415        prom::help_line("innodb_garbage_ratio", "Average garbage ratio per index")
416    )?;
417    wprintln!(
418        writer,
419        "{}",
420        prom::type_line("innodb_garbage_ratio", "gauge")
421    )?;
422    for idx in &report.indexes {
423        let id_str = idx.index_id.to_string();
424        let index_label = idx.index_name.as_deref().unwrap_or(&id_str);
425        wprintln!(
426            writer,
427            "{}",
428            prom::format_gauge(
429                "innodb_garbage_ratio",
430                &[("file", file), ("index", index_label)],
431                idx.avg_garbage_ratio
432            )
433        )?;
434    }
435
436    // innodb_index_pages
437    wprintln!(
438        writer,
439        "{}",
440        prom::help_line("innodb_index_pages", "Total pages per index")
441    )?;
442    wprintln!(writer, "{}", prom::type_line("innodb_index_pages", "gauge"))?;
443    for idx in &report.indexes {
444        let id_str = idx.index_id.to_string();
445        let index_label = idx.index_name.as_deref().unwrap_or(&id_str);
446        wprintln!(
447            writer,
448            "{}",
449            prom::format_gauge_int(
450                "innodb_index_pages",
451                &[("file", file), ("index", index_label)],
452                idx.total_pages
453            )
454        )?;
455    }
456
457    // innodb_scan_duration_seconds
458    wprintln!(
459        writer,
460        "{}",
461        prom::help_line(
462            "innodb_scan_duration_seconds",
463            "Time spent scanning the tablespace"
464        )
465    )?;
466    wprintln!(
467        writer,
468        "{}",
469        prom::type_line("innodb_scan_duration_seconds", "gauge")
470    )?;
471    wprintln!(
472        writer,
473        "{}",
474        prom::format_gauge(
475            "innodb_scan_duration_seconds",
476            &[("file", file)],
477            duration_secs
478        )
479    )?;
480
481    Ok(())
482}