Skip to main content

safe_migrate/rules/
partitions.rs

1// FILE: src/rules/partitions.rs
2use crate::analysis::mutations::{AlterTableActionMutation, Mutation};
3use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
4use crate::engine::config::Config;
5use crate::model::relation::Persistence;
6use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
7use crate::rules::Rule;
8
9pub struct PartitionLockRule;
10
11impl Rule for PartitionLockRule {
12    fn id(&self) -> &'static str {
13        "blocking-partition-mutation"
14    }
15    fn default_tier(&self) -> ViolationTier {
16        ViolationTier::Tier1
17    }
18    fn recipe(&self) -> &'static str {
19        "Attaching or detaching partitions takes an ACCESS EXCLUSIVE lock on the parent table. Run ATTACH PARTITION concurrently (or manage locks explicitly during low traffic)."
20    }
21
22    fn evaluate(
23        &self,
24        mutation: &Mutation,
25        result: &MutationResult,
26        pre_state: &crate::analysis::state::PreState,
27        state: &AnalysisState,
28        config: &Config,
29        _cascade: Option<&CascadeResult>,
30    ) -> Vec<Violation> {
31        if *result == MutationResult::Skipped {
32            return vec![];
33        }
34
35        let mut violations = Vec::new();
36
37        if let Mutation::AlterTable(alter) = mutation {
38            match &alter.action {
39                AlterTableActionMutation::AttachPartition { .. }
40                | AlterTableActionMutation::DetachPartition { .. } => {
41                    let (is_temp, is_stale, rows, is_hash_partitioned) =
42                        match pre_state.relations.get(&alter.id) {
43                            Some(rel) => {
44                                let stale =
45                                    rel.is_stale() && state.baseline_relations.contains(&alter.id);
46                                let is_hash = rel
47                                    .partition_type
48                                    .as_ref()
49                                    .is_some_and(|pt| pt.to_uppercase().contains("HASH"));
50                                (
51                                    rel.persistence == Persistence::Temporary,
52                                    stale,
53                                    rel.estimated_rows.unwrap_or(config.default_rows),
54                                    is_hash,
55                                )
56                            }
57                            None => (false, true, config.default_rows, false),
58                        };
59
60                    if is_temp {
61                        return violations;
62                    }
63
64                    let op_kind = if matches!(
65                        alter.action,
66                        AlterTableActionMutation::AttachPartition { .. }
67                    ) {
68                        OperationKind::AttachPartition
69                    } else {
70                        OperationKind::DetachPartition
71                    };
72
73                    if is_stale {
74                        let key = format!("{}_stale_{}", self.id(), alter.id);
75                        violations.push(Violation { source_range: None,
76                            rule_id: self.id(),
77                            operation_kind: op_kind.clone(),
78                            object_kind: ObjectKind::Table,
79                            object_name: alter.id.to_string(),
80                            tier: ViolationTier::Tier2,
81                            reason: format!("Table {} statistics are stale. Lock evaluations may be inaccurate.", alter.id),
82                            recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
83                            dedup_key: Some(key),
84                                            sql: None,
85                        });
86                    }
87
88                    let tier1_threshold = config.rule_tier1_threshold(self.id());
89                    let tier2_threshold = config.rule_tier2_threshold(self.id());
90
91                    let (adjusted_tier1, adjusted_tier2) = if is_hash_partitioned {
92                        (tier1_threshold / 2, tier2_threshold / 2)
93                    } else {
94                        (tier1_threshold, tier2_threshold)
95                    };
96
97                    let tier = if rows >= adjusted_tier1 {
98                        ViolationTier::Tier1
99                    } else if rows >= adjusted_tier2 {
100                        ViolationTier::Tier2
101                    } else {
102                        ViolationTier::Tier3
103                    };
104
105                    if tier != ViolationTier::Tier3 {
106                        let op_name = if matches!(
107                            alter.action,
108                            AlterTableActionMutation::AttachPartition { .. }
109                        ) {
110                            "Attaching"
111                        } else {
112                            "Detaching"
113                        };
114                        let mut reason = format!(
115                            "{} a partition on heavily utilized parent table {}",
116                            op_name, alter.id
117                        );
118                        if is_hash_partitioned {
119                            reason.push_str(" [HASH partitioning escalates lock severity]");
120                        }
121                        if is_stale {
122                            reason.push_str(" [WARNING: Based on offline/stale statistics]");
123                        }
124
125                        violations.push(Violation {
126                            source_range: None,
127                            rule_id: self.id(),
128                            operation_kind: op_kind,
129                            object_kind: ObjectKind::Table,
130                            object_name: alter.id.to_string(),
131                            tier,
132                            reason,
133                            recipe: self.recipe(),
134                            dedup_key: None,
135                            sql: None,
136                        });
137                    }
138                }
139                _ => {}
140            }
141        }
142        violations
143    }
144}
145
146pub struct PartitionStrategyMismatchRule;
147
148impl Rule for PartitionStrategyMismatchRule {
149    fn id(&self) -> &'static str {
150        "partition-strategy-mismatch"
151    }
152    fn default_tier(&self) -> ViolationTier {
153        ViolationTier::Tier1
154    }
155    fn recipe(&self) -> &'static str {
156        "Ensure the partition being attached matches the parent table's partition strategy (RANGE/LIST/HASH). Mismatched strategies will cause ATTACH PARTITION to fail."
157    }
158
159    fn evaluate(
160        &self,
161        mutation: &Mutation,
162        result: &MutationResult,
163        pre_state: &crate::analysis::state::PreState,
164        _state: &AnalysisState,
165        _config: &Config,
166        _cascade: Option<&CascadeResult>,
167    ) -> Vec<Violation> {
168        if *result == MutationResult::Skipped {
169            return vec![];
170        }
171
172        let mut violations = Vec::new();
173
174        if let Mutation::AlterTable(alter) = mutation
175            && let AlterTableActionMutation::AttachPartition { child } = &alter.action
176        {
177            let parent_partition_type = pre_state
178                .relations
179                .get(&alter.id)
180                .and_then(|rel| rel.partition_type.clone());
181
182            let partition_partition_type = pre_state
183                .relations
184                .get(child)
185                .and_then(|rel| rel.partition_type.clone());
186
187            if let Some(parent_type) = parent_partition_type {
188                match partition_partition_type {
189                    None => {
190                        violations.push(Violation {
191                            source_range: None,
192                            rule_id: self.id(),
193                            operation_kind: OperationKind::AttachPartition,
194                            object_kind: ObjectKind::Table,
195                            object_name: format!("{} -> {}", child, alter.id),
196                            tier: self.default_tier(),
197                            reason: format!(
198                                "ATTACH PARTITION: partition {} has no partition strategy, but parent {} requires {}",
199                                child, alter.id, parent_type
200                            ),
201                            recipe: self.recipe(),
202                            dedup_key: None,
203                            sql: None,
204                        });
205                    }
206                    Some(part_type)
207                        if !part_type
208                            .to_uppercase()
209                            .contains(&parent_type.to_uppercase()) =>
210                    {
211                        violations.push(Violation {
212                            source_range: None,
213                            rule_id: self.id(),
214                            operation_kind: OperationKind::AttachPartition,
215                            object_kind: ObjectKind::Table,
216                            object_name: format!("{} -> {}", child, alter.id),
217                            tier: self.default_tier(),
218                            reason: format!(
219                                "ATTACH PARTITION: partition {} is {} but parent {} is {} (mismatch)",
220                                child, part_type, alter.id, parent_type
221                            ),
222                            recipe: self.recipe(),
223                            dedup_key: None,
224                            sql: None,
225                        });
226                    }
227                    _ => {}
228                }
229            }
230        }
231        violations
232    }
233}