systemprompt_runtime/
schema_currency.rs1use 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(¤cy);
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#[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(¤cy);
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}