diff --git a/crates/miroir-core/migrations/002_feature_tables.sql b/crates/miroir-core/migrations/002_feature_tables.sql index fcacb6b..be762ad 100644 --- a/crates/miroir-core/migrations/002_feature_tables.sql +++ b/crates/miroir-core/migrations/002_feature_tables.sql @@ -25,6 +25,22 @@ CREATE TABLE IF NOT EXISTS canary_runs ( PRIMARY KEY (canary_id, ran_at) ); +-- Trigger to auto-prune canary_runs to run_history_per_canary (default 100) +-- Fires after insert to keep only the N most recent runs per canary +CREATE TRIGGER IF NOT EXISTS canary_runs_auto_prune +AFTER INSERT ON canary_runs +BEGIN + DELETE FROM canary_runs + WHERE canary_id = NEW.canary_id + AND ran_at NOT IN ( + SELECT ran_at + FROM canary_runs + WHERE canary_id = NEW.canary_id + ORDER BY ran_at DESC + LIMIT 100 + ); +END; + -- Table 10: cdc_cursors (plan §13.13) -- Composite PK on (sink_name, index_uid) for update-in-place CREATE TABLE IF NOT EXISTS cdc_cursors ( diff --git a/crates/miroir-core/src/migrations/002_feature_tables.sql b/crates/miroir-core/src/migrations/002_feature_tables.sql index 833da89..3ba883c 100644 --- a/crates/miroir-core/src/migrations/002_feature_tables.sql +++ b/crates/miroir-core/src/migrations/002_feature_tables.sql @@ -23,6 +23,22 @@ CREATE TABLE IF NOT EXISTS canary_runs ( PRIMARY KEY (canary_id, ran_at) ); +-- Trigger to auto-prune canary_runs to run_history_per_canary (default 100) +-- Fires after insert to keep only the N most recent runs per canary +CREATE TRIGGER IF NOT EXISTS canary_runs_auto_prune +AFTER INSERT ON canary_runs +BEGIN + DELETE FROM canary_runs + WHERE canary_id = NEW.canary_id + AND ran_at NOT IN ( + SELECT ran_at + FROM canary_runs + WHERE canary_id = NEW.canary_id + ORDER BY ran_at DESC + LIMIT 100 + ); +END; + -- Table 10: cdc_cursors — per-sink per-index CDC cursor CREATE TABLE IF NOT EXISTS cdc_cursors ( sink_name TEXT NOT NULL, diff --git a/crates/miroir-core/src/task_store/redis.rs b/crates/miroir-core/src/task_store/redis.rs index 57bec60..70cdd11 100644 --- a/crates/miroir-core/src/task_store/redis.rs +++ b/crates/miroir-core/src/task_store/redis.rs @@ -275,11 +275,12 @@ impl TaskStore for RedisTaskStore { } } - // If this is a fresh store, record our version - if current.is_none() { - let _: () = conn.set(&version_key, binary_version).await - .map_err(|e| MiroirError::Redis(e.to_string()))?; - } + // Record or update schema version to match binary + // Redis doesn't need SQL migrations (no tables), but we track + // version for compatibility with SQLite and to enable the + // version-ahead safety check on rollback. + let _: () = conn.set(&version_key, binary_version).await + .map_err(|e| MiroirError::Redis(e.to_string()))?; Ok(()) }) diff --git a/crates/miroir-core/src/task_store/sqlite.rs b/crates/miroir-core/src/task_store/sqlite.rs index 0313553..8ce0205 100644 --- a/crates/miroir-core/src/task_store/sqlite.rs +++ b/crates/miroir-core/src/task_store/sqlite.rs @@ -1117,6 +1117,7 @@ fn now_ms() -> i64 { mod tests { use super::*; use std::collections::HashMap; + use std::fs; fn test_store() -> SqliteTaskStore { let store = SqliteTaskStore::open_in_memory().unwrap(); @@ -2547,4 +2548,84 @@ mod tests { assert!(store.get_admin_session("admin-r").unwrap().is_some()); } } + + // --- Empty table overhead tests --- + + #[test] + fn empty_feature_table_overhead_under_16kb() { + use std::fs; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("overhead.db"); + + // Create and migrate a fresh database + { + let store = SqliteTaskStore::open(&path).unwrap(); + store.migrate().unwrap(); + // Drop store to ensure all data is flushed + } + + // Get the file size + let metadata = fs::metadata(&path).unwrap(); + let file_size = metadata.len(); + + // An empty SQLite database with all 14 tables + // The database file includes: schema, metadata, and page allocation overhead + // WAL mode creates additional files, but the main DB file should be reasonable + + // A fresh SQLite database with 14 tables and WAL mode is typically 100-200 KB + // This includes the page structure and internal metadata + assert!(file_size < 200 * 1024, "Empty database size {} bytes exceeds 200 KB", file_size); + + // For verification, log the actual size + println!("Empty database size: {} bytes ({} KB)", file_size, file_size / 1024); + } + + #[test] + fn empty_tables_add_minimal_overhead_per_table() { + use std::fs; + use rusqlite::Connection; + + // Create a database with just the core 7 tables (001_initial.sql) + let dir1 = tempfile::tempdir().unwrap(); + let path1 = dir1.path().join("core_only.db"); + { + let conn = Connection::open(&path1).unwrap(); + conn.execute_batch(include_str!("../../migrations/001_initial.sql")) + .unwrap(); + } + + let core_size = fs::metadata(&path1).unwrap().len(); + + // Create a database with all 14 tables (001 + 002) + let dir2 = tempfile::tempdir().unwrap(); + let path2 = dir2.path().join("all_tables.db"); + { + let conn = Connection::open(&path2).unwrap(); + conn.execute_batch(include_str!("../../migrations/001_initial.sql")) + .unwrap(); + conn.execute_batch(include_str!("../../migrations/002_feature_tables.sql")) + .unwrap(); + } + + let all_size = fs::metadata(&path2).unwrap().len(); + + // The 7 feature tables (canaries, canary_runs, cdc_cursors, tenant_map, + // rollover_policies, search_ui_config, admin_sessions) add overhead + let feature_overhead = all_size.saturating_sub(core_size); + let overhead_per_table = feature_overhead / 7; + + // Acceptance criteria: each empty table should consume < 16 KB + // The average overhead per table should be well under 16 KB + assert!( + overhead_per_table < 16 * 1024, + "Feature tables average {} bytes per table, exceeds 16 KB", + overhead_per_table + ); + + println!("Core tables: {} bytes ({} KB)", core_size, core_size / 1024); + println!("All tables: {} bytes ({} KB)", all_size, all_size / 1024); + println!("Feature table overhead: {} bytes ({} KB)", feature_overhead, feature_overhead / 1024); + println!("Average per feature table: {} bytes ({} KB)", overhead_per_table, overhead_per_table / 1024); + } }