safe_migrate/rules/
partitions.rs1use 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}