safe_migrate/rules/
partitions.rs1use crate::analysis::mutations::{AlterTableActionMutation, Mutation};
3use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
4use crate::ast::identifiers::ObjectId;
5use crate::engine::config::Config;
6use crate::model::relation::{Persistence, RelationState};
7use crate::report::violations::{Violation, ViolationTier};
8use crate::rules::Rule;
9use std::collections::HashMap;
10
11pub struct PartitionLockRule;
12
13impl Rule for PartitionLockRule {
14 fn id(&self) -> &'static str {
15 "blocking-partition-mutation"
16 }
17 fn default_tier(&self) -> ViolationTier {
18 ViolationTier::Tier1
19 }
20 fn recipe(&self) -> &'static str {
21 "Attaching or detaching partitions takes an ACCESS EXCLUSIVE lock on the parent table. Run ATTACH PARTITION concurrently (or manage locks explicitly during low traffic)."
22 }
23
24 fn evaluate(
25 &self,
26 mutation: &Mutation,
27 result: &MutationResult,
28 pre_relations: &HashMap<ObjectId, RelationState>,
29 state: &AnalysisState,
30 config: &Config,
31 _cascade: Option<&CascadeResult>,
32 ) -> Vec<Violation> {
33 if *result == MutationResult::Skipped {
34 return vec![];
35 }
36
37 let mut violations = Vec::new();
38
39 if let Mutation::AlterTable(alter) = mutation {
40 match &alter.action {
41 AlterTableActionMutation::AttachPartition { .. }
42 | AlterTableActionMutation::DetachPartition { .. } => {
43 let (is_temp, is_stale, rows) = match pre_relations.get(&alter.id) {
44 Some(rel) => {
45 let stale =
46 rel.is_stale() && state.baseline_relations.contains(&alter.id);
47 (
48 rel.persistence == Persistence::Temporary,
49 stale,
50 rel.estimated_rows.unwrap_or(config.default_rows),
51 )
52 }
53 None => (false, true, config.default_rows),
54 };
55
56 if is_temp {
57 return violations;
58 }
59
60 if is_stale {
61 let key = format!("{}_stale_{}", self.id(), alter.id);
62 violations.push(Violation {
63 rule_id: self.id(),
64 title: format!("Table {} statistics are stale. Lock evaluations may be inaccurate.", alter.id),
65 tier: ViolationTier::Tier2,
66 recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
67 dedup_key: Some(key),
68 });
69 }
70
71 let tier1_threshold = config.rule_tier1_threshold(self.id());
72 let tier2_threshold = config.rule_tier2_threshold(self.id());
73
74 let tier = if rows >= tier1_threshold {
75 ViolationTier::Tier1
76 } else if rows >= tier2_threshold {
77 ViolationTier::Tier2
78 } else {
79 ViolationTier::Tier3
80 };
81
82 if tier != ViolationTier::Tier3 {
83 let op_name = if matches!(
84 alter.action,
85 AlterTableActionMutation::AttachPartition { .. }
86 ) {
87 "Attaching"
88 } else {
89 "Detaching"
90 };
91 let mut title = format!(
92 "{} a partition on heavily utilized parent table {}",
93 op_name, alter.id
94 );
95 if is_stale {
96 title.push_str(" [WARNING: Based on offline/stale statistics]");
97 }
98
99 violations.push(Violation {
100 rule_id: self.id(),
101 title,
102 tier,
103 recipe: self.recipe(),
104 dedup_key: None,
105 });
106 }
107 }
108 _ => {}
109 }
110 }
111 violations
112 }
113}