use std::sync::{PoisonError, RwLock};
use systemprompt_database::{
DatabaseProvider, SchemaAhead, SchemaCurrency, SchemaInstallReport, schema_currency,
};
use systemprompt_extension::ExtensionRegistry;
use systemprompt_manifest::Profile;
use crate::error::{RuntimeError, RuntimeResult};
pub async fn assert_schema_current(
registry: &ExtensionRegistry,
db: &dyn DatabaseProvider,
profile: &Profile,
) -> RuntimeResult<Vec<SchemaAhead>> {
let currency = schema_currency(db, registry, &[]).await?;
if currency.is_current() {
let ahead = report_ahead(¤cy);
refuse_breaking(&ahead, profile)?;
return Ok(ahead);
}
Err(RuntimeError::SchemaBehind {
profile: profile.name.clone(),
fresh: currency
.fresh_extensions
.iter()
.map(ToString::to_string)
.collect(),
pending: currency
.pending
.iter()
.map(|m| format!("{}:{:03} {}", m.extension_id, m.version, m.name))
.collect(),
drift: currency
.drift
.iter()
.map(|d| format!("{}:{:03} {}", d.extension_id, d.version, d.name))
.collect(),
})
}
pub fn warn_ahead(ahead: &[SchemaAhead]) {
if ahead.is_empty() {
return;
}
let migrations: Vec<String> = ahead
.iter()
.map(|m| format!("{}:{:03} {}", m.extension, m.version, m.name))
.collect();
tracing::warn!(
migrations = ?migrations,
"Database schema is ahead of this binary: a newer release applied these migrations; \
serving under the expand/contract contract until this node is replaced"
);
}
pub fn refuse_breaking(ahead: &[SchemaAhead], profile: &Profile) -> RuntimeResult<()> {
let breaking: Vec<String> = ahead
.iter()
.filter(|m| m.breaks_older)
.map(|m| format!("{}:{:03} {}", m.extension, m.version, m.name))
.collect();
if breaking.is_empty() {
return Ok(());
}
Err(RuntimeError::SchemaBreaksThisRelease {
profile: profile.name.clone(),
migrations: breaking,
})
}
fn report_ahead(currency: &SchemaCurrency) -> Vec<SchemaAhead> {
let ahead = SchemaAhead::from_currency(currency);
if !currency.superseded_drift.is_empty() {
let superseded: Vec<String> = currency
.superseded_drift
.iter()
.map(|d| format!("{}:{:03} {}", d.extension_id, d.version, d.name))
.collect();
tracing::warn!(
superseded_drift = ?superseded,
"Checksum drift in extensions a newer release migrated; left to that release"
);
}
warn_ahead(&ahead);
ahead
}
#[derive(Debug)]
pub struct SchemaState {
boot: SchemaInstallReport,
ahead: RwLock<Vec<SchemaAhead>>,
}
impl From<SchemaInstallReport> for SchemaState {
fn from(boot: SchemaInstallReport) -> Self {
let ahead = RwLock::new(boot.ahead.clone());
Self { boot, ahead }
}
}
impl SchemaState {
#[must_use]
pub const fn boot(&self) -> &SchemaInstallReport {
&self.boot
}
#[must_use]
pub fn ahead(&self) -> Vec<SchemaAhead> {
self.ahead
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
#[must_use]
pub fn breaks_this_release(&self) -> bool {
self.ahead
.read()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.any(|m| m.breaks_older)
}
fn replace(&self, ahead: Vec<SchemaAhead>) -> Vec<SchemaAhead> {
std::mem::replace(
&mut *self.ahead.write().unwrap_or_else(PoisonError::into_inner),
ahead,
)
}
}
pub async fn recheck(
registry: &ExtensionRegistry,
db: &dyn DatabaseProvider,
state: &SchemaState,
) -> RuntimeResult<()> {
let currency = schema_currency(db, registry, &[]).await?;
let ahead = SchemaAhead::from_currency(¤cy);
let previous = state.replace(ahead.clone());
let newly: Vec<&SchemaAhead> = ahead.iter().filter(|m| !previous.contains(m)).collect();
if newly.is_empty() {
return Ok(());
}
let migrations: Vec<String> = newly
.iter()
.map(|m| format!("{}:{:03} {}", m.extension, m.version, m.name))
.collect();
if newly.iter().any(|m| m.breaks_older) {
tracing::error!(
migrations = ?migrations,
"A newer release applied a contract migration this binary cannot serve; reporting \
not ready until this node is replaced"
);
} else {
tracing::warn!(
migrations = ?migrations,
"A newer release applied migrations while this node was running; serving under the \
expand/contract contract"
);
}
Ok(())
}