//! Toasty models and transactional libSQL snapshots for Console control data. //! //! Component bytes and WIT packages remain filesystem artifacts. This module //! persists service manifests, deployments, metrics, the bounded event //! history, and service-scoped Host KV entries. //! //! There are two write paths: //! //! - [`Persistence::sync`] stores a complete control-plane snapshot. //! - [`Persistence::record_invocations`] applies ordered metric deltas. //! //! Callers serialize these paths with the Console persistence mutex. Existing //! service counters are intentionally owned by the delta path; a snapshot //! updates their manifest and timestamp without replacing those counters. use std::{ collections::BTreeSet, path::Path, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; use toasty::Db; use toasty_driver_turso::Turso; use tokio::sync::Mutex; #[derive(Clone, Debug, toasty::Model)] pub(crate) struct StoredService { #[key] pub service_key: String, pub manifest_toml: String, pub updated_at_ms: u64, pub calls: u64, pub errors: u64, pub last_latency_ms: Option, } #[derive(Clone, Debug, toasty::Model)] pub(crate) struct StoredEvent { #[key] pub id: u64, pub timestamp_ms: u64, pub kind: String, pub service_id: Option, pub revision: Option, pub message: String, } #[derive(Clone, Debug, toasty::Model)] pub(crate) struct StoredDeployment { #[key] pub service_id: String, pub active_revision: String, pub updated_at_ms: u64, } #[derive(Clone, Debug, toasty::Model)] #[key(service_id, entry_key)] pub(crate) struct StoredKvEntry { pub service_id: String, pub entry_key: String, pub value: Vec, pub updated_at_ms: u64, } /// One invocation's durable metric delta and event. /// /// The caller assigns the event ID before enqueueing the update. A missing /// service row is tolerated because unregister may race queued telemetry; the /// event is still retained for diagnostics. pub(crate) struct InvocationUpdate { pub service_key: String, pub failed: bool, pub updated_at_ms: u64, pub latency_ms: u64, pub event: StoredEvent, } /// Serialized access to the Toasty database connection. /// /// This mutex protects a single Toasty [`Db`] handle. It is separate from the /// higher-level Console persistence mutex, which coordinates whole snapshot /// and invocation-delta operations before they acquire this connection. #[derive(Clone)] pub(crate) struct Persistence { db: Arc>, } impl Persistence { /// Opens the database and installs or advances its schema. pub async fn open(path: &Path) -> toasty::Result { let is_new_database = !path.exists(); let db = Db::builder() .models(toasty::models!( StoredService, StoredEvent, StoredDeployment, StoredKvEntry )) .build(Turso::file(path)) .await?; if is_new_database { db.push_schema().await?; } else { // This project predates Toasty's migration history. Keep the // additive bootstrap explicit so existing databases can be opened // without recreating the service and event tables. let mut db = db; toasty::sql::statement( r#"CREATE TABLE IF NOT EXISTS "stored_deployments" ( "service_id" TEXT NOT NULL, "active_revision" TEXT NOT NULL, "updated_at_ms" INTEGER NOT NULL, PRIMARY KEY ("service_id") )"#, ) .exec(&mut db) .await?; toasty::sql::statement( r#"CREATE TABLE IF NOT EXISTS "stored_kv_entries" ( "service_id" TEXT NOT NULL, "entry_key" TEXT NOT NULL, "value" BLOB NOT NULL, "updated_at_ms" INTEGER NOT NULL, PRIMARY KEY ("service_id", "entry_key") )"#, ) .exec(&mut db) .await?; return Ok(Self { db: Arc::new(Mutex::new(db)), }); } Ok(Self { db: Arc::new(Mutex::new(db)), }) } /// Loads the complete service and deployment sets plus retained events. pub async fn load( &self, ) -> toasty::Result<(Vec, Vec, Vec)> { let mut db = self.db.lock().await; let services = StoredService::all().exec(&mut *db).await?; let events = StoredEvent::all().exec(&mut *db).await?; let deployments = StoredDeployment::all().exec(&mut *db).await?; Ok((services, events, deployments)) } /// Atomically upserts a Console snapshot and prunes expired events. /// /// The input is authoritative for services and deployments: rows omitted /// from those collections are removed. Events older than the oldest /// supplied retained event are pruned. Existing invocation counters are /// preserved because [`Self::record_invocations`] owns counter changes. pub async fn sync( &self, services: Vec, events: Vec, deployments: Vec, ) -> toasty::Result<()> { let mut db = self.db.lock().await; let mut tx = db.transaction().await?; let stored_services = StoredService::all().exec(&mut tx).await?; let service_keys = services .iter() .map(|service| service.service_key.clone()) .collect::>(); let existing_service_keys = stored_services .iter() .map(|service| service.service_key.clone()) .collect::>(); for stored in stored_services { if !service_keys.contains(&stored.service_key) { StoredService::delete_by_service_key(&mut tx, &stored.service_key).await?; } } for service in services { if existing_service_keys.contains(&service.service_key) { // Invocation metrics are written as ordered deltas by // `record_invocations`; control snapshots must not overwrite // or double-count those concurrent updates. StoredService::filter_by_service_key(&service.service_key) .update() .manifest_toml(service.manifest_toml) .updated_at_ms(service.updated_at_ms) .exec(&mut tx) .await?; } else { StoredService::create() .service_key(service.service_key) .manifest_toml(service.manifest_toml) .updated_at_ms(service.updated_at_ms) .calls(0) .errors(0) .last_latency_ms(None) .exec(&mut tx) .await?; } } // Console stores events newest-first, so the last element is the // oldest ID that should survive snapshot pruning. if let Some(oldest_event) = events.last() { StoredEvent::filter(StoredEvent::fields().id().lt(oldest_event.id)) .delete() .exec(&mut tx) .await?; } for event in events { StoredEvent::upsert_by_id(event.id) .timestamp_ms(event.timestamp_ms) .kind(event.kind) .service_id(event.service_id) .revision(event.revision) .message(event.message) .exec(&mut tx) .await?; } let deployment_ids = deployments .iter() .map(|deployment| deployment.service_id.clone()) .collect::>(); for stored in StoredDeployment::all().exec(&mut tx).await? { if !deployment_ids.contains(&stored.service_id) { StoredDeployment::delete_by_service_id(&mut tx, &stored.service_id).await?; } } for deployment in deployments { StoredDeployment::upsert_by_service_id(deployment.service_id) .active_revision(deployment.active_revision) .updated_at_ms(deployment.updated_at_ms) .exec(&mut tx) .await?; } tx.commit().await } /// Atomically records invocation deltas and their bounded event history. /// /// All supplied updates commit together. Metrics use saturating arithmetic /// so corrupted or extremely old databases cannot wrap counters. pub(crate) async fn record_invocations( &self, updates: Vec, ) -> toasty::Result<()> { let mut db = self.db.lock().await; let mut tx = db.transaction().await?; let mut newest_event_id = 0; for update in updates { if let Some(stored) = StoredService::filter_by_service_key(&update.service_key) .first() .exec(&mut tx) .await? { StoredService::filter_by_service_key(&update.service_key) .update() .calls(stored.calls.saturating_add(1)) .errors(stored.errors.saturating_add(u64::from(update.failed))) .updated_at_ms(update.updated_at_ms) .last_latency_ms(Some(update.latency_ms)) .exec(&mut tx) .await?; } newest_event_id = newest_event_id.max(update.event.id); StoredEvent::upsert_by_id(update.event.id) .timestamp_ms(update.event.timestamp_ms) .kind(update.event.kind) .service_id(update.event.service_id) .revision(update.event.revision) .message(update.event.message) .exec(&mut tx) .await?; } let oldest_retained_event_id = newest_event_id.saturating_sub(255); if oldest_retained_event_id > 0 { StoredEvent::filter(StoredEvent::fields().id().lt(oldest_retained_event_id)) .delete() .exec(&mut tx) .await?; } tx.commit().await } pub(crate) async fn kv_get( &self, service_id: &str, entry_key: &str, ) -> toasty::Result>> { // The namespace is the stable service ID rather than its revision. // Deploying a new revision therefore preserves application state. let mut db = self.db.lock().await; Ok( StoredKvEntry::filter_by_service_id_and_entry_key(service_id, entry_key) .first() .exec(&mut *db) .await? .map(|entry| entry.value), ) } pub(crate) async fn kv_set( &self, service_id: String, entry_key: String, value: Vec, ) -> toasty::Result<()> { // Upsert makes a retry after an ambiguous Host timeout idempotent for // the same `(service_id, entry_key, value)`. let mut db = self.db.lock().await; StoredKvEntry::upsert_by_service_id_and_entry_key(service_id, entry_key) .value(value) .updated_at_ms(unix_time_ms()) .exec(&mut *db) .await?; Ok(()) } pub(crate) async fn kv_delete(&self, service_id: &str, entry_key: &str) -> toasty::Result<()> { let mut db = self.db.lock().await; StoredKvEntry::delete_by_service_id_and_entry_key(&mut *db, service_id, entry_key).await } } fn unix_time_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)) .unwrap_or_default() }