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