Skip to main content

safe_migrate/rules/
constraints.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 BlockingConstraintRule;
9
10impl Rule for BlockingConstraintRule {
11    fn id(&self) -> &'static str {
12        "blocking-constraint"
13    }
14    fn default_tier(&self) -> ViolationTier {
15        ViolationTier::Tier1
16    }
17    fn recipe(&self) -> &'static str {
18        "Adding a valid CHECK or FOREIGN KEY constraint takes an ACCESS EXCLUSIVE lock and scans the table. Add it as NOT VALID first, then VALIDATE it in a separate transaction."
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            let (is_temp, mut is_stale, child_rows) = match pre_state.relations.get(&alter.id) {
38                Some(rel) => {
39                    // Only cache-backed relations have meaningful statistics age.
40                    let stale = rel.is_stale() && state.baseline_relations.contains(&alter.id);
41                    (
42                        rel.persistence == Persistence::Temporary,
43                        stale,
44                        rel.estimated_rows.unwrap_or(config.default_rows),
45                    )
46                }
47                None => (false, true, config.default_rows),
48            };
49
50            // Temporary-table locks do not block other sessions.
51            if is_temp {
52                return violations;
53            }
54
55            let action_is_relevant = matches!(
56                &alter.action,
57                AlterTableActionMutation::AddCheckConstraint {
58                    not_valid: false,
59                    ..
60                } | AlterTableActionMutation::AddForeignKey {
61                    not_valid: false,
62                    ..
63                } | AlterTableActionMutation::SetNotNull { .. }
64                    | AlterTableActionMutation::AddUniqueConstraint {
65                        using_index: None,
66                        ..
67                    }
68                    | AlterTableActionMutation::AddPrimaryKeyConstraint {
69                        using_index: None,
70                        ..
71                    }
72                    | AlterTableActionMutation::AddExcludeConstraint { .. }
73                    | AlterTableActionMutation::SetStorage { .. }
74                    | AlterTableActionMutation::SetAccessMethod
75            );
76            if !action_is_relevant {
77                return violations;
78            }
79
80            let max_locked_rows = match &alter.action {
81                AlterTableActionMutation::AddForeignKey { to_table, .. } => {
82                    // Foreign keys can lock both sides; classify by the larger table.
83                    let parent_rows = match pre_state.relations.get(to_table) {
84                        Some(parent_rel) => {
85                            if parent_rel.is_stale() && state.baseline_relations.contains(to_table)
86                            {
87                                is_stale = true;
88                            }
89                            parent_rel.estimated_rows.unwrap_or(config.default_rows)
90                        }
91                        None => {
92                            is_stale = true;
93                            config.default_rows
94                        }
95                    };
96                    std::cmp::max(child_rows, parent_rows)
97                }
98                _ => child_rows,
99            };
100
101            let tier1_threshold = config.rule_tier1_threshold(self.id());
102            let tier2_threshold = config.rule_tier2_threshold(self.id());
103
104            // Check if either table is partitioned (partitioned tables have higher lock costs)
105            let is_partitioned =
106                if let AlterTableActionMutation::AddForeignKey { to_table, .. } = &alter.action {
107                    let child_partitioned = pre_state
108                        .relations
109                        .get(&alter.id)
110                        .is_some_and(|rel| rel.partition_type.is_some());
111                    let parent_partitioned = pre_state
112                        .relations
113                        .get(to_table)
114                        .is_some_and(|rel| rel.partition_type.is_some());
115                    child_partitioned || parent_partitioned
116                } else {
117                    false
118                };
119
120            // Partitioned tables escalate lock severity: use 50% of threshold (floor at 1)
121            let (adjusted_tier1, adjusted_tier2) = if is_partitioned {
122                (
123                    std::cmp::max(1, tier1_threshold / 2),
124                    std::cmp::max(1, tier2_threshold / 2),
125                )
126            } else {
127                (tier1_threshold, tier2_threshold)
128            };
129
130            let tier = if max_locked_rows >= adjusted_tier1 {
131                ViolationTier::Tier1
132            } else if max_locked_rows >= adjusted_tier2 {
133                ViolationTier::Tier2
134            } else {
135                ViolationTier::Tier3
136            };
137
138            // Emit staleness warning if required (and if it's not going to be completely silent)
139            if is_stale && tier != ViolationTier::Tier3 {
140                let key = format!("{}_stale_{}", self.id(), alter.id);
141                violations.push(Violation { source_range: None,
142                    rule_id: self.id(),
143                    operation_kind: OperationKind::AddConstraint,
144                    object_kind: ObjectKind::Table,
145                    object_name: alter.id.to_string(),
146                    tier: ViolationTier::Tier2,
147                    reason: "Table statistics are offline/stale. Lock evaluations may be inaccurate.".to_string(),
148                    recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
149                    dedup_key: Some(key),
150                            sql: None,
151                            fk_dependency_related: false,
152                });
153            }
154
155            // Short-circuit if the locked tables are small enough to be safe
156            if tier == ViolationTier::Tier3 {
157                return violations;
158            }
159
160            match &alter.action {
161                AlterTableActionMutation::AddCheckConstraint {
162                    constraint_name,
163                    not_valid: false,
164                } => {
165                    let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
166                    let mut reason = format!(
167                        "Synchronous CHECK constraint '{}' addition on {}",
168                        name_str, alter.id
169                    );
170                    if is_stale {
171                        reason.push_str(" [WARNING: Based on offline/stale statistics]");
172                    }
173
174                    violations.push(Violation {
175                        source_range: None,
176                        rule_id: self.id(),
177                        operation_kind: OperationKind::AddConstraint,
178                        object_kind: ObjectKind::Table,
179                        object_name: alter.id.to_string(),
180                        tier,
181                        reason,
182                        recipe: self.recipe(),
183                        dedup_key: None,
184                        sql: None,
185                        fk_dependency_related: false,
186                    });
187                }
188                AlterTableActionMutation::AddForeignKey {
189                    constraint_name,
190                    not_valid: false,
191                    to_table,
192                    ..
193                } => {
194                    let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
195
196                    let mut reason = format!(
197                        "Synchronous FOREIGN KEY constraint '{}' addition locks {} and {}",
198                        name_str, alter.id, to_table
199                    );
200                    if is_partitioned {
201                        reason.push_str(" [partitioned tables escalate lock severity]");
202                    }
203                    if is_stale {
204                        reason.push_str(" [WARNING: Based on offline/stale statistics]");
205                    }
206
207                    violations.push(Violation {
208                        source_range: None,
209                        rule_id: self.id(),
210                        operation_kind: OperationKind::AddConstraint,
211                        object_kind: ObjectKind::Table,
212                        object_name: alter.id.to_string(),
213                        tier,
214                        reason,
215                        recipe: self.recipe(),
216                        dedup_key: None,
217                        sql: None,
218                        fk_dependency_related: false,
219                    });
220                }
221                AlterTableActionMutation::SetNotNull { column } => {
222                    let has_fast_path = pre_state
223                        .relations
224                        .get(&alter.id)
225                        .map(|r| {
226                            r.get_column(column)
227                                .map(|c| !c.is_nullable)
228                                .unwrap_or(false)
229                        })
230                        .unwrap_or(false);
231
232                    if !has_fast_path {
233                        violations.push(Violation {
234                            source_range: None,
235                            rule_id: self.id(),
236                            operation_kind: OperationKind::AddConstraint,
237                            object_kind: ObjectKind::Table,
238                            object_name: format!("{}.{}", alter.id, column),
239                            tier,
240                            reason: format!("Synchronous SET NOT NULL on {}.{}", alter.id, column),
241                            recipe: "Add CHECK constraint NOT VALID, then VALIDATE separately.",
242                            dedup_key: None,
243                            sql: None,
244                            fk_dependency_related: false,
245                        });
246                    }
247                }
248                AlterTableActionMutation::AddUniqueConstraint {
249                    using_index: None, ..
250                }
251                | AlterTableActionMutation::AddPrimaryKeyConstraint {
252                    using_index: None, ..
253                }
254                | AlterTableActionMutation::AddExcludeConstraint { .. } => {
255                    let mut reason = format!(
256                        "Building an index for a UNIQUE, PRIMARY KEY, or EXCLUDE constraint on {}",
257                        alter.id
258                    );
259                    if is_stale {
260                        reason.push_str(" [WARNING: Based on offline/stale statistics]");
261                    }
262
263                    violations.push(Violation { source_range: None,
264                        rule_id: "blocking-index-constraint",
265                        operation_kind: OperationKind::AddConstraint,
266                        object_kind: ObjectKind::Table,
267                        object_name: alter.id.to_string(),
268                        tier,
269                        reason,
270                        recipe: "Build a UNIQUE index CONCURRENTLY first, then add the constraint USING INDEX.",
271                        dedup_key: None,
272                                    sql: None,
273                                    fk_dependency_related: false,
274                    });
275                }
276                AlterTableActionMutation::SetStorage { column } => {
277                    let mut reason = format!(
278                        "Changing storage parameter for {}.{} causes a table rewrite",
279                        alter.id, column
280                    );
281                    if is_stale {
282                        reason.push_str(" [WARNING: Based on offline/stale statistics]");
283                    }
284
285                    violations.push(Violation { source_range: None,
286                        rule_id: "table-rewrite-storage",
287                        operation_kind: OperationKind::AlterColumnType,
288                        object_kind: ObjectKind::Table,
289                        object_name: format!("{}.{}", alter.id, column),
290                        tier,
291                        reason,
292                        recipe: "Changing column storage requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
293                        dedup_key: None,
294                                    sql: None,
295                                    fk_dependency_related: false,
296                    });
297                }
298                AlterTableActionMutation::SetAccessMethod => {
299                    let mut reason = format!(
300                        "Changing access method for {} causes a table rewrite",
301                        alter.id
302                    );
303                    if is_stale {
304                        reason.push_str(" [WARNING: Based on offline/stale statistics]");
305                    }
306
307                    violations.push(Violation { source_range: None,
308                        rule_id: "table-rewrite-access-method",
309                        operation_kind: OperationKind::AlterColumnType,
310                        object_kind: ObjectKind::Table,
311                        object_name: alter.id.to_string(),
312                        tier,
313                        reason,
314                        recipe: "Changing table access method requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
315                        dedup_key: None,
316                                    sql: None,
317                                    fk_dependency_related: false,
318                    });
319                }
320                _ => {}
321            }
322        }
323        violations
324    }
325}