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