Skip to main content

systemprompt_database/lifecycle/migrations/
repair.rs

1//! Migration checksum-drift repair.
2//!
3//! When an already-applied migration file is edited in place, its stored
4//! checksum stops matching the file and the runner refuses to proceed.
5//! [`MigrationService::repair_drift`] re-executes each drifted migration and
6//! rewrites its stored checksum in the same transaction; the tracking row is
7//! never deleted, so a failed re-apply rolls back to "drifted but tracked"
8//! instead of leaving the migration untracked and crash-looping the next
9//! boot. Re-applying requires the migration SQL to be re-executable against
10//! the current schema — a later migration may have invalidated that, in which
11//! case [`MigrationService::reconcile_drift`] rewrites the stored checksum
12//! without executing any SQL. `no_transaction` migrations cannot be repaired
13//! atomically: a mid-SQL failure leaves the row tracked with the old
14//! checksum, which still reports as drift rather than crash-looping.
15//!
16//! Both entry points refuse outright when the recorded row for a slot names a
17//! different migration than the file now occupying it. That is a reused slot,
18//! not drift, and reconciling it would stamp one migration's checksum onto a
19//! row describing another — silencing that row's drift detector for good.
20//!
21//! Copyright (c) systemprompt.io — Business Source License 1.1.
22//! See <https://systemprompt.io> for licensing details.
23
24use super::exec::{TrackingWrite, check_cross_extension_alters, execute_statements_transactional};
25use super::{ChecksumDrift, ExtensionMigrationStatus, MigrationService};
26use crate::lifecycle::installation::BootstrapLockGuard;
27use crate::services::SqlExecutor;
28use systemprompt_extension::{Extension, LoaderError, Migration};
29use systemprompt_identifiers::ToDbValue;
30
31const UPDATE_CHECKSUM_SQL: &str =
32    "UPDATE extension_migrations SET checksum = $3 WHERE extension_id = $1 AND version = $2";
33
34#[derive(Debug, Default, Clone)]
35pub struct RepairResult {
36    pub repaired: Vec<ChecksumDrift>,
37    // Why: drifted migrations whose SQL was actually re-executed. Zero for
38    // reconcile_drift, which only rewrites bookkeeping.
39    pub reapplied: usize,
40    // Why: previously-unapplied migrations run as part of the repair — a
41    // different number, and reporting it as re-applied is what hid the bug.
42    pub migrations_run: usize,
43}
44
45impl MigrationService<'_> {
46    pub async fn repair_drift(
47        &self,
48        extension: &dyn Extension,
49    ) -> Result<RepairResult, LoaderError> {
50        let status = self.status(extension).await?;
51        Self::refuse_slot_collisions(&status)?;
52
53        if status.drift.is_empty() {
54            return Ok(RepairResult::default());
55        }
56
57        let reapplied = status.drift.len();
58        let guard = BootstrapLockGuard::acquire(self.db).await?;
59        let outcome = self.reapply_drifted(extension, &status.drift).await;
60        let pending = match outcome {
61            Ok(()) => self.run_pending_migrations(extension).await,
62            Err(e) => Err(e),
63        };
64        guard.release().await;
65        let result = pending?;
66
67        Ok(RepairResult {
68            repaired: status.drift,
69            reapplied,
70            migrations_run: result.migrations_run,
71        })
72    }
73
74    pub async fn reconcile_drift(
75        &self,
76        extension: &dyn Extension,
77    ) -> Result<RepairResult, LoaderError> {
78        let status = self.status(extension).await?;
79        Self::refuse_slot_collisions(&status)?;
80
81        if status.drift.is_empty() {
82            return Ok(RepairResult::default());
83        }
84
85        let guard = BootstrapLockGuard::acquire(self.db).await?;
86        let mut outcome = Ok(());
87        for drift in &status.drift {
88            if let Err(e) = self.rewrite_checksum(drift).await {
89                outcome = Err(e);
90                break;
91            }
92        }
93        guard.release().await;
94        outcome?;
95
96        Ok(RepairResult {
97            repaired: status.drift,
98            reapplied: 0,
99            migrations_run: 0,
100        })
101    }
102
103    // Why: matching a recorded row on (extension_id, version) alone cannot
104    // tell an edited migration from a reused slot. Reconciling a collision
105    // stamps one migration's checksum onto a row describing another, which
106    // silences that row's drift detector permanently. Refuse instead.
107    pub fn refuse_slot_collisions(status: &ExtensionMigrationStatus) -> Result<(), LoaderError> {
108        let Some(collision) = status.slot_collisions.first() else {
109            return Ok(());
110        };
111        Err(LoaderError::MigrationSlotReused {
112            extension: collision.extension_id.clone(),
113            version: collision.version,
114            stored_name: collision.stored_name.clone(),
115            current_name: collision.current_name.clone(),
116        })
117    }
118
119    async fn rewrite_checksum(&self, drift: &ChecksumDrift) -> Result<(), LoaderError> {
120        self.db
121            .execute(
122                &UPDATE_CHECKSUM_SQL,
123                &[&drift.extension_id, &drift.version, &drift.current_checksum],
124            )
125            .await
126            .map_err(|e| LoaderError::MigrationFailed {
127                extension: drift.extension_id.clone(),
128                message: format!(
129                    "Failed to rewrite checksum for migration {} ('{}'): {e}",
130                    drift.version, drift.name
131                ),
132            })?;
133        Ok(())
134    }
135
136    async fn reapply_drifted(
137        &self,
138        extension: &dyn Extension,
139        drift: &[ChecksumDrift],
140    ) -> Result<(), LoaderError> {
141        let ext_id = extension.metadata().id;
142        let migrations = extension.migrations();
143
144        for d in drift {
145            let migration = migrations
146                .iter()
147                .find(|m| m.version == d.version)
148                .ok_or_else(|| LoaderError::MigrationFailed {
149                    extension: ext_id.to_owned(),
150                    message: format!(
151                        "Drifted migration {} ('{}') is no longer declared by extension \
152                         '{ext_id}'",
153                        d.version, d.name
154                    ),
155                })?;
156            self.reapply_one(extension, migration, d).await?;
157        }
158
159        Ok(())
160    }
161
162    async fn reapply_one(
163        &self,
164        extension: &dyn Extension,
165        migration: &Migration,
166        drift: &ChecksumDrift,
167    ) -> Result<(), LoaderError> {
168        let ext_id = extension.metadata().id;
169
170        check_cross_extension_alters(extension, migration)?;
171
172        tracing::info!(
173            extension = %ext_id,
174            version = migration.version,
175            name = %migration.name,
176            no_transaction = migration.no_transaction,
177            "Re-applying drifted migration"
178        );
179
180        let update_params: [&dyn ToDbValue; 3] =
181            [&drift.extension_id, &drift.version, &drift.current_checksum];
182
183        if migration.no_transaction {
184            SqlExecutor::execute_statements_parsed(self.db, migration.sql)
185                .await
186                .map_err(|e| LoaderError::MigrationFailed {
187                    extension: ext_id.to_owned(),
188                    message: format!(
189                        "Failed to re-apply drifted migration {} ({}): {e}",
190                        migration.version, migration.name
191                    ),
192                })?;
193            self.rewrite_checksum(drift).await
194        } else {
195            let statements = SqlExecutor::parse_sql_statements(migration.sql).map_err(|e| {
196                LoaderError::MigrationFailed {
197                    extension: ext_id.to_owned(),
198                    message: format!(
199                        "Failed to parse migration {} ({}): {e}",
200                        migration.version, migration.name
201                    ),
202                }
203            })?;
204            execute_statements_transactional(
205                self.db,
206                &statements,
207                ext_id,
208                migration,
209                Some(TrackingWrite {
210                    sql: UPDATE_CHECKSUM_SQL,
211                    params: &update_params,
212                }),
213            )
214            .await
215        }
216    }
217}