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                                            fk_dependency_related: false,
86                        });
87                    }
88
89                    let tier1_threshold = config.rule_tier1_threshold(self.id());
90                    let tier2_threshold = config.rule_tier2_threshold(self.id());
91
92                    let (adjusted_tier1, adjusted_tier2) = if is_hash_partitioned {
93                        (tier1_threshold / 2, tier2_threshold / 2)
94                    } else {
95                        (tier1_threshold, tier2_threshold)
96                    };
97
98                    let tier = if rows >= adjusted_tier1 {
99                        ViolationTier::Tier1
100                    } else if rows >= adjusted_tier2 {
101                        ViolationTier::Tier2
102                    } else {
103                        ViolationTier::Tier3
104                    };
105
106                    if tier != ViolationTier::Tier3 {
107                        let op_name = if matches!(
108                            alter.action,
109                            AlterTableActionMutation::AttachPartition { .. }
110                        ) {
111                            "Attaching"
112                        } else {
113                            "Detaching"
114                        };
115                        let mut reason = format!(
116                            "{} a partition on heavily utilized parent table {}",
117                            op_name, alter.id
118                        );
119                        if is_hash_partitioned {
120                            reason.push_str(" [HASH partitioning escalates lock severity]");
121                        }
122                        if is_stale {
123                            reason.push_str(" [WARNING: Based on offline/stale statistics]");
124                        }
125
126                        violations.push(Violation {
127                            source_range: None,
128                            rule_id: self.id(),
129                            operation_kind: op_kind,
130                            object_kind: ObjectKind::Table,
131                            object_name: alter.id.to_string(),
132                            tier,
133                            reason,
134                            recipe: self.recipe(),
135                            dedup_key: None,
136                            sql: None,
137                            fk_dependency_related: false,
138                        });
139                    }
140                }
141                _ => {}
142            }
143        }
144        violations
145    }
146}
147
148pub struct PartitionStrategyMismatchRule;
149
150impl Rule for PartitionStrategyMismatchRule {
151    fn id(&self) -> &'static str {
152        "partition-strategy-mismatch"
153    }
154    fn default_tier(&self) -> ViolationTier {
155        ViolationTier::Tier1
156    }
157    fn recipe(&self) -> &'static str {
158        "Ensure the partition being attached matches the parent table's partition strategy (RANGE/LIST/HASH). Mismatched strategies will cause ATTACH PARTITION to fail."
159    }
160
161    fn evaluate(
162        &self,
163        mutation: &Mutation,
164        result: &MutationResult,
165        pre_state: &crate::analysis::state::PreState,
166        _state: &AnalysisState,
167        _config: &Config,
168        _cascade: Option<&CascadeResult>,
169    ) -> Vec<Violation> {
170        if *result == MutationResult::Skipped {
171            return vec![];
172        }
173
174        let mut violations = Vec::new();
175
176        if let Mutation::AlterTable(alter) = mutation
177            && let AlterTableActionMutation::AttachPartition { child } = &alter.action
178        {
179            let parent_partition_type = pre_state
180                .relations
181                .get(&alter.id)
182                .and_then(|rel| rel.partition_type.clone());
183
184            let partition_partition_type = pre_state
185                .relations
186                .get(child)
187                .and_then(|rel| rel.partition_type.clone());
188
189            if let Some(parent_type) = parent_partition_type {
190                match partition_partition_type {
191                    Some(part_type)
192                        if !part_type
193                            .to_uppercase()
194                            .contains(&parent_type.to_uppercase()) =>
195                    {
196                        violations.push(Violation {
197                            source_range: None,
198                            rule_id: self.id(),
199                            operation_kind: OperationKind::AttachPartition,
200                            object_kind: ObjectKind::Table,
201                            object_name: format!("{} -> {}", child, alter.id),
202                            tier: self.default_tier(),
203                            reason: format!(
204                                "ATTACH PARTITION: partition {} is {} but parent {} is {} (mismatch)",
205                                child, part_type, alter.id, parent_type
206                            ),
207                            recipe: self.recipe(),
208                            dedup_key: None,
209                            sql: None,
210                            fk_dependency_related: false,
211                        });
212                    }
213                    _ => {}
214                }
215            }
216        }
217        violations
218    }
219}