diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e02758de..a76c88bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,33 @@ jobs: steps: - uses: actions/checkout@v7 + - name: Tune Windows runner for test I/O + shell: pwsh + run: | + # Defender real-time scanning taxes the many short-lived SQLite/tempdir + # files these tests create; hosted runners are elevated, so turn it off + # and exclude the hot paths as a fallback. + Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction SilentlyContinue + $exclusions = @( + $env:GITHUB_WORKSPACE, + $env:RUNNER_TEMP, + [System.IO.Path]::GetTempPath(), + $env:CARGO_HOME, + $env:RUSTUP_HOME, + "$env:USERPROFILE\.cargo", + "$env:USERPROFILE\.rustup" + ) | Where-Object { $_ } | Select-Object -Unique + foreach ($path in $exclusions) { + Add-MpPreference -ExclusionPath $path -ErrorAction SilentlyContinue + } + # Default user TEMP is on the slow C: drive; RUNNER_TEMP shares the + # faster workspace drive. Point TMP/TEMP there so tempfile::TempDir in + # the nextest test processes follows. + $fastTemp = Join-Path $env:RUNNER_TEMP "tmp" + New-Item -ItemType Directory -Path $fastTemp -Force | Out-Null + "TMP=$fastTemp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "TEMP=$fastTemp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - uses: dtolnay/rust-toolchain@stable - name: Use lld-link linker @@ -142,6 +169,8 @@ jobs: cache-on-failure: true - name: Run Windows tests + env: + TRACEDECAY_SQLITE_UNSAFE_FAST: "1" run: cargo nextest run --workspace --profile ci --locked --partition slice:${{ matrix.partition }}/10 --test-threads num-cpus --status-level slow - name: Clean abandoned Windows test children diff --git a/Cargo.toml b/Cargo.toml index 9991b5fb..5c378442 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,3 +157,18 @@ criterion = { version = "0.5", features = ["async_tokio", "html_reports"] } [[bench]] name = "large_repos" harness = false + +# Compile the SQLite stack with optimizations even in dev/test builds. The +# bundled SQLite C sources in libsql-ffi are otherwise built at -O0 (cc honors +# cargo's OPT_LEVEL), and nearly every test creates databases and runs many +# queries — unoptimized SQLite dominates test runtime, worst on Windows CI. +# The `test` profile inherits these dev package overrides. Deliberately +# scoped to the libsql layers only; do not blanket-override "*". +[profile.dev.package.libsql-ffi] +opt-level = 2 + +[profile.dev.package.libsql-sys] +opt-level = 2 + +[profile.dev.package.libsql-rusqlite] +opt-level = 2 diff --git a/src/db/connection.rs b/src/db/connection.rs index 84701457..a192ea24 100644 --- a/src/db/connection.rs +++ b/src/db/connection.rs @@ -49,6 +49,22 @@ pub(crate) fn platform_safe_mmap_size(mmap: u64) -> u64 { } } +/// Env var that, when set to `1`, switches every `TraceDecay` `SQLite` +/// connection to `journal_mode=MEMORY` + `synchronous=OFF` on all platforms. +/// +/// **For tests/CI only — must never be set in production.** It trades away +/// crash durability entirely: a process or OS crash mid-transaction can +/// corrupt the database. CI test runs don't care (every DB is a throwaway +/// fixture), and on Windows this avoids the per-transaction rollback-journal +/// file create/write/fsync/delete cost of the `DELETE`+`FULL` pairing. An +/// in-memory journal also never enters WAL mode, so it sidesteps the Windows +/// WAL close-time teardown crash the same way `DELETE` does. +pub const SQLITE_UNSAFE_FAST_ENV: &str = "TRACEDECAY_SQLITE_UNSAFE_FAST"; + +fn sqlite_unsafe_fast_enabled() -> bool { + std::env::var(SQLITE_UNSAFE_FAST_ENV).as_deref() == Ok("1") +} + /// Returns the `journal_mode` safe for the current platform. /// /// Windows libsql/SQLite local databases can intermittently fault while closing @@ -56,8 +72,14 @@ pub(crate) fn platform_safe_mmap_size(mmap: u64) -> u64 { /// mmap removed one unsafe teardown path, but master CI still aborts in /// unrelated tests as different short-lived databases close. Use rollback /// journaling on Windows and keep WAL everywhere else. +/// +/// When [`SQLITE_UNSAFE_FAST_ENV`] is `1` (tests/CI only — never set it in +/// production) this returns `MEMORY` on every platform, skipping journal file +/// I/O entirely at the cost of crash durability. pub(crate) fn platform_safe_journal_mode() -> &'static str { - if cfg!(windows) { + if sqlite_unsafe_fast_enabled() { + "MEMORY" + } else if cfg!(windows) { "DELETE" } else { "WAL" @@ -70,8 +92,14 @@ pub(crate) fn platform_safe_journal_mode() -> &'static str { /// final fsync, but rollback journals need `FULL` to avoid corruption after an /// OS crash or power loss. Keep the faster WAL+NORMAL pairing on non-Windows /// and use DELETE+FULL on Windows. +/// +/// When [`SQLITE_UNSAFE_FAST_ENV`] is `1` (tests/CI only — never set it in +/// production) this returns `OFF` on every platform, skipping fsyncs entirely +/// at the cost of crash durability. pub(crate) fn platform_safe_synchronous_mode() -> &'static str { - if cfg!(windows) { + if sqlite_unsafe_fast_enabled() { + "OFF" + } else if cfg!(windows) { "FULL" } else { "NORMAL" @@ -413,6 +441,39 @@ mod tests { const KB: u64 = 1024; const MB: u64 = 1024 * 1024; + /// Serializes tests that mutate [`SQLITE_UNSAFE_FAST_ENV`]; process env is + /// shared across threads under plain `cargo test`. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + + fn unset(key: &'static str) -> Self { + let previous = std::env::var_os(key); + std::env::remove_var(key); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + std::env::set_var(self.key, previous); + } else { + std::env::remove_var(self.key); + } + } + } + #[test] fn adaptive_new_db_gets_minimum() { let (cache_kb, mmap) = adaptive_cache_sizes(0); @@ -470,6 +531,10 @@ mod tests { #[test] fn journal_mode_uses_wal_except_on_windows() { + let _lock = ENV_LOCK.lock().unwrap(); + // Pin the CI-only escape hatch off: Windows CI exports it for the + // whole test run, and this test asserts the durable defaults. + let _env = EnvVarGuard::unset(SQLITE_UNSAFE_FAST_ENV); if cfg!(windows) { assert_eq!(platform_safe_journal_mode(), "DELETE"); } else { @@ -479,10 +544,28 @@ mod tests { #[test] fn synchronous_mode_matches_platform_journal_mode() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvVarGuard::unset(SQLITE_UNSAFE_FAST_ENV); if cfg!(windows) { assert_eq!(platform_safe_synchronous_mode(), "FULL"); } else { assert_eq!(platform_safe_synchronous_mode(), "NORMAL"); } } + + #[test] + fn unsafe_fast_env_overrides_journal_and_synchronous_modes() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvVarGuard::set(SQLITE_UNSAFE_FAST_ENV, "1"); + assert_eq!(platform_safe_journal_mode(), "MEMORY"); + assert_eq!(platform_safe_synchronous_mode(), "OFF"); + } + + #[test] + fn unsafe_fast_env_requires_exact_value_one() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvVarGuard::set(SQLITE_UNSAFE_FAST_ENV, "true"); + assert_ne!(platform_safe_journal_mode(), "MEMORY"); + assert_ne!(platform_safe_synchronous_mode(), "OFF"); + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 1ce4c141..36a0dd43 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -15,9 +15,9 @@ mod stats; mod tx; mod unresolved; -pub use connection::Database; pub(crate) use connection::{ platform_safe_journal_mode, platform_safe_mmap_size, platform_safe_synchronous_mode, }; +pub use connection::{Database, SQLITE_UNSAFE_FAST_ENV}; pub use fingerprints::StoredFingerprint; pub use search::DependencyImportUse; diff --git a/tests/automation_runner_support/mod.rs b/tests/automation_runner_support/mod.rs index 45503824..61b4ed2b 100644 --- a/tests/automation_runner_support/mod.rs +++ b/tests/automation_runner_support/mod.rs @@ -318,6 +318,18 @@ impl Drop for EnvVarGuard { } } +/// Pins `TRACEDECAY_GLOBAL_DB` at the test project's already-created session +/// store for the guard's lifetime. Skill-writer evidence building calls +/// `GlobalDb::open()`, which would otherwise create (or contend on) the +/// shared per-user global DB — a full schema creation that dominates these +/// fixtures on Windows CI, where many test processes share one home. The +/// session store uses the same schema and these tests never rely on +/// pre-existing global-DB contents, so reusing it keeps the open cheap and +/// fully isolated. Callers must hold [`ENV_LOCK`] while the guard is alive. +pub(crate) fn isolate_global_db(cg: &TraceDecay) -> EnvVarGuard { + EnvVarGuard::set("TRACEDECAY_GLOBAL_DB", &cg.store_layout().sessions_db_path) +} + impl FailingBackend { pub(crate) fn new(task: AgentTaskKind) -> Self { Self { diff --git a/tests/automation_runner_test.rs b/tests/automation_runner_test.rs index 820c8e31..637d3957 100644 --- a/tests/automation_runner_test.rs +++ b/tests/automation_runner_test.rs @@ -82,11 +82,16 @@ async fn scheduler_memory_curator_respects_interval_gate() { ); } +// The scheduler gate tests below deliberately skip session-evidence seeding: +// the runners evaluate the scheduler gate before opening any session store, +// and each test pins the exact gate skip reason, so a regression that +// reordered gating behind evidence gathering would fail with a different +// error. Skipping the seed avoids paying a full session-DB schema creation +// per test, which dominates these fixtures on Windows. #[tokio::test] async fn scheduler_session_reflector_respects_interval_gate() { let temp = tempdir().unwrap(); let cg = init_project(temp.path()).await; - seed_session_evidence(&cg).await; let config = scheduler_config(Some(3600), None); append_run_record( &cg.store_layout().dashboard_root, @@ -125,7 +130,6 @@ async fn scheduler_session_reflector_respects_interval_gate() { async fn scheduler_skill_writer_respects_interval_gate() { let temp = tempdir().unwrap(); let cg = init_project(temp.path()).await; - seed_session_evidence(&cg).await; let config = scheduler_config(Some(3600), None); append_run_record( &cg.store_layout().dashboard_root, @@ -164,7 +168,6 @@ async fn scheduler_skill_writer_respects_interval_gate() { async fn scheduler_skill_writer_respects_idle_window_after_manual_run() { let temp = tempdir().unwrap(); let cg = init_project(temp.path()).await; - seed_session_evidence(&cg).await; let mut config = scheduler_config(Some(1), None); config.tasks.skill_writer.min_idle_secs = Some(3600); let mut record = scheduler_record_for( @@ -357,77 +360,6 @@ async fn init_project(project_root: &Path) -> TraceDecay { TraceDecay::init(project_root).await.unwrap() } -async fn seed_session_evidence(cg: &TraceDecay) { - let db = GlobalDb::open_at(&cg.store_layout().sessions_db_path) - .await - .expect("session db open"); - seed_session_message_in_db( - &db, - cg.project_root(), - SeedSessionMessage { - provider: "cursor", - session_id: "session-reflect-1", - message_id: "session-reflect-1-message-001", - role: "user", - timestamp: 1_715_000_001, - text: "Remember durable session reflection facts must remain approval gated for automation workflows.", - source: None, - }, - ) - .await; -} - -struct SeedSessionMessage<'a> { - provider: &'a str, - session_id: &'a str, - message_id: &'a str, - role: &'a str, - timestamp: i64, - text: &'a str, - source: Option<&'a str>, -} - -async fn seed_session_message_in_db( - db: &GlobalDb, - project_root: &Path, - seed: SeedSessionMessage<'_>, -) { - let session = SessionRecord { - provider: seed.provider.to_string(), - session_id: seed.session_id.to_string(), - project_key: project_root.display().to_string(), - project_path: project_root.display().to_string(), - title: Some("Session reflection fixture".to_string()), - started_at: Some(seed.timestamp.saturating_sub(1)), - ended_at: None, - transcript_path: None, - metadata_json: None, - parent_session_id: None, - is_subagent: false, - agent_id: None, - parent_tool_use_id: None, - }; - assert!(db.upsert_session(&session).await); - let message = SessionMessageRecord { - provider: seed.provider.to_string(), - message_id: seed.message_id.to_string(), - session_id: seed.session_id.to_string(), - role: seed.role.to_string(), - timestamp: Some(seed.timestamp), - ordinal: 1, - text: seed.text.to_string(), - kind: Some("message".to_string()), - model: None, - tool_names: None, - source_path: None, - source_offset: None, - metadata_json: seed - .source - .map(|source| json!({ "source": source }).to_string()), - }; - assert!(db.upsert_session_message(&message).await); -} - async fn seed_duplicate_facts(cg: &TraceDecay) { let conn = cg.db().conn(); let vec_a = HolographicEncoder::serialize(&[0.20, 0.35, 0.50]).unwrap(); diff --git a/tests/automation_session_reflector_runner_test.rs b/tests/automation_session_reflector_runner_test.rs index d6346c28..bad2ae0f 100644 --- a/tests/automation_session_reflector_runner_test.rs +++ b/tests/automation_session_reflector_runner_test.rs @@ -399,7 +399,10 @@ async fn session_fact_proposals_dedupe_repeated_pending_facts_across_runs() { async fn session_reflector_runner_reads_hermes_profile_lcm_with_filters() { let temp = tempdir().unwrap(); let cg = init_project(temp.path()).await; - seed_session_evidence(&cg).await; + // No project session store is seeded (or created) on purpose: the + // hermes_profile storage scope must read only the hermes profile DB, so + // a regression that consulted the project store would find no store at + // all and skip with lcm_not_ingested instead of succeeding. let hermes_home = tempdir().unwrap(); let profile_db_path = resolve_hermes_profile_session_db_path(hermes_home.path()).unwrap(); diff --git a/tests/automation_skill_writer_runner_test.rs b/tests/automation_skill_writer_runner_test.rs index 02b7ea01..9404e8e4 100644 --- a/tests/automation_skill_writer_runner_test.rs +++ b/tests/automation_skill_writer_runner_test.rs @@ -35,8 +35,12 @@ async fn skill_writer_runner_skips_when_task_is_disabled() { ); } +// Every test below that reaches evidence building holds `ENV_LOCK` and pins +// `TRACEDECAY_GLOBAL_DB` at its own session store via `isolate_global_db`: +// see that helper's docs for why (Windows CI global-DB contention). #[tokio::test] async fn skill_writer_default_provider_searches_all_providers() { + let _env_lock = ENV_LOCK.lock().await; let temp = tempdir().unwrap(); let profile_root = temp.path().join("profile"); let cg = init_project(temp.path()).await; @@ -57,6 +61,7 @@ async fn skill_writer_default_provider_searches_all_providers() { }, ) .await; + let _global_db = isolate_global_db(&cg); let backend = SkillJsonBackend::new(json!({"skills": []})); let config = AutomationConfig { enabled: true, @@ -91,10 +96,12 @@ async fn skill_writer_default_provider_searches_all_providers() { #[tokio::test] async fn skill_writer_runner_creates_pending_skill_drafts_for_approval() { + let _env_lock = ENV_LOCK.lock().await; let temp = tempdir().unwrap(); let profile_root = temp.path().join("profile"); let cg = init_project(temp.path()).await; seed_session_evidence(&cg).await; + let _global_db = isolate_global_db(&cg); let backend = SkillJsonBackend::new(json!({ "skills": [ { @@ -291,11 +298,10 @@ async fn skill_writer_evidence_imports_project_skill_usage_analytics_before_summ let _env_lock = ENV_LOCK.lock().await; let temp = tempdir().unwrap(); let profile_root = temp.path().join("profile"); - let global_db_path = temp.path().join("global.db"); - let _global_db = EnvVarGuard::set("TRACEDECAY_GLOBAL_DB", &global_db_path); let cg = init_project(temp.path()).await; seed_session_evidence(&cg).await; seed_search_underuse_session_evidence(&cg).await; + let _global_db = isolate_global_db(&cg); create_managed_skill_draft( &profile_root, ManagedSkillDraft { @@ -390,10 +396,12 @@ async fn skill_writer_evidence_imports_project_skill_usage_analytics_before_summ #[tokio::test] async fn skill_writer_evidence_includes_underused_tool_family_summary() { + let _env_lock = ENV_LOCK.lock().await; let temp = tempdir().unwrap(); let cg = init_project(temp.path()).await; seed_session_evidence(&cg).await; seed_search_underuse_session_evidence(&cg).await; + let _global_db = isolate_global_db(&cg); let backend = InspectSkillWriterUnderusedBackend; let config = AutomationConfig { enabled: true, @@ -431,10 +439,12 @@ async fn skill_writer_evidence_includes_underused_tool_family_summary() { #[tokio::test] async fn skill_writer_runner_auto_enables_when_config_explicitly_allows() { + let _env_lock = ENV_LOCK.lock().await; let temp = tempdir().unwrap(); let profile_root = temp.path().join("profile"); let cg = init_project(temp.path()).await; seed_session_evidence(&cg).await; + let _global_db = isolate_global_db(&cg); create_managed_skill_draft( &profile_root, ManagedSkillDraft { @@ -565,10 +575,12 @@ async fn skill_writer_runner_auto_enables_when_config_explicitly_allows() { #[tokio::test] async fn skill_writer_runner_updates_existing_skills_with_checksum_precondition() { + let _env_lock = ENV_LOCK.lock().await; let temp = tempdir().unwrap(); let profile_root = temp.path().join("profile"); let cg = init_project(temp.path()).await; seed_session_evidence(&cg).await; + let _global_db = isolate_global_db(&cg); create_managed_skill_draft( &profile_root, ManagedSkillDraft { @@ -764,10 +776,12 @@ async fn skill_writer_runner_updates_existing_skills_with_checksum_precondition( #[tokio::test] async fn skill_writer_runner_ledgers_malformed_backend_output() { + let _env_lock = ENV_LOCK.lock().await; let temp = tempdir().unwrap(); let profile_root = temp.path().join("profile"); let cg = init_project(temp.path()).await; seed_session_evidence(&cg).await; + let _global_db = isolate_global_db(&cg); let backend = SkillTextBackend::new("not json"); let config = AutomationConfig { enabled: true, @@ -832,10 +846,12 @@ async fn skill_writer_runner_ledgers_malformed_backend_output() { #[tokio::test] async fn skill_writer_runner_ledgers_missing_skills_array() { + let _env_lock = ENV_LOCK.lock().await; let temp = tempdir().unwrap(); let profile_root = temp.path().join("profile"); let cg = init_project(temp.path()).await; seed_session_evidence(&cg).await; + let _global_db = isolate_global_db(&cg); let output = json!({"summary": "no skills"}); let backend = SkillJsonBackend::new(output.clone()); let config = AutomationConfig { @@ -898,10 +914,12 @@ async fn skill_writer_runner_ledgers_missing_skills_array() { #[tokio::test] async fn skill_writer_runner_records_noop_fallback_when_backend_run_task_fails() { + let _env_lock = ENV_LOCK.lock().await; let temp = tempdir().unwrap(); let profile_root = temp.path().join("profile"); let cg = init_project(temp.path()).await; seed_session_evidence(&cg).await; + let _global_db = isolate_global_db(&cg); let backend = FailingBackend::new(AgentTaskKind::SkillWriter); let config = AutomationConfig { enabled: true, diff --git a/tests/branch_drift_test.rs b/tests/branch_drift_test.rs index 2410c58d..5a68b7de 100644 --- a/tests/branch_drift_test.rs +++ b/tests/branch_drift_test.rs @@ -346,16 +346,23 @@ async fn open_repairs_missing_tracked_branch_db_before_diagnostics() { .unwrap(); commit_all(project, "feature"); - TraceDecay::add_branch_tracking(project, "feature/tracked") - .await - .unwrap(); - + // Track the branch by writing its metadata entry directly instead of + // going through TraceDecay::add_branch_tracking, which would build and + // sync a branch DB only for the test to delete it again. The repair + // under test keys purely off "tracked in metadata, DB file missing", so + // the state is identical and the fixture skips a whole DB build. let tracedecay_dir = project_data_dir(project); - let meta = tracedecay::branch_meta::load_branch_meta(&tracedecay_dir).unwrap(); + let mut meta = tracedecay::branch_meta::load_branch_meta(&tracedecay_dir).unwrap(); + let stem = tracedecay::branch::sanitize_branch_name("feature/tracked"); + meta.add_branch("feature/tracked", &format!("branches/{stem}.db"), "main"); + save_branch_meta(&tracedecay_dir, &meta).unwrap(); let feature_db = tracedecay::branch::resolve_branch_db_path(&tracedecay_dir, "feature/tracked", &meta) .unwrap(); - fs::remove_file(&feature_db).unwrap(); + assert!( + !feature_db.exists(), + "tracked branch DB must be missing before the repair-on-open under test" + ); let cg = TraceDecay::open(project).await.unwrap(); let diagnostics = cg.branch_diagnostics(); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index e222f908..bbe5cb6d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -410,6 +410,15 @@ pub async fn open_lcm_db(tmp: &TempDir) -> GlobalDb { GlobalDb::open_at(&db_path).await.expect("session db open") } +/// Writes an empty `GlobalDb`-schema store at `db_path` from the cached +/// per-process template, so later opens (fixture seeding, dashboard server +/// startup) find an existing DB and skip the full schema creation — a large +/// fixed cost on Windows. The first call in a process pays one real schema +/// creation to build the template; every further store is a file copy. +pub async fn write_empty_global_db_schema(db_path: &Path) { + seed_lcm_db_from_template(db_path).await; +} + async fn seed_lcm_db_from_template(db_path: &Path) { if let Some(parent) = db_path.parent() { fs::create_dir_all(parent).unwrap_or_else(|err| { diff --git a/tests/corruption_test.rs b/tests/corruption_test.rs index 54c1dfcf..26e0592e 100644 --- a/tests/corruption_test.rs +++ b/tests/corruption_test.rs @@ -8,6 +8,8 @@ //! - The dirty sentinel lifecycle works correctly //! - The full crash→detect→repair cycle works end-to-end +mod common; + use std::io::{Seek, Write}; use tempfile::TempDir; use tracedecay::db::Database; @@ -192,7 +194,14 @@ async fn search_nodes_falls_back_to_like_when_fts_empty() { // ─── begin_bulk_load no longer downgrades synchronous ──────────────────── #[tokio::test] +#[allow(clippy::await_holding_lock)] async fn bulk_load_preserves_platform_synchronous_mode() { + // Pin the CI-only unsafe-fast escape hatch off for this test: it asserts + // the *durable* platform synchronous mode, which + // TRACEDECAY_SQLITE_UNSAFE_FAST=1 (exported for the whole Windows CI test + // run) would relax to OFF. + let _env_lock = common::GLOBAL_DB_ENV_LOCK.lock().unwrap(); + let _unsafe_fast_off = common::EnvVarGuard::unset(tracedecay::db::SQLITE_UNSAFE_FAST_ENV); let (db, _dir, _path) = setup_db().await; db.begin_bulk_load().await.unwrap(); diff --git a/tests/dashboard_analytics_api_test.rs b/tests/dashboard_analytics_api_test.rs index 38e4aa26..cb50059c 100644 --- a/tests/dashboard_analytics_api_test.rs +++ b/tests/dashboard_analytics_api_test.rs @@ -9,7 +9,7 @@ use std::sync::Mutex; use common::{ create_runtime, get_json, http_agent, message_record_at, pick_free_port, wait_for_dashboard, - EnvVarGuard, + write_empty_global_db_schema, EnvVarGuard, }; use serde_json::Value; use tempfile::TempDir; @@ -276,10 +276,15 @@ async fn start_fixture(seed_durable_events: bool) -> Fixture { let global_db_path = tmp.path().join("global").join("global.db"); let env_guard = EnvVarGuard::set("TRACEDECAY_GLOBAL_DB", &global_db_path); + // Pre-create both GlobalDb-schema stores from the cached empty template + // so seeding and dashboard startup open existing DBs instead of paying a + // full schema creation each (slow on Windows). + write_empty_global_db_schema(&global_db_path).await; let cg = TraceDecay::init(&project_root) .await .expect("tracedecay init"); let session_db_path = project_session_db_path(&project_root); + write_empty_global_db_schema(&session_db_path).await; seed_session_store(&session_db_path, &project_root).await; if seed_durable_events { seed_durable_analytics(&global_db_path, &project_root).await; diff --git a/tests/dashboard_api_support/mod.rs b/tests/dashboard_api_support/mod.rs index 60032ed7..0bafc6cd 100644 --- a/tests/dashboard_api_support/mod.rs +++ b/tests/dashboard_api_support/mod.rs @@ -8,7 +8,8 @@ pub(crate) use std::thread; pub(crate) use crate::common::{ create_runtime, fake_codex_bin, get_json, http_agent, http_agent_with_timeout, install_fake_codex_launcher, pick_free_port, response_to_json, tempdir_or_panic, - wait_for_dashboard, EnvVarGuard, GLOBAL_DB_ENV, GLOBAL_DB_ENV_LOCK, + wait_for_dashboard, write_empty_global_db_schema, EnvVarGuard, GLOBAL_DB_ENV, + GLOBAL_DB_ENV_LOCK, }; pub(crate) use serde_json::Value; pub(crate) use tempfile::TempDir; @@ -514,19 +515,18 @@ async fn start_dashboard_fixture_with_options( panic!("failed to enroll dashboard fixture in profile storage: {err}"); } + // Pre-create both GlobalDb-schema stores from the cached empty template: + // the init-time registry write, LCM seeding, and the dashboard server's + // startup LCM resolve + catch-up ingest then all open existing DBs + // instead of each paying a full schema creation (slow on Windows). + write_empty_global_db_schema(&global_db_path).await; + let cg = setup_project(&project_root).await; if seed_memory { seed_memory_fixture(&cg).await; } - let global_db = match GlobalDb::open_at(&global_db_path).await { - Some(db) => db, - None => panic!( - "failed to open temporary global DB at {}", - global_db_path.display() - ), - }; - drop(global_db); + write_empty_global_db_schema(&cg.store_layout().sessions_db_path).await; if seed_lcm { let session_store = open_project_session_store(&project_root).await; seed_lcm_fixture(&session_store, &project_root).await; diff --git a/tests/dashboard_memory_curation_api_test.rs b/tests/dashboard_memory_curation_api_test.rs index 6483da87..9ec590c3 100644 --- a/tests/dashboard_memory_curation_api_test.rs +++ b/tests/dashboard_memory_curation_api_test.rs @@ -837,8 +837,14 @@ fn curation_preview_persists_across_dashboard_restarts() { let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + // Pre-create both GlobalDb-schema stores from the cached empty + // template so each dashboard server start opens existing DBs instead + // of paying a full schema creation (slow on Windows, and this test + // starts the server twice). + write_empty_global_db_schema(&global_db_path).await; let cg = setup_project(&project_root).await; seed_memory_fixture(&cg).await; + write_empty_global_db_schema(&cg.store_layout().sessions_db_path).await; let agent = http_agent(); let sidecar = cg .store_layout() diff --git a/tests/mcp_handler_test.rs b/tests/mcp_handler_test.rs index ee127ba4..9f428988 100644 --- a/tests/mcp_handler_test.rs +++ b/tests/mcp_handler_test.rs @@ -61,19 +61,28 @@ impl Deref for TestDbConnection { async fn open_test_db_connection(db_path: &Path) -> TestDbConnection { let db = libsql::Builder::new_local(db_path).build().await.unwrap(); let conn = db.connect().unwrap(); - let pragmas = if cfg!(windows) { - "PRAGMA mmap_size = 0; - PRAGMA journal_mode = DELETE; - PRAGMA busy_timeout = 5000; - PRAGMA synchronous = FULL; - PRAGMA foreign_keys = ON;" + // Mirror the pragma choices of src/db/connection.rs, including the + // CI-only TRACEDECAY_SQLITE_UNSAFE_FAST=1 escape hatch, so this helper + // never fights the journal mode the product code selected. + let unsafe_fast = std::env::var(tracedecay::db::SQLITE_UNSAFE_FAST_ENV).as_deref() == Ok("1"); + let (journal_mode, synchronous) = if unsafe_fast { + ("MEMORY", "OFF") + } else if cfg!(windows) { + ("DELETE", "FULL") } else { - "PRAGMA journal_mode = WAL; + ("WAL", "NORMAL") + }; + if cfg!(windows) { + conn.execute_batch("PRAGMA mmap_size = 0;").await.unwrap(); + } + conn.execute_batch(&format!( + "PRAGMA journal_mode = {journal_mode}; PRAGMA busy_timeout = 5000; - PRAGMA synchronous = NORMAL; + PRAGMA synchronous = {synchronous}; PRAGMA foreign_keys = ON;" - }; - conn.execute_batch(pragmas).await.unwrap(); + )) + .await + .unwrap(); TestDbConnection { _db: db, conn } }