Skip to main content

alembic_engine/
state.rs

1//! local uid -> backend id state store.
2
3use crate::types::BackendId;
4use alembic_core::{TypeName, Uid};
5use anyhow::{anyhow, Context, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::fs::{self, File, OpenOptions, TryLockError};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use tokio::sync::Mutex;
12use tokio_postgres::Client;
13
14/// on-disk state schema.
15#[derive(Debug, Default, Clone, Serialize, Deserialize)]
16pub struct StateData {
17    #[serde(default)]
18    pub mappings: BTreeMap<TypeName, BTreeMap<Uid, BackendId>>,
19}
20
21/// TLS configuration for postgres state backend connections.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PostgresTlsMode {
24    Disable,
25    Require,
26}
27
28/// trait for pluggable state backends.
29#[async_trait::async_trait]
30pub trait StateBackend: Send + Sync + std::fmt::Debug {
31    async fn load(&mut self) -> Result<StateData>;
32    async fn save(&mut self, data: &StateData) -> Result<()>;
33}
34
35/// state store wrapper with load/save helpers.
36#[derive(Debug, Clone)]
37pub struct StateStore {
38    backend: Option<Arc<Mutex<dyn StateBackend>>>,
39    data: StateData,
40    journal_dir: Option<PathBuf>,
41    // advisory exclusive lock held for a local-file store's whole lifetime, so two
42    // concurrent runs against the same state file cannot both load-modify-save it
43    // and clobber each other's mappings (the save is atomic but last-writer-wins).
44    // released when the last clone of the store drops. `None` for the postgres
45    // backend (it has its own optimistic lock) and for in-memory stores.
46    lock: Option<Arc<File>>,
47}
48
49impl StateStore {
50    /// create a new state store with an optional backend.
51    pub fn new(backend: Option<Arc<Mutex<dyn StateBackend>>>, data: StateData) -> Self {
52        Self {
53            backend,
54            data,
55            journal_dir: None,
56            lock: None,
57        }
58    }
59
60    /// set the directory used to persist apply journals for resumable applies.
61    pub fn with_journal_dir(mut self, dir: PathBuf) -> Self {
62        self.journal_dir = Some(dir);
63        self
64    }
65
66    /// directory used to persist apply journals, if configured.
67    pub fn journal_dir(&self) -> Option<&Path> {
68        self.journal_dir.as_deref()
69    }
70
71    /// load state from a file path.
72    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
73        let path = path.as_ref().to_path_buf();
74        // take the state lock before reading, and hold it for this store's whole
75        // lifetime, so a concurrent run can't load the same snapshot and race us to
76        // save. fail fast rather than block if another run already holds it.
77        let lock = acquire_state_lock(&path)?;
78        // always create a backend so we can save to the same path later.
79        // the backend's load() method handles missing files gracefully.
80        let backend: Option<Arc<Mutex<dyn StateBackend>>> =
81            Some(Arc::new(Mutex::new(LocalBackend { path: path.clone() }))
82                as Arc<Mutex<dyn StateBackend>>);
83        let data = if path.exists() {
84            let raw = fs::read_to_string(&path)
85                .with_context(|| format!("read state: {}", path.display()))?;
86            serde_json::from_str::<StateData>(&raw)
87                .with_context(|| format!("parse state: {}", path.display()))?
88        } else {
89            StateData::default()
90        };
91        let mut store = Self::new(backend, data);
92        store.lock = Some(lock);
93        Ok(store)
94    }
95
96    /// load state from a postgres backend.
97    pub async fn load_postgres(
98        url: impl Into<String>,
99        key: impl Into<String>,
100        tls_mode: PostgresTlsMode,
101    ) -> Result<Self> {
102        let mut postgres_backend = PostgresBackend {
103            url: url.into(),
104            key: key.into(),
105            tls_mode,
106            loaded_version: None,
107            table_ensured: false,
108        };
109        let data = postgres_backend.load().await?;
110        let backend: Arc<Mutex<dyn StateBackend>> = Arc::new(Mutex::new(postgres_backend));
111        Ok(Self::new(Some(backend), data))
112    }
113
114    /// load state from the configured backend.
115    pub async fn load_async(&mut self) -> Result<()> {
116        if let Some(backend) = &self.backend {
117            self.data = backend.lock().await.load().await?;
118        }
119        Ok(())
120    }
121
122    /// persist state to the configured backend.
123    pub async fn save_async(&self) -> Result<()> {
124        if let Some(backend) = &self.backend {
125            backend.lock().await.save(&self.data).await?;
126        }
127        Ok(())
128    }
129
130    /// lookup a backend id by type + uid.
131    pub fn backend_id(&self, type_name: TypeName, uid: Uid) -> Option<BackendId> {
132        self.data
133            .mappings
134            .get(&type_name)
135            .and_then(|map| map.get(&uid).cloned())
136    }
137
138    /// set a backend id mapping.
139    pub fn set_backend_id(&mut self, type_name: TypeName, uid: Uid, backend_id: BackendId) {
140        self.data
141            .mappings
142            .entry(type_name)
143            .or_default()
144            .insert(uid, backend_id);
145    }
146
147    /// remove a backend id mapping.
148    pub fn remove_backend_id(&mut self, type_name: TypeName, uid: Uid) {
149        if let Some(type_map) = self.data.mappings.get_mut(&type_name) {
150            type_map.remove(&uid);
151        }
152    }
153
154    /// return all mappings for external use.
155    pub fn all_mappings(&self) -> &BTreeMap<TypeName, BTreeMap<Uid, BackendId>> {
156        &self.data.mappings
157    }
158}
159
160/// take an exclusive advisory lock for a local state file. the lock lives in a
161/// sidecar `<path>.lock` file (created if missing) and is released when the
162/// returned handle, and every clone of the owning store, is dropped or the
163/// process exits. errors if another run already holds it.
164fn acquire_state_lock(path: &Path) -> Result<Arc<File>> {
165    if let Some(parent) = path.parent() {
166        if !parent.as_os_str().is_empty() {
167            fs::create_dir_all(parent)
168                .with_context(|| format!("create state dir: {}", parent.display()))?;
169        }
170    }
171    let lock_path = {
172        let mut p = path.as_os_str().to_owned();
173        p.push(".lock");
174        PathBuf::from(p)
175    };
176    let file = OpenOptions::new()
177        .create(true)
178        .write(true)
179        .truncate(false)
180        .open(&lock_path)
181        .with_context(|| format!("open state lock: {}", lock_path.display()))?;
182    match file.try_lock() {
183        Ok(()) => Ok(Arc::new(file)),
184        Err(TryLockError::WouldBlock) => Err(anyhow!(
185            "another alembic run holds the state lock at {}; wait for it to finish",
186            lock_path.display()
187        )),
188        Err(TryLockError::Error(err)) => {
189            Err(err).with_context(|| format!("acquire state lock: {}", lock_path.display()))
190        }
191    }
192}
193
194impl From<&StateStore> for StateData {
195    fn from(store: &StateStore) -> Self {
196        Self {
197            mappings: store.all_mappings().clone(),
198        }
199    }
200}
201
202#[derive(Debug)]
203struct LocalBackend {
204    path: PathBuf,
205}
206
207#[async_trait::async_trait]
208impl StateBackend for LocalBackend {
209    async fn load(&mut self) -> Result<StateData> {
210        if self.path.exists() {
211            let raw = fs::read_to_string(&self.path)
212                .with_context(|| format!("read state: {}", self.path.display()))?;
213            let data = serde_json::from_str::<StateData>(&raw)
214                .with_context(|| format!("parse state: {}", self.path.display()))?;
215            Ok(data)
216        } else {
217            Ok(StateData::default())
218        }
219    }
220
221    async fn save(&mut self, data: &StateData) -> Result<()> {
222        if let Some(parent) = self.path.parent() {
223            fs::create_dir_all(parent)
224                .with_context(|| format!("create state dir: {}", parent.display()))?;
225        }
226        let raw = serde_json::to_string_pretty(data)?;
227        let tmp = self.path.with_extension("json.tmp");
228        fs::write(&tmp, &raw).with_context(|| format!("write state tmp: {}", tmp.display()))?;
229        fs::rename(&tmp, &self.path)
230            .with_context(|| format!("write state: {}", self.path.display()))?;
231        Ok(())
232    }
233}
234
235#[derive(Debug)]
236struct PostgresBackend {
237    url: String,
238    key: String,
239    tls_mode: PostgresTlsMode,
240    loaded_version: Option<i32>,
241    table_ensured: bool,
242}
243
244#[async_trait::async_trait]
245impl StateBackend for PostgresBackend {
246    async fn load(&mut self) -> Result<StateData> {
247        let client = self.connect().await?;
248
249        let row = client
250            .query_opt(
251                "SELECT payload::text, loaded_version FROM alembic_state WHERE state_key = $1",
252                &[&self.key],
253            )
254            .await
255            .with_context(|| "load postgres state payload")?;
256
257        let Some(row) = row else {
258            self.loaded_version = Some(0);
259            return Ok(StateData::default());
260        };
261
262        self.loaded_version = Some(row.try_get::<_, i32>("loaded_version")?);
263
264        let raw: String = row
265            .try_get(0)
266            .with_context(|| "decode postgres state payload")?;
267        serde_json::from_str::<StateData>(&raw).with_context(|| "parse postgres state payload")
268    }
269
270    async fn save(&mut self, data: &StateData) -> Result<()> {
271        let client = self.connect().await?;
272        let Some(loaded_version) = self.loaded_version else {
273            return Err(anyhow!("must load state before saving"));
274        };
275
276        let payload = serde_json::to_string(data)?;
277        let rows_modified = client
278            .execute(
279                "INSERT INTO alembic_state (state_key, payload, updated_at)
280                 VALUES ($1, CAST($2 AS TEXT)::jsonb, NOW())
281                 ON CONFLICT (state_key)
282                 DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW(), loaded_version = $3+1
283                 WHERE alembic_state.loaded_version = $3",
284                &[&self.key, &payload, &loaded_version],
285            )
286            .await
287            .with_context(|| "save postgres state payload")?;
288
289        if rows_modified == 0 {
290            return Err(anyhow!(
291                "failed to save postgres state for key '{}': optimistic lock failed (expected loaded_version={})",
292                self.key,
293                loaded_version
294            ));
295        }
296
297        self.loaded_version = Some(loaded_version + 1);
298
299        Ok(())
300    }
301}
302
303impl PostgresBackend {
304    async fn ensure_table(&mut self, client: &Client) -> Result<()> {
305        if self.table_ensured {
306            return Ok(());
307        }
308
309        client
310            .execute(
311                "CREATE TABLE IF NOT EXISTS alembic_state (\
312                state_key TEXT PRIMARY KEY, \
313                payload JSONB NOT NULL, \
314                updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
315                loaded_version INTEGER NOT NULL DEFAULT 1\
316                )",
317                &[],
318            )
319            .await
320            .with_context(|| "ensure postgres alembic_state table exists")?;
321
322        // if the table already existed, CREATE TABLE IF NOT EXISTS won't add new columns.
323        client
324            .execute(
325                "ALTER TABLE alembic_state ADD COLUMN IF NOT EXISTS loaded_version INTEGER NOT NULL DEFAULT 1",
326                &[],
327            )
328            .await
329            .with_context(|| "ensure postgres alembic_state.loaded_version column exists")?;
330
331        self.table_ensured = true;
332        Ok(())
333    }
334
335    async fn connect(&mut self) -> Result<tokio_postgres::Client> {
336        match self.tls_mode {
337            PostgresTlsMode::Disable => {
338                let (client, connection) =
339                    tokio_postgres::connect(&self.url, tokio_postgres::NoTls)
340                        .await
341                        .with_context(|| "connect postgres state backend")?;
342                tokio::spawn(async move {
343                    if let Err(err) = connection.await {
344                        tracing::warn!("postgres state backend connection error: {err}");
345                    }
346                });
347                self.ensure_table(&client).await?;
348                Ok(client)
349            }
350            PostgresTlsMode::Require => {
351                let connector = native_tls::TlsConnector::builder()
352                    .build()
353                    .with_context(|| "build postgres TLS connector")?;
354                let connector = postgres_native_tls::MakeTlsConnector::new(connector);
355                let (client, connection) = tokio_postgres::connect(&self.url, connector)
356                    .await
357                    .with_context(|| "connect postgres state backend")?;
358                tokio::spawn(async move {
359                    if let Err(err) = connection.await {
360                        tracing::warn!("postgres state backend connection error: {err}");
361                    }
362                });
363                self.ensure_table(&client).await?;
364                Ok(client)
365            }
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use tempfile::TempDir;
374
375    fn t(s: &str) -> TypeName {
376        TypeName::new(s)
377    }
378
379    fn uid(n: u128) -> Uid {
380        Uid::from_u128(n)
381    }
382
383    #[test]
384    fn backend_id_returns_none_for_missing_type() {
385        let store = StateStore::new(None, StateData::default());
386        assert_eq!(store.backend_id(t("site"), uid(1)), None);
387    }
388
389    #[test]
390    fn backend_id_returns_none_for_missing_uid() {
391        let mut data = StateData::default();
392        data.mappings
393            .entry(t("site"))
394            .or_default()
395            .insert(uid(1), BackendId::Int(42));
396        let store = StateStore::new(None, data);
397        assert_eq!(store.backend_id(t("site"), uid(2)), None);
398    }
399
400    #[test]
401    fn backend_id_returns_value_for_existing_mapping() {
402        let mut data = StateData::default();
403        data.mappings
404            .entry(t("site"))
405            .or_default()
406            .insert(uid(1), BackendId::Int(42));
407        let store = StateStore::new(None, data);
408        assert_eq!(
409            store.backend_id(t("site"), uid(1)),
410            Some(BackendId::Int(42))
411        );
412    }
413
414    #[test]
415    fn set_backend_id_creates_mapping() {
416        let mut store = StateStore::new(None, StateData::default());
417        store.set_backend_id(t("site"), uid(1), BackendId::Int(42));
418        assert_eq!(
419            store.backend_id(t("site"), uid(1)),
420            Some(BackendId::Int(42))
421        );
422    }
423
424    #[test]
425    fn set_backend_id_overwrites_existing() {
426        let mut data = StateData::default();
427        data.mappings
428            .entry(t("site"))
429            .or_default()
430            .insert(uid(1), BackendId::Int(42));
431        let mut store = StateStore::new(None, data);
432        store.set_backend_id(t("site"), uid(1), BackendId::Int(99));
433        assert_eq!(
434            store.backend_id(t("site"), uid(1)),
435            Some(BackendId::Int(99))
436        );
437    }
438
439    #[test]
440    fn remove_backend_id_removes_mapping() {
441        let mut data = StateData::default();
442        data.mappings
443            .entry(t("site"))
444            .or_default()
445            .insert(uid(1), BackendId::Int(42));
446        let mut store = StateStore::new(None, data);
447        store.remove_backend_id(t("site"), uid(1));
448        assert_eq!(store.backend_id(t("site"), uid(1)), None);
449    }
450
451    #[test]
452    fn remove_backend_id_noop_for_missing() {
453        let mut store = StateStore::new(None, StateData::default());
454        store.remove_backend_id(t("site"), uid(1));
455        // should not panic
456    }
457
458    #[test]
459    fn journal_dir_defaults_to_none_and_round_trips() {
460        let store = StateStore::new(None, StateData::default());
461        assert_eq!(store.journal_dir(), None);
462
463        let dir = PathBuf::from("/tmp/alembic-journal");
464        let store = store.with_journal_dir(dir.clone());
465        assert_eq!(store.journal_dir(), Some(dir.as_path()));
466    }
467
468    #[tokio::test]
469    async fn local_backend_load_missing_returns_empty() {
470        let dir = TempDir::new().unwrap();
471        let mut backend = LocalBackend {
472            path: dir.path().join("nope.json"),
473        };
474        let data = backend.load().await.unwrap();
475        assert!(data.mappings.is_empty());
476    }
477
478    #[tokio::test]
479    async fn local_backend_save_load_round_trip() {
480        let dir = TempDir::new().unwrap();
481        let path = dir.path().join("sub").join("state.json");
482        let mut backend = LocalBackend { path: path.clone() };
483
484        let mut data = StateData::default();
485        data.mappings
486            .entry(t("site"))
487            .or_default()
488            .insert(uid(10), BackendId::String("site-001".into()));
489
490        backend.save(&data).await.unwrap();
491        assert!(path.exists());
492
493        let loaded = backend.load().await.unwrap();
494        assert_eq!(
495            loaded.mappings[&t("site")][&uid(10)],
496            BackendId::String("site-001".into())
497        );
498    }
499
500    #[tokio::test]
501    async fn store_save_without_backend_is_noop() {
502        let store = StateStore::new(None, StateData::default());
503        store.save_async().await.unwrap();
504    }
505
506    #[tokio::test]
507    async fn store_load_async_without_backend_is_noop() {
508        let mut store = StateStore::new(None, StateData::default());
509        store.set_backend_id(t("x"), uid(1), BackendId::Int(1));
510        store.load_async().await.unwrap();
511        assert_eq!(store.backend_id(t("x"), uid(1)), Some(BackendId::Int(1)));
512    }
513}