io_pimdir/client/diagnostics.rs
1//! What a consistency check asks about the index rather than through it.
2//!
3//! These reads observe invariants the store maintains: whether a refcount
4//! matches the references justifying it, whether a row points at
5//! something absent, what the bodies weigh. They live here rather than in
6//! the operator CLI because the library owns the repairs
7//! (`recompute_refcounts`, `clear_dangling_bindings`), and a repair that
8//! cannot report what it found is a worse seam than a diagnostic that
9//! can.
10//!
11//! Every statement here is a `SELECT`, running on the handle the caller
12//! already holds, read-only or owning.
13
14use alloc::{format, string::String, vec::Vec};
15use std::collections::BTreeSet;
16
17use rusqlite::{OptionalExtension, named_params};
18use serde::Serialize;
19
20use crate::{
21 client::{PimdirError, reader::PimdirReader, rows},
22 sql,
23};
24
25/// How many objects the index holds and what they weigh.
26#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
27pub struct PimdirObjectStats {
28 /// Indexed objects.
29 pub count: u64,
30 /// Their total size in bytes.
31 pub bytes: u64,
32}
33
34/// One object whose stored refcount disagrees with the references that
35/// justify it: items, conflict copies, per-source bases and queue pins.
36#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
37pub struct PimdirRefcountDrift {
38 /// The object's content hash.
39 pub hash: String,
40 /// The refcount the index stores.
41 pub stored: i64,
42 /// The refcount its references add up to.
43 pub expected: i64,
44}
45
46/// How many minted keys one collection holds: the second copies of
47/// identities a source hands over twice, each filed as an item of its own
48/// (spec ยง9).
49#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
50pub struct PimdirMinted {
51 /// The collection holding them.
52 pub collection: String,
53 /// How many of its live items carry a minted key.
54 pub items: i64,
55}
56
57/// One row referencing something that is not there.
58#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
59pub struct PimdirDangling {
60 /// What kind of row dangles (`binding`, `item-object`, `queue-object`).
61 pub kind: &'static str,
62 /// The row, as an operator would name it.
63 pub row: String,
64 /// What it points at and cannot find.
65 pub target: String,
66}
67
68impl PimdirReader {
69 /// How many objects are indexed and what they weigh in total.
70 pub fn object_stats(&self) -> Result<PimdirObjectStats, PimdirError> {
71 let (count, bytes) = self.conn.query_row(sql::OBJECT_STATS, [], |r| {
72 Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
73 })?;
74 Ok(PimdirObjectStats {
75 count: count.max(0) as u64,
76 bytes: bytes.max(0) as u64,
77 })
78 }
79
80 /// The bytes held by objects at least one live item still binds.
81 ///
82 /// An object a live and a retained item share counts here, since
83 /// purging the retained one would not free it.
84 pub fn live_bytes(&self) -> Result<u64, PimdirError> {
85 let bytes: i64 = self.conn.query_row(sql::LIVE_BYTES, [], |r| r.get(0))?;
86 Ok(bytes.max(0) as u64)
87 }
88
89 /// One object's stored size.
90 pub fn object_size(&self, hash: &str) -> Result<Option<u64>, PimdirError> {
91 let size: Option<i64> = self
92 .conn
93 .query_row(sql::OBJECT_SIZE, named_params! { ":hash": hash }, |r| {
94 r.get(0)
95 })
96 .optional()?;
97 Ok(size.map(|size| size.max(0) as u64))
98 }
99
100 /// What a purge with this cutoff would retire: how many retained
101 /// items, and the bytes their bodies weigh.
102 ///
103 /// A preview, so a confirmation can say what is at stake; the purge itself
104 /// is the authority, and the collector is what frees the bytes.
105 pub fn retained_before(&self, cutoff: &str) -> Result<(u64, u64), PimdirError> {
106 let (count, bytes) = self.conn.query_row(
107 sql::COUNT_RETAINED_BEFORE,
108 named_params! { ":cutoff": cutoff },
109 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
110 )?;
111 Ok((count.max(0) as u64, bytes.max(0) as u64))
112 }
113
114 /// Every hash the index knows, to diff against the blob directory: the
115 /// index half of what [`PimdirBlobs::files`](crate::client::PimdirBlobs::files)
116 /// reads from disk.
117 pub fn indexed_hashes(&self) -> Result<BTreeSet<String>, PimdirError> {
118 Ok(rows(&self.conn, sql::LIST_OBJECT_HASHES, [], |r| r.get(0))?
119 .into_iter()
120 .collect())
121 }
122
123 /// The objects whose stored refcount disagrees with their references.
124 ///
125 /// The expected count is exactly what the write path maintains
126 /// incrementally: an item's body, an item's conflict copy, each source's
127 /// stored base, and each queue row pinning a body it enqueued.
128 /// [`recompute_refcounts`](crate::client::PimdirStore::recompute_refcounts) is what
129 /// settles what this reports.
130 pub fn refcount_drift(&self) -> Result<Vec<PimdirRefcountDrift>, PimdirError> {
131 Ok(rows(&self.conn, sql::REFCOUNT_DRIFT, [], |r| {
132 Ok(PimdirRefcountDrift {
133 hash: r.get(0)?,
134 stored: r.get(1)?,
135 expected: r.get(2)?,
136 })
137 })?)
138 }
139
140 /// The minted keys each collection holds, where it holds any.
141 ///
142 /// Not a defect and nothing to repair: two copies of one identity is
143 /// redundancy, and the store holds both rather than judging them. It
144 /// is reported because a collection whose count climbs every sync is
145 /// a source handing over the same duplicate under a new handle each
146 /// run, which an operator has no other way to see.
147 pub fn minted_keys(&self) -> Result<Vec<PimdirMinted>, PimdirError> {
148 Ok(rows(&self.conn, sql::MINTED_KEYS, [], |r| {
149 Ok(PimdirMinted {
150 collection: r.get(0)?,
151 items: r.get(1)?,
152 })
153 })?)
154 }
155
156 /// Every row pointing at something absent: a binding whose item is gone, an
157 /// item or a queue row whose object is not indexed.
158 ///
159 /// Only the first is repairable ([`clear_dangling_bindings`](crate::client::PimdirStore::clear_dangling_bindings));
160 /// the other two still hold data, so they are reported and left alone.
161 pub fn dangling(&self) -> Result<Vec<PimdirDangling>, PimdirError> {
162 let mut dangling = rows(&self.conn, sql::DANGLING_BINDINGS, [], |r| {
163 Ok(PimdirDangling {
164 kind: "binding",
165 row: format!(
166 "{}/{} @{}",
167 r.get::<_, String>(0)?,
168 r.get::<_, String>(1)?,
169 r.get::<_, String>(2)?
170 ),
171 target: format!("item {}/{}", r.get::<_, String>(0)?, r.get::<_, String>(1)?),
172 })
173 })?;
174
175 dangling.extend(rows(&self.conn, sql::DANGLING_ITEM_OBJECTS, [], |r| {
176 Ok(PimdirDangling {
177 kind: "item-object",
178 row: format!("{}/{}", r.get::<_, String>(0)?, r.get::<_, String>(1)?),
179 target: format!("object {}", r.get::<_, String>(2)?),
180 })
181 })?);
182
183 dangling.extend(rows(&self.conn, sql::DANGLING_QUEUE_OBJECTS, [], |r| {
184 Ok(PimdirDangling {
185 kind: "queue-object",
186 row: format!("queue {} ({})", r.get::<_, i64>(0)?, r.get::<_, String>(1)?),
187 target: format!("object {}", r.get::<_, String>(2)?),
188 })
189 })?);
190
191 Ok(dangling)
192 }
193}