relay-knowledge 1.1.10

Graph-database-based knowledge graph project.
Documentation
use std::{
    path::PathBuf,
    sync::{Arc, OnceLock},
};

use crate::{
    application::RuntimeConfiguration,
    paths::RuntimePaths,
    storage::{
        KnowledgeStore, PartitionedSqliteKnowledgeStore, SqliteGraphStore, StorageError,
        StorageTopology, StorageTopologySnapshot,
    },
};

#[derive(Clone)]
pub(in crate::application) struct StorageProvider {
    config: Option<StorageProviderConfig>,
    ready: Arc<OnceLock<Arc<dyn KnowledgeStore>>>,
    init_lock: Arc<tokio::sync::Mutex<()>>,
}

impl StorageProvider {
    pub(super) fn configured(runtime: &RuntimeConfiguration) -> Self {
        Self {
            config: Some(StorageProviderConfig {
                database_path: runtime.paths.database_file(),
                paths: runtime.paths.clone(),
                topology: runtime.storage.topology,
            }),
            ready: Arc::new(OnceLock::new()),
            init_lock: Arc::new(tokio::sync::Mutex::new(())),
        }
    }

    pub(super) fn ready(store: Arc<dyn KnowledgeStore>) -> Self {
        let ready = OnceLock::new();
        let _ = ready.set(store);

        Self {
            config: None,
            ready: Arc::new(ready),
            init_lock: Arc::new(tokio::sync::Mutex::new(())),
        }
    }

    pub(in crate::application) async fn get(
        &self,
    ) -> Result<Arc<dyn KnowledgeStore>, StorageError> {
        if let Some(store) = self.ready.get() {
            return Ok(Arc::clone(store));
        }
        let _guard = self.init_lock.lock().await;
        if let Some(store) = self.ready.get() {
            return Ok(Arc::clone(store));
        }

        let Some(config) = self.config.clone() else {
            return Err(StorageError::InvalidInput(
                "storage provider was not initialized".to_owned(),
            ));
        };
        let ready = Arc::clone(&self.ready);
        tokio::task::spawn_blocking(move || {
            if let Some(store) = ready.get() {
                return Ok(Arc::clone(store));
            }
            let store = open_store(config)?;
            let _ = ready.set(Arc::clone(&store));
            Ok(store)
        })
        .await?
    }

    pub(in crate::application) fn ready_store(&self) -> Option<Arc<dyn KnowledgeStore>> {
        self.ready.get().map(Arc::clone)
    }

    pub(in crate::application) async fn topology_snapshot(
        &self,
    ) -> Result<StorageTopologySnapshot, StorageError> {
        let Some(config) = self.config.clone() else {
            return Ok(StorageTopologySnapshot::default());
        };
        match config.topology {
            StorageTopology::SingleSqlite | StorageTopology::PartitionedSqlite => {
                tokio::task::spawn_blocking(move || {
                    PartitionedSqliteKnowledgeStore::topology_snapshot_from_catalog(
                        config.database_path,
                        &config.paths,
                    )
                })
                .await?
            }
        }
    }
}

#[derive(Clone)]
struct StorageProviderConfig {
    database_path: PathBuf,
    paths: RuntimePaths,
    topology: StorageTopology,
}

fn open_store(config: StorageProviderConfig) -> Result<Arc<dyn KnowledgeStore>, StorageError> {
    match config.topology {
        StorageTopology::SingleSqlite => {
            if PartitionedSqliteKnowledgeStore::has_active_catalog(&config.database_path)? {
                return Err(StorageError::InvalidInput(
                    "single_sqlite cannot open a database with active partitioned_sqlite shards; set RELAY_KNOWLEDGE_STORAGE_TOPOLOGY=partitioned_sqlite or migrate the shard catalog before rollback".to_owned(),
                ));
            }
            Ok(Arc::new(SqliteGraphStore::open(config.database_path)?) as Arc<dyn KnowledgeStore>)
        }
        StorageTopology::PartitionedSqlite => Ok(Arc::new(PartitionedSqliteKnowledgeStore::open(
            config.database_path,
            config.paths,
        )?) as Arc<dyn KnowledgeStore>),
    }
}

#[cfg(test)]
mod tests {
    use std::{
        fs,
        path::PathBuf,
        time::{SystemTime, UNIX_EPOCH},
    };

    use crate::{
        domain::CodeRepositoryRegistration,
        env::{EnvironmentConfig, PlatformKind},
        paths::RuntimePaths,
        storage::{CodeRepositoryStore, StorageTopology},
    };

    use super::*;

    #[tokio::test]
    async fn single_sqlite_rejects_active_partitioned_catalog() {
        let paths = runtime_paths();
        let database_path = paths.database_file();
        let partitioned =
            PartitionedSqliteKnowledgeStore::open(&database_path, paths.clone()).expect("open");
        partitioned
            .upsert_code_repository(
                CodeRepositoryRegistration::new(
                    "repo-alpha",
                    "alpha",
                    "/tmp/alpha",
                    Vec::new(),
                    Vec::new(),
                )
                .expect("registration"),
            )
            .await
            .expect("partitioned registration activates catalog");

        let error = match open_store(StorageProviderConfig {
            database_path,
            paths,
            topology: StorageTopology::SingleSqlite,
        }) {
            Ok(_) => panic!("single topology should reject active shard catalog"),
            Err(error) => error,
        };

        assert!(error.to_string().contains("partitioned_sqlite"));
        assert!(error.to_string().contains("single_sqlite"));
    }

    fn runtime_paths() -> RuntimePaths {
        let root = unique_temp_dir("storage-provider");
        let environment = EnvironmentConfig::from_pairs(
            PlatformKind::current(),
            [(
                "RELAY_KNOWLEDGE_HOME",
                root.to_str().expect("temp path should be UTF-8"),
            )],
        )
        .expect("environment should parse");

        RuntimePaths::resolve(&environment.platform, &environment.paths).expect("paths resolve")
    }

    fn unique_temp_dir(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("clock should be after epoch")
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "relay-knowledge-{name}-{}-{nanos}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&path);
        path
    }
}