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