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::{ExtensionId, 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::MigrationStepFailed {
119                extension: drift.extension_id.clone(),
120                context: format!(
121                    "Failed to rewrite checksum for migration {} ('{}')",
122                    drift.version, drift.name
123                ),
124                source: Box::new(e),
125            })?;
126        Ok(())
127    }
128
129    async fn reapply_drifted(
130        &self,
131        extension: &dyn Extension,
132        drift: &[ChecksumDrift],
133    ) -> Result<(), LoaderError> {
134        let ext_id = &ExtensionId::new(extension.metadata().id);
135        let migrations = extension.migrations();
136
137        for d in drift {
138            let migration = migrations
139                .iter()
140                .find(|m| m.version == d.version)
141                .ok_or_else(|| LoaderError::MigrationFailed {
142                    extension: ext_id.clone(),
143                    message: format!(
144                        "Drifted migration {} ('{}') is no longer declared by extension \
145                         '{ext_id}'",
146                        d.version, d.name
147                    ),
148                })?;
149            self.reapply_one(extension, migration, d).await?;
150        }
151
152        Ok(())
153    }
154
155    async fn reapply_one(
156        &self,
157        extension: &dyn Extension,
158        migration: &Migration,
159        drift: &ChecksumDrift,
160    ) -> Result<(), LoaderError> {
161        let ext_id = &ExtensionId::new(extension.metadata().id);
162
163        check_cross_extension_alters(extension, migration)?;
164
165        tracing::info!(
166            extension = %ext_id,
167            version = migration.version,
168            name = %migration.name,
169            no_transaction = migration.no_transaction,
170            "Re-applying drifted migration"
171        );
172
173        let update_params: [&dyn ToDbValue; 3] =
174            [&drift.extension_id, &drift.version, &drift.current_checksum];
175
176        if migration.no_transaction {
177            SqlExecutor::execute_statements_parsed(self.db, migration.sql)
178                .await
179                .map_err(|e| LoaderError::MigrationStepFailed {
180                    extension: ext_id.clone(),
181                    context: format!(
182                        "Failed to re-apply drifted migration {} ({})",
183                        migration.version, migration.name
184                    ),
185                    source: Box::new(e),
186                })?;
187            self.rewrite_checksum(drift).await
188        } else {
189            let statements = SqlExecutor::parse_sql_statements(migration.sql).map_err(|e| {
190                LoaderError::MigrationStepFailed {
191                    extension: ext_id.clone(),
192                    context: format!(
193                        "Failed to parse migration {} ({})",
194                        migration.version, migration.name
195                    ),
196                    source: Box::new(e),
197                }
198            })?;
199            execute_statements_transactional(
200                self.db,
201                &statements,
202                ext_id,
203                migration,
204                Some(TrackingWrite {
205                    sql: UPDATE_CHECKSUM_SQL,
206                    params: &update_params,
207                }),
208            )
209            .await
210        }
211    }
212}