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    pub reapplied: usize,
38    pub migrations_run: usize,
39}
40
41impl MigrationService<'_> {
42    pub async fn repair_drift(
43        &self,
44        extension: &dyn Extension,
45    ) -> Result<RepairResult, LoaderError> {
46        let status = self.status(extension).await?;
47        Self::refuse_slot_collisions(&status)?;
48
49        if status.drift.is_empty() {
50            return Ok(RepairResult::default());
51        }
52
53        let reapplied = status.drift.len();
54        let guard = BootstrapLockGuard::acquire(self.db).await?;
55        let outcome = self.reapply_drifted(extension, &status.drift).await;
56        let pending = match outcome {
57            Ok(()) => self.run_pending_migrations(extension).await,
58            Err(e) => Err(e),
59        };
60        guard.release().await;
61        let result = pending?;
62
63        Ok(RepairResult {
64            repaired: status.drift,
65            reapplied,
66            migrations_run: result.migrations_run,
67        })
68    }
69
70    pub async fn reconcile_drift(
71        &self,
72        extension: &dyn Extension,
73    ) -> Result<RepairResult, LoaderError> {
74        let status = self.status(extension).await?;
75        Self::refuse_slot_collisions(&status)?;
76
77        if status.drift.is_empty() {
78            return Ok(RepairResult::default());
79        }
80
81        let guard = BootstrapLockGuard::acquire(self.db).await?;
82        let mut outcome = Ok(());
83        for drift in &status.drift {
84            if let Err(e) = self.rewrite_checksum(drift).await {
85                outcome = Err(e);
86                break;
87            }
88        }
89        guard.release().await;
90        outcome?;
91
92        Ok(RepairResult {
93            repaired: status.drift,
94            reapplied: 0,
95            migrations_run: 0,
96        })
97    }
98
99    pub fn refuse_slot_collisions(status: &ExtensionMigrationStatus) -> Result<(), LoaderError> {
100        let Some(collision) = status.slot_collisions.first() else {
101            return Ok(());
102        };
103        Err(LoaderError::MigrationSlotReused {
104            extension: collision.extension_id.clone(),
105            version: collision.version,
106            stored_name: collision.stored_name.clone(),
107            current_name: collision.current_name.clone(),
108        })
109    }
110
111    async fn rewrite_checksum(&self, drift: &ChecksumDrift) -> Result<(), LoaderError> {
112        self.db
113            .execute(
114                &UPDATE_CHECKSUM_SQL,
115                &[&drift.extension_id, &drift.version, &drift.current_checksum],
116            )
117            .await
118            .map_err(|e| LoaderError::MigrationFailed {
119                extension: drift.extension_id.clone(),
120                message: format!(
121                    "Failed to rewrite checksum for migration {} ('{}'): {e}",
122                    drift.version, drift.name
123                ),
124            })?;
125        Ok(())
126    }
127
128    async fn reapply_drifted(
129        &self,
130        extension: &dyn Extension,
131        drift: &[ChecksumDrift],
132    ) -> Result<(), LoaderError> {
133        let ext_id = extension.metadata().id;
134        let migrations = extension.migrations();
135
136        for d in drift {
137            let migration = migrations
138                .iter()
139                .find(|m| m.version == d.version)
140                .ok_or_else(|| LoaderError::MigrationFailed {
141                    extension: ext_id.to_owned(),
142                    message: format!(
143                        "Drifted migration {} ('{}') is no longer declared by extension \
144                         '{ext_id}'",
145                        d.version, d.name
146                    ),
147                })?;
148            self.reapply_one(extension, migration, d).await?;
149        }
150
151        Ok(())
152    }
153
154    async fn reapply_one(
155        &self,
156        extension: &dyn Extension,
157        migration: &Migration,
158        drift: &ChecksumDrift,
159    ) -> Result<(), LoaderError> {
160        let ext_id = extension.metadata().id;
161
162        check_cross_extension_alters(extension, migration)?;
163
164        tracing::info!(
165            extension = %ext_id,
166            version = migration.version,
167            name = %migration.name,
168            no_transaction = migration.no_transaction,
169            "Re-applying drifted migration"
170        );
171
172        let update_params: [&dyn ToDbValue; 3] =
173            [&drift.extension_id, &drift.version, &drift.current_checksum];
174
175        if migration.no_transaction {
176            SqlExecutor::execute_statements_parsed(self.db, migration.sql)
177                .await
178                .map_err(|e| LoaderError::MigrationFailed {
179                    extension: ext_id.to_owned(),
180                    message: format!(
181                        "Failed to re-apply drifted migration {} ({}): {e}",
182                        migration.version, migration.name
183                    ),
184                })?;
185            self.rewrite_checksum(drift).await
186        } else {
187            let statements = SqlExecutor::parse_sql_statements(migration.sql).map_err(|e| {
188                LoaderError::MigrationFailed {
189                    extension: ext_id.to_owned(),
190                    message: format!(
191                        "Failed to parse migration {} ({}): {e}",
192                        migration.version, migration.name
193                    ),
194                }
195            })?;
196            execute_statements_transactional(
197                self.db,
198                &statements,
199                ext_id,
200                migration,
201                Some(TrackingWrite {
202                    sql: UPDATE_CHECKSUM_SQL,
203                    params: &update_params,
204                }),
205            )
206            .await
207        }
208    }
209}