use std::sync::Arc;
use std::time::Duration;
use assay_domain::events::EngineEventBus;
use tracing::info;
use crate::config::{BackendConfig, EngineConfig};
#[derive(Debug, Clone)]
pub struct BuiltinModule {
pub name: &'static str,
pub version: &'static str,
pub default_enabled: bool,
}
pub fn builtin_modules() -> Vec<BuiltinModule> {
#[cfg_attr(not(feature = "vault"), allow(unused_mut))]
let mut mods = vec![
BuiltinModule {
name: "workflow",
version: env!("CARGO_PKG_VERSION"),
default_enabled: true,
},
BuiltinModule {
name: "auth",
version: env!("CARGO_PKG_VERSION"),
default_enabled: true,
},
];
#[cfg(feature = "vault")]
mods.push(BuiltinModule {
name: "vault",
version: env!("CARGO_PKG_VERSION"),
default_enabled: true,
});
mods
}
const INSTANCE_HEARTBEAT_SECS: u64 = 3;
#[cfg(feature = "backend-postgres")]
const INSTANCE_STALE_SECS: f64 = 10.0;
pub enum EngineBoot {
#[cfg(feature = "backend-postgres")]
Postgres(PgBoot),
#[cfg(feature = "backend-sqlite")]
Sqlite(SqliteBoot),
}
#[cfg(feature = "backend-postgres")]
pub struct PgBoot {
pub pool: sqlx::PgPool,
pub bus: Arc<dyn EngineEventBus>,
pub instance_id: uuid::Uuid,
pub modules: Vec<String>,
}
#[cfg(feature = "backend-sqlite")]
pub struct SqliteBoot {
pub pool: sqlx::SqlitePool,
pub bus: Arc<dyn EngineEventBus>,
pub instance_id: uuid::Uuid,
pub modules: Vec<String>,
}
impl EngineBoot {
pub async fn run(cfg: &EngineConfig) -> anyhow::Result<Self> {
match cfg.backend.clone() {
#[cfg(feature = "backend-postgres")]
BackendConfig::Postgres { url } => {
let boot = pg_boot(&url, &cfg.auto_enable_modules).await?;
Ok(EngineBoot::Postgres(boot))
}
#[cfg(feature = "backend-sqlite")]
BackendConfig::Sqlite { .. } => {
let data_dir = cfg
.backend
.sqlite_data_dir()
.expect("sqlite backend yields data_dir");
let boot = sqlite_boot(&data_dir, &cfg.auto_enable_modules).await?;
Ok(EngineBoot::Sqlite(boot))
}
#[allow(unreachable_patterns)]
_ => anyhow::bail!("backend not enabled at compile time"),
}
}
pub fn modules(&self) -> &[String] {
match self {
#[cfg(feature = "backend-postgres")]
EngineBoot::Postgres(b) => &b.modules,
#[cfg(feature = "backend-sqlite")]
EngineBoot::Sqlite(b) => &b.modules,
}
}
pub fn instance_id(&self) -> uuid::Uuid {
match self {
#[cfg(feature = "backend-postgres")]
EngineBoot::Postgres(b) => b.instance_id,
#[cfg(feature = "backend-sqlite")]
EngineBoot::Sqlite(b) => b.instance_id,
}
}
}
#[cfg(feature = "backend-postgres")]
async fn pg_boot(url: &str, auto_enable: &[String]) -> anyhow::Result<PgBoot> {
use assay_domain::engine::PgEngineSchema;
use assay_domain::events::PgEngineEventBus;
use sqlx::PgPool;
info!(target: "assay-engine", "boot: connecting to postgres");
let pool = PgPool::connect(url)
.await
.map_err(|e| anyhow::anyhow!("connect postgres: {e}"))?;
let schema = PgEngineSchema::new(pool.clone());
schema
.migrate()
.await
.map_err(|e| anyhow::anyhow!("engine schema migrate (pg): {e}"))?;
record_engine_migration_pg(&pool, "engine", 1).await?;
let modules = read_or_seed_modules_pg(&schema, auto_enable).await?;
for name in &modules {
let create = format!("CREATE SCHEMA IF NOT EXISTS {name}");
sqlx::query(&create)
.execute(&pool)
.await
.map_err(|e| anyhow::anyhow!("create schema {name}: {e}"))?;
record_engine_migration_pg(&pool, name, 1).await?;
}
if modules.iter().any(|m| m == "auth") {
assay_auth::schema::migrate_postgres(&pool)
.await
.map_err(|e| anyhow::anyhow!("auth schema migrate (pg): {e}"))?;
let _ = assay_auth::biscuit::load_or_init_postgres(&pool)
.await
.map_err(|e| anyhow::anyhow!("biscuit root key bootstrap (pg): {e}"))?;
sqlx::query("SELECT COUNT(*) FROM auth.oidc_clients")
.fetch_one(&pool)
.await
.map_err(|e| anyhow::anyhow!("oidc provider tables (pg): {e}"))?;
}
#[cfg(feature = "vault")]
if modules.iter().any(|m| m == "vault") {
assay_vault::schema::migrate_postgres(&pool)
.await
.map_err(|e| anyhow::anyhow!("vault schema migrate (pg): {e}"))?;
sqlx::query("SELECT COUNT(*) FROM vault.kv_meta")
.fetch_one(&pool)
.await
.map_err(|e| anyhow::anyhow!("vault tables (pg): {e}"))?;
}
let bus: Arc<dyn EngineEventBus> = Arc::new(
PgEngineEventBus::new(pool.clone(), url)
.await
.map_err(|e| anyhow::anyhow!("engine-events bus (pg): {e}"))?,
);
let instance_id = schema
.register_instance(&modules, Some(env!("CARGO_PKG_VERSION")))
.await
.map_err(|e| anyhow::anyhow!("register engine.instances row: {e}"))?;
spawn_pg_instance_lifecycle(pool.clone(), instance_id);
info!(target: "assay-engine", instance = %instance_id, modules = ?modules, "boot complete (pg)");
Ok(PgBoot {
pool,
bus,
instance_id,
modules,
})
}
#[cfg(feature = "backend-postgres")]
async fn read_or_seed_modules_pg(
schema: &assay_domain::engine::PgEngineSchema,
auto_enable: &[String],
) -> anyhow::Result<Vec<String>> {
let existing = schema
.list_modules()
.await
.map_err(|e| anyhow::anyhow!("list engine.modules (pg): {e}"))?;
let known: std::collections::HashSet<String> =
existing.iter().map(|m| m.name.clone()).collect();
for module in builtin_modules() {
if known.contains(module.name) {
continue;
}
let enabled = module.default_enabled || auto_enable.iter().any(|n| n == module.name);
schema
.upsert_module(module.name, Some(module.version), enabled)
.await
.map_err(|e| anyhow::anyhow!("seed engine.modules row {}: {e}", module.name))?;
}
let final_list = schema
.list_modules()
.await
.map_err(|e| anyhow::anyhow!("re-list engine.modules (pg): {e}"))?;
Ok(final_list
.into_iter()
.filter(|m| m.enabled)
.map(|m| m.name)
.collect())
}
#[cfg(feature = "backend-postgres")]
async fn record_engine_migration_pg(
pool: &sqlx::PgPool,
module: &str,
version: i32,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO engine.migrations (module, version)
VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(module)
.bind(version)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("record engine.migrations row {module}/{version}: {e}"))?;
Ok(())
}
#[cfg(feature = "backend-postgres")]
fn spawn_pg_instance_lifecycle(pool: sqlx::PgPool, id: uuid::Uuid) {
use assay_domain::engine::PgEngineSchema;
let schema = PgEngineSchema::new(pool.clone());
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(INSTANCE_HEARTBEAT_SECS));
loop {
tick.tick().await;
if let Err(e) = schema.heartbeat_instance(id).await {
tracing::warn!(?e, %id, "engine.instances heartbeat failed");
}
let cutoff_sql = format!(
"DELETE FROM engine.instances
WHERE last_heartbeat < EXTRACT(EPOCH FROM NOW()) - {INSTANCE_STALE_SECS}"
);
if let Err(e) = sqlx::query(&cutoff_sql).execute(&pool).await {
tracing::debug!(?e, "engine.instances stale cleanup failed");
}
}
});
}
#[cfg(feature = "backend-sqlite")]
async fn sqlite_boot(data_dir: &str, auto_enable: &[String]) -> anyhow::Result<SqliteBoot> {
use assay_domain::engine::SqliteEngineSchema;
use assay_domain::events::SqliteEngineEventBus;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
let in_memory = data_dir == ":memory:";
if !in_memory {
std::fs::create_dir_all(data_dir)
.map_err(|e| anyhow::anyhow!("create data_dir {data_dir}: {e}"))?;
}
let main_url = "sqlite::memory:";
let opts = SqliteConnectOptions::from_str(main_url)?.create_if_missing(true);
let engine_attach = sqlite_attach_uri(data_dir, "engine", in_memory);
let workflow_attach = sqlite_attach_uri(data_dir, "workflow", in_memory);
let auth_attach = sqlite_attach_uri(data_dir, "auth", in_memory);
#[cfg(feature = "vault")]
let vault_attach = sqlite_attach_uri(data_dir, "vault", in_memory);
info!(
target: "assay-engine",
data_dir = %data_dir,
engine = %engine_attach,
workflow = %workflow_attach,
"boot: opening sqlite engine pool"
);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.after_connect(move |conn, _meta| {
let engine_attach = engine_attach.clone();
let workflow_attach = workflow_attach.clone();
let auth_attach = auth_attach.clone();
#[cfg(feature = "vault")]
let vault_attach = vault_attach.clone();
Box::pin(async move {
use sqlx::Executor;
conn.execute(format!("ATTACH DATABASE '{engine_attach}' AS engine").as_str())
.await?;
conn.execute(format!("ATTACH DATABASE '{workflow_attach}' AS workflow").as_str())
.await?;
conn.execute(format!("ATTACH DATABASE '{auth_attach}' AS auth").as_str())
.await?;
#[cfg(feature = "vault")]
conn.execute(format!("ATTACH DATABASE '{vault_attach}' AS vault").as_str())
.await?;
Ok(())
})
})
.connect_with(opts)
.await
.map_err(|e| anyhow::anyhow!("connect sqlite: {e}"))?;
let schema = SqliteEngineSchema::new(pool.clone());
schema
.migrate()
.await
.map_err(|e| anyhow::anyhow!("engine schema migrate (sqlite): {e}"))?;
record_engine_migration_sqlite(&pool, "engine", 1).await?;
let modules = read_or_seed_modules_sqlite(&schema, auto_enable).await?;
for name in &modules {
record_engine_migration_sqlite(&pool, name, 1).await?;
}
if modules.iter().any(|m| m == "auth") {
assay_auth::schema::migrate_sqlite(&pool)
.await
.map_err(|e| anyhow::anyhow!("auth schema migrate (sqlite): {e}"))?;
let _ = assay_auth::biscuit::load_or_init_sqlite(&pool)
.await
.map_err(|e| anyhow::anyhow!("biscuit root key bootstrap (sqlite): {e}"))?;
sqlx::query("SELECT COUNT(*) FROM auth.oidc_clients")
.fetch_one(&pool)
.await
.map_err(|e| anyhow::anyhow!("oidc provider tables (sqlite): {e}"))?;
}
#[cfg(feature = "vault")]
if modules.iter().any(|m| m == "vault") {
assay_vault::schema::migrate_sqlite(&pool)
.await
.map_err(|e| anyhow::anyhow!("vault schema migrate (sqlite): {e}"))?;
sqlx::query("SELECT COUNT(*) FROM vault.kv_meta")
.fetch_one(&pool)
.await
.map_err(|e| anyhow::anyhow!("vault tables (sqlite): {e}"))?;
}
let bus: Arc<dyn EngineEventBus> = Arc::new(
SqliteEngineEventBus::new(pool.clone())
.await
.map_err(|e| anyhow::anyhow!("engine-events bus (sqlite): {e}"))?,
);
let instance_id = schema
.register_instance(&modules, Some(env!("CARGO_PKG_VERSION")))
.await
.map_err(|e| anyhow::anyhow!("register engine.instances row: {e}"))?;
spawn_sqlite_instance_lifecycle(pool.clone(), instance_id);
info!(target: "assay-engine", instance = %instance_id, modules = ?modules, "boot complete (sqlite)");
Ok(SqliteBoot {
pool,
bus,
instance_id,
modules,
})
}
#[cfg(feature = "backend-sqlite")]
fn sqlite_attach_uri(data_dir: &str, module: &str, in_memory: bool) -> String {
if in_memory {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let suffix = format!(
"{}_{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
);
format!("file:assay_{module}_{suffix}?mode=memory&cache=shared")
} else {
format!("file:{data_dir}/{module}.db?mode=rwc")
}
}
#[cfg(feature = "backend-sqlite")]
async fn read_or_seed_modules_sqlite(
schema: &assay_domain::engine::SqliteEngineSchema,
auto_enable: &[String],
) -> anyhow::Result<Vec<String>> {
let existing = schema
.list_modules()
.await
.map_err(|e| anyhow::anyhow!("list engine.modules (sqlite): {e}"))?;
let known: std::collections::HashSet<String> =
existing.iter().map(|m| m.name.clone()).collect();
for module in builtin_modules() {
if known.contains(module.name) {
continue;
}
let enabled = module.default_enabled || auto_enable.iter().any(|n| n == module.name);
schema
.upsert_module(module.name, Some(module.version), enabled)
.await
.map_err(|e| anyhow::anyhow!("seed engine.modules row {}: {e}", module.name))?;
}
let final_list = schema
.list_modules()
.await
.map_err(|e| anyhow::anyhow!("re-list engine.modules (sqlite): {e}"))?;
Ok(final_list
.into_iter()
.filter(|m| m.enabled)
.map(|m| m.name)
.collect())
}
#[cfg(feature = "backend-sqlite")]
async fn record_engine_migration_sqlite(
pool: &sqlx::SqlitePool,
module: &str,
version: i32,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT OR IGNORE INTO engine.migrations (module, version)
VALUES (?, ?)",
)
.bind(module)
.bind(version)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("record engine.migrations row {module}/{version}: {e}"))?;
Ok(())
}
#[cfg(feature = "backend-sqlite")]
fn spawn_sqlite_instance_lifecycle(pool: sqlx::SqlitePool, id: uuid::Uuid) {
use assay_domain::engine::SqliteEngineSchema;
let schema = SqliteEngineSchema::new(pool);
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(INSTANCE_HEARTBEAT_SECS));
loop {
tick.tick().await;
if let Err(e) = schema.heartbeat_instance(id).await {
tracing::warn!(?e, %id, "engine.instances heartbeat failed");
}
}
});
}
#[cfg(all(test, feature = "backend-sqlite"))]
mod tests {
use super::*;
#[tokio::test(flavor = "multi_thread")]
async fn sqlite_boot_default_runs_auth_migration() {
let boot = sqlite_boot(":memory:", &[]).await.expect("boot");
assert!(
boot.modules.iter().any(|m| m == "auth"),
"auth must be in active modules by default; got {:?}",
boot.modules
);
let auth_row: Option<(String,)> =
sqlx::query_as("SELECT module FROM engine.migrations WHERE module = 'auth'")
.fetch_optional(&boot.pool)
.await
.expect("query engine.migrations");
assert!(
auth_row.is_some(),
"engine.migrations should have an auth row after auto-enabled boot"
);
let user_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM auth.users")
.fetch_one(&boot.pool)
.await
.expect("count auth.users");
assert_eq!(user_count.0, 0);
}
}