Skip to main content

pg_blast_radius/
types.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
5#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
6pub enum LockMode {
7    AccessShare,
8    RowShare,
9    RowExclusive,
10    ShareUpdateExclusive,
11    Share,
12    ShareRowExclusive,
13    Exclusive,
14    AccessExclusive,
15}
16
17impl LockMode {
18    pub fn blocks_reads(self) -> bool {
19        self == LockMode::AccessExclusive
20    }
21
22    pub fn blocks_writes(self) -> bool {
23        self >= LockMode::Share
24    }
25}
26
27impl fmt::Display for LockMode {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::AccessShare => write!(f, "ACCESS SHARE"),
31            Self::RowShare => write!(f, "ROW SHARE"),
32            Self::RowExclusive => write!(f, "ROW EXCLUSIVE"),
33            Self::ShareUpdateExclusive => write!(f, "SHARE UPDATE EXCLUSIVE"),
34            Self::Share => write!(f, "SHARE"),
35            Self::ShareRowExclusive => write!(f, "SHARE ROW EXCLUSIVE"),
36            Self::Exclusive => write!(f, "EXCLUSIVE"),
37            Self::AccessExclusive => write!(f, "ACCESS EXCLUSIVE"),
38        }
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, clap::ValueEnum)]
43#[serde(rename_all = "lowercase")]
44pub enum RiskLevel {
45    Low,
46    Medium,
47    High,
48    Extreme,
49}
50
51impl fmt::Display for RiskLevel {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::Low => write!(f, "LOW"),
55            Self::Medium => write!(f, "MEDIUM"),
56            Self::High => write!(f, "HIGH"),
57            Self::Extreme => write!(f, "EXTREME"),
58        }
59    }
60}
61
62
63#[derive(Debug, Clone, Serialize)]
64#[serde(rename_all = "snake_case", tag = "type")]
65pub enum RewriteRisk {
66    None,
67    Required,
68    Conditional { reason: String },
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "snake_case")]
73pub enum RolloutPhase {
74    Expand,
75    Backfill,
76    Validate,
77    Switch,
78    Contract,
79}
80
81impl fmt::Display for RolloutPhase {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            Self::Expand => write!(f, "expand"),
85            Self::Backfill => write!(f, "backfill"),
86            Self::Validate => write!(f, "validate"),
87            Self::Switch => write!(f, "switch"),
88            Self::Contract => write!(f, "contract"),
89        }
90    }
91}
92
93
94#[derive(Debug, Clone, Serialize)]
95pub struct RecipeStep {
96    pub phase: RolloutPhase,
97    pub description: String,
98    pub sql: String,
99    pub separate_transaction: bool,
100}
101
102#[derive(Debug, Clone, Serialize)]
103pub struct RolloutRecipe {
104    pub title: String,
105    pub steps: Vec<RecipeStep>,
106}
107
108#[derive(Debug, Clone, Serialize)]
109pub struct Finding {
110    pub rule_id: String,
111    pub risk_level: RiskLevel,
112    pub confidence: ConfidenceLedger,
113    pub lock_mode: LockMode,
114    pub rewrite: RewriteRisk,
115    pub affected_table: Option<String>,
116    pub summary: String,
117    pub explanation: String,
118    pub recipe: Option<RolloutRecipe>,
119    pub pg_version_note: Option<String>,
120    pub statement_sql: String,
121    pub duration_forecast: Option<DurationForecast>,
122}
123
124#[derive(Debug, Clone, Serialize)]
125pub struct TableSize {
126    pub total_bytes: i64,
127    pub row_estimate: i64,
128    pub human_size: String,
129}
130
131#[derive(Debug, Clone, Serialize)]
132pub struct TableBlastRadius {
133    pub table_name: String,
134    pub strongest_lock: LockMode,
135    pub blocks_reads: bool,
136    pub blocks_writes: bool,
137    pub statement_count: usize,
138    pub table_size: Option<TableSize>,
139    pub duration_forecast: Option<DurationForecast>,
140    pub blocked_queries: Vec<BlockedQueryForecast>,
141    pub total_blocked_qps: f64,
142    pub confidence: ConfidenceLedger,
143    pub recommendation: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct BlastRadius {
148    pub per_table: Vec<TableBlastRadius>,
149}
150
151#[derive(Debug, Clone, Serialize)]
152pub struct WorkloadMeta {
153    pub stats_reset: Option<String>,
154    pub collected_at: String,
155    pub stats_window_seconds: Option<f64>,
156    pub unparseable_queries: usize,
157}
158
159#[derive(Debug, Clone, Serialize)]
160pub struct AnalysisResult {
161    pub file: String,
162    pub findings: Vec<Finding>,
163    pub blast_radius: BlastRadius,
164    pub overall_risk: RiskLevel,
165    pub overall_confidence: ConfidenceGrade,
166    pub workload_meta: Option<WorkloadMeta>,
167}
168
169pub fn human_size(bytes: i64) -> String {
170    let bytes = bytes as f64;
171    if bytes < 1024.0 {
172        format!("{bytes:.0} B")
173    } else if bytes < 1024.0 * 1024.0 {
174        format!("{:.1} kB", bytes / 1024.0)
175    } else if bytes < 1024.0 * 1024.0 * 1024.0 {
176        format!("{:.1} MB", bytes / (1024.0 * 1024.0))
177    } else {
178        format!("{:.1} GB", bytes / (1024.0 * 1024.0 * 1024.0))
179    }
180}
181
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum ConfidenceGrade {
186    Static,
187    Estimated,
188    Measured,
189}
190
191impl fmt::Display for ConfidenceGrade {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        match self {
194            Self::Static => write!(f, "STATIC"),
195            Self::Estimated => write!(f, "ESTIMATED"),
196            Self::Measured => write!(f, "MEASURED"),
197        }
198    }
199}
200
201#[derive(Debug, Clone, Serialize)]
202pub struct ConfidenceLedger {
203    pub from_docs: Vec<String>,
204    pub from_catalog: Vec<String>,
205    pub from_stats: Vec<String>,
206    pub unknowns: Vec<String>,
207    pub grade: ConfidenceGrade,
208}
209
210impl ConfidenceLedger {
211    pub fn static_only(doc_facts: Vec<String>) -> Self {
212        Self {
213            from_docs: doc_facts,
214            from_catalog: vec![],
215            from_stats: vec![],
216            unknowns: vec![
217                "table size unknown".into(),
218                "query load unknown".into(),
219                "cache state unknown".into(),
220            ],
221            grade: ConfidenceGrade::Static,
222        }
223    }
224
225    pub fn with_catalog(doc_facts: Vec<String>, catalog_facts: Vec<String>) -> Self {
226        Self {
227            from_docs: doc_facts,
228            from_catalog: catalog_facts,
229            from_stats: vec![],
230            unknowns: vec![
231                "query load unknown".into(),
232                "cache state unknown".into(),
233            ],
234            grade: ConfidenceGrade::Estimated,
235        }
236    }
237
238    pub fn with_workload(
239        doc_facts: Vec<String>,
240        catalog_facts: Vec<String>,
241        stats_facts: Vec<String>,
242    ) -> Self {
243        Self {
244            from_docs: doc_facts,
245            from_catalog: catalog_facts,
246            from_stats: stats_facts,
247            unknowns: vec!["cache state assumed".into()],
248            grade: ConfidenceGrade::Measured,
249        }
250    }
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
254#[serde(rename_all = "snake_case")]
255pub enum AssumptionSource {
256    Documentation,
257    Catalog,
258    Workload,
259    Assumed,
260}
261
262#[derive(Debug, Clone, Serialize)]
263pub struct ForecastAssumption {
264    pub factor: String,
265    pub assumed: String,
266    pub source: AssumptionSource,
267}
268
269#[derive(Debug, Clone, Serialize)]
270pub struct DurationForecast {
271    pub p50_seconds: f64,
272    pub p90_seconds: f64,
273    pub worst_seconds: f64,
274    pub assumptions: Vec<ForecastAssumption>,
275}
276
277impl fmt::Display for DurationForecast {
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        write!(
280            f,
281            "{} (p50)  {} (p90)  {} (worst)",
282            format_seconds(self.p50_seconds),
283            format_seconds(self.p90_seconds),
284            format_seconds(self.worst_seconds),
285        )
286    }
287}
288
289fn format_seconds(secs: f64) -> String {
290    if secs < 1.0 {
291        format!("{:.0}ms", secs * 1000.0)
292    } else if secs < 60.0 {
293        format!("{:.1}s", secs)
294    } else if secs < 3600.0 {
295        format!("{:.0}m", secs / 60.0)
296    } else {
297        format!("{:.1}h", secs / 3600.0)
298    }
299}
300
301#[derive(Debug, Clone, Serialize)]
302pub struct BlockedQueryForecast {
303    pub query_label: String,
304    pub normalised_sql: String,
305    pub calls_per_sec: f64,
306    pub queued_at_p50: u64,
307    pub queued_at_p90: u64,
308}
309
310pub fn adjust_risk_for_size(base: RiskLevel, total_bytes: Option<i64>) -> RiskLevel {
311    match total_bytes {
312        Some(b) if b < 10_000_000 => {
313            match base {
314                RiskLevel::Extreme => RiskLevel::High,
315                RiskLevel::High => RiskLevel::Medium,
316                RiskLevel::Medium => RiskLevel::Low,
317                RiskLevel::Low => RiskLevel::Low,
318            }
319        }
320        Some(b) if b > 10_000_000_000 => {
321            match base {
322                RiskLevel::Low => RiskLevel::Medium,
323                RiskLevel::Medium => RiskLevel::High,
324                RiskLevel::High => RiskLevel::Extreme,
325                RiskLevel::Extreme => RiskLevel::Extreme,
326            }
327        }
328        _ => base,
329    }
330}