Skip to main content

safe_migrate/rules/
partitions.rs

1use crate::analysis::mutations::{AlterTableActionMutation, Mutation};
2use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
3use crate::engine::config::Config;
4use crate::model::relation::Persistence;
5use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
6use crate::rules::Rule;
7
8pub struct PartitionLockRule;
9
10impl Rule for PartitionLockRule {
11    fn id(&self) -> &'static str {
12        "blocking-partition-mutation"
13    }
14    fn default_tier(&self) -> ViolationTier {
15        ViolationTier::Tier1
16    }
17    fn recipe(&self) -> &'static str {
18        "Attaching or detaching partitions takes an ACCESS EXCLUSIVE lock on the parent table. Run ATTACH PARTITION concurrently (or manage locks explicitly during low traffic)."
19    }
20
21    fn evaluate(
22        &self,
23        mutation: &Mutation,
24        result: &MutationResult,
25        pre_state: &crate::analysis::state::PreState,
26        state: &AnalysisState,
27        config: &Config,
28        _cascade: Option<&CascadeResult>,
29    ) -> Vec<Violation> {
30        if *result == MutationResult::Skipped {
31            return vec![];
32        }
33
34        let mut violations = Vec::new();
35
36        if let Mutation::AlterTable(alter) = mutation {
37            match &alter.action {
38                AlterTableActionMutation::AttachPartition { .. }
39                | AlterTableActionMutation::DetachPartition { .. } => {
40                    let (is_temp, is_stale, rows, is_hash_partitioned) =
41                        match pre_state.relations.get(&alter.id) {
42                            Some(rel) => {
43                                let stale =
44                                    rel.is_stale() && state.baseline_relations.contains(&alter.id);
45                                let is_hash = rel
46                                    .partition_type
47                                    .as_ref()
48                                    .is_some_and(|pt| pt.to_uppercase().contains("HASH"));
49                                (
50                                    rel.persistence == Persistence::Temporary,
51                                    stale,
52                                    rel.estimated_rows.unwrap_or(config.default_rows),
53                                    is_hash,
54                                )
55                            }
56                            None => (false, true, config.default_rows, false),
57                        };
58
59                    if is_temp {
60                        return violations;
61                    }
62
63                    let op_kind = if matches!(
64                        alter.action,
65                        AlterTableActionMutation::AttachPartition { .. }
66                    ) {
67                        OperationKind::AttachPartition
68                    } else {
69                        OperationKind::DetachPartition
70                    };
71
72                    if is_stale {
73                        let key = format!("{}_stale_{}", self.id(), alter.id);
74                        violations.push(Violation { source_range: None,
75                            rule_id: self.id(),
76                            operation_kind: op_kind.clone(),
77                            object_kind: ObjectKind::Table,
78                            object_name: alter.id.to_string(),
79                            tier: ViolationTier::Tier2,
80                            reason: format!("Table {} statistics are stale. Lock evaluations may be inaccurate.", alter.id),
81                            recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
82                            dedup_key: Some(key),
83                                            sql: None,
84                                            fk_dependency_related: false,
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                            fk_dependency_related: false,
137                        });
138                    }
139                }
140                _ => {}
141            }
142        }
143        violations
144    }
145}
146
147pub struct PartitionStrategyMismatchRule;
148
149impl Rule for PartitionStrategyMismatchRule {
150    fn id(&self) -> &'static str {
151        "partition-strategy-mismatch"
152    }
153    fn default_tier(&self) -> ViolationTier {
154        ViolationTier::Tier1
155    }
156    fn recipe(&self) -> &'static str {
157        "Ensure the partition being attached matches the parent table's partition strategy (RANGE/LIST/HASH). Mismatched strategies will cause ATTACH PARTITION to fail."
158    }
159
160    fn evaluate(
161        &self,
162        mutation: &Mutation,
163        result: &MutationResult,
164        pre_state: &crate::analysis::state::PreState,
165        _state: &AnalysisState,
166        _config: &Config,
167        _cascade: Option<&CascadeResult>,
168    ) -> Vec<Violation> {
169        if *result == MutationResult::Skipped {
170            return vec![];
171        }
172
173        let mut violations = Vec::new();
174
175        if let Mutation::AlterTable(alter) = mutation
176            && let AlterTableActionMutation::AttachPartition { child, strategy } = &alter.action
177        {
178            let parent_partition_type = pre_state
179                .relations
180                .get(&alter.id)
181                .and_then(|rel| rel.partition_type.clone());
182
183            if let Some(parent_type) = parent_partition_type {
184                let normalized = |value: &str| {
185                    value
186                        .to_uppercase()
187                        .split('(')
188                        .next()
189                        .unwrap_or_default()
190                        .trim()
191                        .to_string()
192                };
193                let parent_kind = normalized(&parent_type);
194                let child_kind = pre_state
195                    .relations
196                    .get(child)
197                    .and_then(|rel| rel.partition_type.as_deref())
198                    .map(normalized);
199                let bound_kind = strategy.as_deref().map(normalized);
200                let mismatch_kind = child_kind
201                    .filter(|kind| kind != &parent_kind)
202                    .or_else(|| bound_kind.filter(|kind| kind != &parent_kind));
203
204                if let Some(part_type) = mismatch_kind {
205                    violations.push(Violation {
206                        source_range: None,
207                        rule_id: self.id(),
208                        operation_kind: OperationKind::AttachPartition,
209                        object_kind: ObjectKind::Table,
210                        object_name: format!("{} -> {}", child, alter.id),
211                        tier: self.default_tier(),
212                        reason: format!(
213                            "ATTACH PARTITION: partition {} is {} but parent {} is {} (mismatch)",
214                            child, part_type, alter.id, parent_type
215                        ),
216                        recipe: self.recipe(),
217                        dedup_key: None,
218                        sql: None,
219                        fk_dependency_related: false,
220                    });
221                }
222            }
223        }
224        violations
225    }
226}