Skip to main content

systemprompt_runtime/
schema_currency.rs

1//! Boot-time schema currency: refuse a database the migration step has not
2//! reached, and recognise one a newer release has already moved past.
3//!
4//! When a node boots without migrating, [`assert_schema_current`] runs while
5//! the application context is built, before the domain services start, and
6//! fails with the pending work and the migrate command, instead of letting
7//! the first query against a missing column fail mid-request.
8//!
9//! A database *ahead* of the binary — ledger rows numbered past every
10//! migration it declares, written by a newer release during a rolling update —
11//! is not a failure. Under the expand/contract contract the previous release
12//! keeps serving it; the state is logged at `WARN` and carried on the
13//! [`systemprompt_database::SchemaInstallReport`] so `/readyz` can report it.
14//! A booting install against an ahead database applies nothing (the newer
15//! release owns that schema) and reports the same state through
16//! [`warn_ahead`]. The exception is an ahead migration recorded as
17//! `@compat: breaking`: [`refuse_breaking`] fails the boot, because the newer
18//! release has contracted the schema this one reads.
19//!
20//! A running node learns the same thing later: [`SchemaState`] holds the boot
21//! report and the latest ahead set, which [`recheck`] refreshes against the
22//! live ledger, so a newer release's migration applied after this node booted
23//! reaches `/readyz` — and an ahead `@compat: breaking` migration makes the
24//! node unready instead of serving a schema it can no longer read.
25//!
26//! Copyright (c) systemprompt.io — Business Source License 1.1.
27//! See <https://systemprompt.io> for licensing details.
28
29use std::sync::{PoisonError, RwLock};
30
31use systemprompt_database::{
32    DatabaseProvider, SchemaAhead, SchemaCurrency, SchemaInstallReport, schema_currency,
33};
34use systemprompt_extension::ExtensionRegistry;
35use systemprompt_manifest::Profile;
36
37use crate::error::{RuntimeError, RuntimeResult};
38
39pub async fn assert_schema_current(
40    registry: &ExtensionRegistry,
41    db: &dyn DatabaseProvider,
42    profile: &Profile,
43) -> RuntimeResult<Vec<SchemaAhead>> {
44    let currency = schema_currency(db, registry, &[]).await?;
45    if currency.is_current() {
46        let ahead = report_ahead(&currency);
47        refuse_breaking(&ahead, profile)?;
48        return Ok(ahead);
49    }
50    Err(RuntimeError::SchemaBehind {
51        profile: profile.name.clone(),
52        fresh: currency
53            .fresh_extensions
54            .iter()
55            .map(ToString::to_string)
56            .collect(),
57        pending: currency
58            .pending
59            .iter()
60            .map(|m| format!("{}:{:03} {}", m.extension_id, m.version, m.name))
61            .collect(),
62        drift: currency
63            .drift
64            .iter()
65            .map(|d| format!("{}:{:03} {}", d.extension_id, d.version, d.name))
66            .collect(),
67    })
68}
69
70pub fn warn_ahead(ahead: &[SchemaAhead]) {
71    if ahead.is_empty() {
72        return;
73    }
74    let migrations: Vec<String> = ahead
75        .iter()
76        .map(|m| format!("{}:{:03} {}", m.extension, m.version, m.name))
77        .collect();
78    tracing::warn!(
79        migrations = ?migrations,
80        "Database schema is ahead of this binary: a newer release applied these migrations; \
81         serving under the expand/contract contract until this node is replaced"
82    );
83}
84
85pub fn refuse_breaking(ahead: &[SchemaAhead], profile: &Profile) -> RuntimeResult<()> {
86    let breaking: Vec<String> = ahead
87        .iter()
88        .filter(|m| m.breaks_older)
89        .map(|m| format!("{}:{:03} {}", m.extension, m.version, m.name))
90        .collect();
91    if breaking.is_empty() {
92        return Ok(());
93    }
94    Err(RuntimeError::SchemaBreaksThisRelease {
95        profile: profile.name.clone(),
96        migrations: breaking,
97    })
98}
99
100fn report_ahead(currency: &SchemaCurrency) -> Vec<SchemaAhead> {
101    let ahead = SchemaAhead::from_currency(currency);
102    if !currency.superseded_drift.is_empty() {
103        let superseded: Vec<String> = currency
104            .superseded_drift
105            .iter()
106            .map(|d| format!("{}:{:03} {}", d.extension_id, d.version, d.name))
107            .collect();
108        tracing::warn!(
109            superseded_drift = ?superseded,
110            "Checksum drift in extensions a newer release migrated; left to that release"
111        );
112    }
113    warn_ahead(&ahead);
114    ahead
115}
116
117/// The boot-time schema report plus the ahead set the last [`recheck`] saw.
118#[derive(Debug)]
119pub struct SchemaState {
120    boot: SchemaInstallReport,
121    ahead: RwLock<Vec<SchemaAhead>>,
122}
123
124impl From<SchemaInstallReport> for SchemaState {
125    fn from(boot: SchemaInstallReport) -> Self {
126        let ahead = RwLock::new(boot.ahead.clone());
127        Self { boot, ahead }
128    }
129}
130
131impl SchemaState {
132    #[must_use]
133    pub const fn boot(&self) -> &SchemaInstallReport {
134        &self.boot
135    }
136
137    #[must_use]
138    pub fn ahead(&self) -> Vec<SchemaAhead> {
139        self.ahead
140            .read()
141            .unwrap_or_else(PoisonError::into_inner)
142            .clone()
143    }
144
145    #[must_use]
146    pub fn breaks_this_release(&self) -> bool {
147        self.ahead
148            .read()
149            .unwrap_or_else(PoisonError::into_inner)
150            .iter()
151            .any(|m| m.breaks_older)
152    }
153
154    fn replace(&self, ahead: Vec<SchemaAhead>) -> Vec<SchemaAhead> {
155        std::mem::replace(
156            &mut *self.ahead.write().unwrap_or_else(PoisonError::into_inner),
157            ahead,
158        )
159    }
160}
161
162pub async fn recheck(
163    registry: &ExtensionRegistry,
164    db: &dyn DatabaseProvider,
165    state: &SchemaState,
166) -> RuntimeResult<()> {
167    let currency = schema_currency(db, registry, &[]).await?;
168    let ahead = SchemaAhead::from_currency(&currency);
169    let previous = state.replace(ahead.clone());
170    let newly: Vec<&SchemaAhead> = ahead.iter().filter(|m| !previous.contains(m)).collect();
171    if newly.is_empty() {
172        return Ok(());
173    }
174    let migrations: Vec<String> = newly
175        .iter()
176        .map(|m| format!("{}:{:03} {}", m.extension, m.version, m.name))
177        .collect();
178    if newly.iter().any(|m| m.breaks_older) {
179        tracing::error!(
180            migrations = ?migrations,
181            "A newer release applied a contract migration this binary cannot serve; reporting \
182             not ready until this node is replaced"
183        );
184    } else {
185        tracing::warn!(
186            migrations = ?migrations,
187            "A newer release applied migrations while this node was running; serving under the \
188             expand/contract contract"
189        );
190    }
191    Ok(())
192}