systemprompt_database/lifecycle/installation/
migration_cost.rs1use std::sync::Arc;
44
45use pg_query::NodeEnum;
46use pg_query::protobuf::AlterTableType;
47use systemprompt_extension::Extension;
48use systemprompt_extension::cost::{self, CostDirective};
49
50pub const HOT_TABLES: &[&str] = &[
51 "ai_requests",
52 "ai_request_messages",
53 "ai_request_payloads",
54 "ai_request_client_evidence",
55 "ai_request_tool_calls",
56 "analytics_events",
57 "event_outbox",
58 "logs",
59 "user_sessions",
60];
61
62#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ExpensiveStatement {
66 pub position: usize,
67 pub table: String,
68 pub form: &'static str,
69}
70
71#[derive(Debug, Clone)]
74pub struct MigrationCost {
75 pub extension: String,
76 pub migration: String,
77 pub statements: Vec<ExpensiveStatement>,
78 pub declared: Option<CostDirective>,
79 pub malformed: Option<String>,
80}
81
82impl MigrationCost {
83 #[must_use]
84 pub const fn is_undeclared(&self) -> bool {
85 !self.statements.is_empty() && self.declared.is_none()
86 }
87
88 #[must_use]
89 pub fn label(&self) -> String {
90 format!("{}/{}", self.extension, self.migration)
91 }
92
93 #[must_use]
94 pub fn statement_summary(&self) -> String {
95 self.statements
96 .iter()
97 .map(|s| format!("statement {} {} {}", s.position, s.form, s.table))
98 .collect::<Vec<_>>()
99 .join("; ")
100 }
101}
102
103#[must_use]
104pub fn audit_migration_cost(extensions: &[Arc<dyn Extension>], hot: &[&str]) -> Vec<MigrationCost> {
105 let mut out = Vec::new();
106 for ext in extensions {
107 let extension = ext.id().to_owned();
108 for migration in ext.migrations().into_iter().filter(|m| !m.tombstone) {
109 let label = format!("{:03}_{}", migration.version, migration.name);
110 if let Some(cost) = audit_one(&extension, &label, migration.sql, hot) {
111 out.push(cost);
112 }
113 }
114 }
115 out
116}
117
118#[must_use]
119pub fn audit_one(
120 extension: &str,
121 migration: &str,
122 sql: &str,
123 hot: &[&str],
124) -> Option<MigrationCost> {
125 let (declared, malformed) = match cost::parse(sql) {
126 Ok(found) => (found, None),
127 Err(e) => (None, Some(e.to_string())),
128 };
129 let statements = expensive_statements(sql, hot);
130 if statements.is_empty() && declared.is_none() && malformed.is_none() {
131 return None;
132 }
133 Some(MigrationCost {
134 extension: extension.to_owned(),
135 migration: migration.to_owned(),
136 statements,
137 declared,
138 malformed,
139 })
140}
141
142fn expensive_statements(sql: &str, hot: &[&str]) -> Vec<ExpensiveStatement> {
146 let Ok(parsed) = pg_query::parse(sql) else {
147 return Vec::new();
148 };
149 let mut out = Vec::new();
150 for (index, node) in parsed
151 .protobuf
152 .stmts
153 .iter()
154 .filter_map(|raw| raw.stmt.as_ref().and_then(|s| s.node.as_ref()))
155 .enumerate()
156 {
157 let position = index + 1;
158 if let Some((table, form)) = classify(node)
159 && hot.contains(&table.as_str())
160 {
161 out.push(ExpensiveStatement {
162 position,
163 table,
164 form,
165 });
166 }
167 }
168 out
169}
170
171fn is_select_driven(select: Option<&pg_query::protobuf::Node>) -> bool {
172 let Some(NodeEnum::SelectStmt(select)) = select.and_then(|n| n.node.as_ref()) else {
173 return false;
174 };
175 select.values_lists.is_empty()
176}
177
178fn classify(node: &NodeEnum) -> Option<(String, &'static str)> {
179 match node {
180 NodeEnum::UpdateStmt(stmt) => Some((stmt.relation.as_ref()?.relname.clone(), "UPDATE on")),
181 NodeEnum::DeleteStmt(stmt) => {
182 Some((stmt.relation.as_ref()?.relname.clone(), "DELETE from"))
183 },
184 NodeEnum::InsertStmt(stmt) if is_select_driven(stmt.select_stmt.as_deref()) => Some((
190 stmt.relation.as_ref()?.relname.clone(),
191 "INSERT … SELECT into",
192 )),
193 NodeEnum::IndexStmt(stmt) if !stmt.concurrent => Some((
196 stmt.relation.as_ref()?.relname.clone(),
197 "CREATE INDEX (not CONCURRENTLY) on",
198 )),
199 NodeEnum::AlterTableStmt(stmt) => {
200 let table = stmt.relation.as_ref()?.relname.clone();
201 let form = stmt.cmds.iter().find_map(|cmd| match cmd.node.as_ref() {
202 Some(NodeEnum::AlterTableCmd(c)) => scanning_alter(c.subtype),
203 _ => None,
204 })?;
205 Some((table, form))
206 },
207 _ => None,
208 }
209}
210
211const fn scanning_alter(subtype: i32) -> Option<&'static str> {
214 if subtype == AlterTableType::AtValidateConstraint as i32 {
215 return Some("ALTER TABLE … VALIDATE CONSTRAINT on");
216 }
217 if subtype == AlterTableType::AtSetNotNull as i32 {
218 return Some("ALTER TABLE … SET NOT NULL on");
219 }
220 None
221}