Skip to main content

systemprompt_database/lifecycle/installation/
migration_cost.rs

1//! Finds the statements in a migration that rewrite a hot table, and pairs
2//! them with the cost its author measured.
3//!
4//! A migration runs inside the boot, before the HTTP listener is bound, in
5//! one transaction, holding its locks, and every per-row trigger on the table
6//! fires for every row it touches. That is how a 3,644-row `UPDATE
7//! ai_requests` took 27 minutes on a production instance: one trigger
8//! re-enqueued the whole client session per row, 113 ms a row. Suspending it
9//! took the same statement to 2.0 s. The same shape emptied `logs` once and
10//! wrote 77,797 outbox tombstones and as many `pg_notify` calls in a single
11//! transaction.
12//!
13//! What this module reports is deliberately blunt: any write to a table on
14//! the hot list, plus the `ALTER`/`CREATE INDEX` forms that take a full scan
15//! or a blocking lock. Judging whether a `WHERE` clause is selective is not
16//! something a parser can do — the author is the one who can measure it, and
17//! [`CostDirective`] is where they say so.
18//!
19//! [`HOT_TABLES`] is the core-shipped list: row counts are from the 2026-09-22
20//! production analysis, and every one of them grows with traffic while none is
21//! ever pruned to a bounded size. It is passed in rather than read directly so
22//! an installation can add the tables it owns — core cannot know about a
23//! downstream repo's hottest table.
24//!
25//! Two consumers, one detector, and they read it over different populations.
26//!
27//! Each repo's test suite runs it over the whole catalogue, where the findings
28//! are a hard failure against a baseline of migrations that predate the gate.
29//! That is where an unmeasured backfill is supposed to be caught — in the pull
30//! request, not on a customer's server.
31//!
32//! The boot runs it over the migrations that are about to execute against a
33//! table that already holds rows, and only warns: a missing comment must never
34//! brick an upgrade, and the statement timeout derived from `measured` is what
35//! actually bounds the damage. It is deliberately not the whole catalogue. A
36//! migration that has already run cannot be made cheaper by a comment, so
37//! reporting it says nothing the operator can act on, and the grandfathered
38//! set is large enough that doing so buried the one finding that mattered.
39//!
40//! Copyright (c) systemprompt.io — Business Source License 1.1.
41//! See <https://systemprompt.io> for licensing details.
42
43use 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/// One statement that rewrites or rescans a hot table. `position` is 1-based,
63/// matching how the runner numbers statements when one fails.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ExpensiveStatement {
66    pub position: usize,
67    pub table: String,
68    pub form: &'static str,
69}
70
71/// One migration's expensive statements and what it declared about them.
72/// `malformed` is set when the body carries a `@cost` line that does not parse.
73#[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
142// Why: an unparseable body is not this check's business — `migration_refs`
143// already refuses it with the parse error, and reporting it twice would only
144// bury that message.
145fn 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        // Why: `INSERT … VALUES` writes what the author typed; only a
185        // select-driven insert scales with the table it reads. The parser
186        // models both as a SelectStmt hanging off the insert, so the two are
187        // told apart by that node carrying rows of its own rather than a
188        // FROM — without this, every literal insert reads as a backfill.
189        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        // Why: a non-concurrent index build holds a write lock for the whole
194        // build; concurrently is the form that does not stop traffic.
195        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
211// Why: both forms read every existing row before they can be recorded, and
212// both take an ACCESS EXCLUSIVE or SHARE UPDATE EXCLUSIVE lock while doing it.
213const 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}