mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Route SQLite logs to an explicitly configured log_dir (#55978)
## What changed - Store `logs_2.sqlite` in `log_dir` when explicitly configured, unless a managed `sqlite_home` requirement applies. Keep other databases in `sqlite_home` and preserve the existing SQLite log location when `log_dir` is unset. - Initialize and lock the selected log directory only when opening its database, so an unavailable directory does not prevent authentication commands from loading configuration. Share the cleanup lock across configuration clones and allow failed initialization to be retried. - Leave existing logs in place. Read legacy logs for diagnostic submissions when the current query returns no logs, attaching them as `codex-logs-legacy.log`. Continue retention cleanup and page reclamation for both locations, with independent maintenance locks and filesystem checks. ## Testing Add coverage for configuration routing and managed requirements, deferred initialization and lock lifetimes, authentication with an unavailable log directory, legacy log attachments, and cleanup of current and legacy databases. GitOrigin-RevId: 48216694596d738eff1b3c5eef6c4e7ada6d0137
This commit is contained in:
committed by
copyberry
parent
3c4c04b224
commit
17a0e6f63f
@@ -304,6 +304,33 @@ WHERE id IN (
|
||||
pub async fn query_feedback_logs_for_threads(
|
||||
&self,
|
||||
thread_ids: &[&str],
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
Self::query_feedback_logs_from_pool(self.logs_pool.as_ref(), thread_ids).await
|
||||
}
|
||||
|
||||
/// Read logs from the previous directory without creating or migrating its database.
|
||||
pub async fn query_legacy_feedback_logs_for_threads(
|
||||
&self,
|
||||
thread_ids: &[&str],
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let Some(path) = self.sqlite.legacy_logs_db_path() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if thread_ids.is_empty() || !tokio::fs::try_exists(&path).await? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let pool = self
|
||||
.sqlite
|
||||
.open_read_only_pool(&path, /*busy_timeout*/ None)
|
||||
.await?;
|
||||
let result = Self::query_feedback_logs_from_pool(&pool, thread_ids).await;
|
||||
pool.close().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn query_feedback_logs_from_pool(
|
||||
pool: &SqlitePool,
|
||||
thread_ids: &[&str],
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
if thread_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -376,7 +403,7 @@ WHERE cumulative_estimated_bytes <=
|
||||
builder.push(" ORDER BY ts DESC, ts_nanos DESC, id DESC");
|
||||
let rows = builder
|
||||
.build_query_as::<FeedbackLogRow>()
|
||||
.fetch_all(self.logs_pool.as_ref())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Prune diagnostic logs in the background immediately, then on a timer.
|
||||
//! Prune current and legacy diagnostic logs immediately, then on a timer.
|
||||
//! SQLite keeps freed pages for reuse.
|
||||
|
||||
use super::StateRuntime;
|
||||
@@ -29,25 +29,40 @@ impl StateRuntime {
|
||||
if runtime.logs_pool.is_closed() {
|
||||
break;
|
||||
}
|
||||
if let Err(err) = prune_by_age_and_size(
|
||||
&runtime.logs_pool,
|
||||
Utc::now().timestamp(),
|
||||
LOG_DATABASE_BUDGET_BYTES,
|
||||
)
|
||||
.await
|
||||
// Older versions write logs in sqlite_home. Keep the same retention policy
|
||||
// there until those logs expire. Do not create a missing legacy database.
|
||||
let legacy_pool = match runtime.sqlite.open_legacy_logs_for_cleanup().await {
|
||||
Ok(pool) => pool,
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to open legacy diagnostic logs for cleanup: {err}");
|
||||
None
|
||||
}
|
||||
};
|
||||
for pool in std::iter::once(runtime.logs_pool.as_ref()).chain(legacy_pool.as_ref())
|
||||
{
|
||||
tracing::warn!("failed to prune diagnostic logs: {err}");
|
||||
}
|
||||
if first_sweep {
|
||||
first_sweep = false;
|
||||
// Preserve the startup checkpoint without waiting for readers or writers.
|
||||
if let Err(err) = sqlx::query("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
.execute(runtime.logs_pool.as_ref())
|
||||
.await
|
||||
if let Err(err) = prune_by_age_and_size(
|
||||
pool,
|
||||
Utc::now().timestamp(),
|
||||
LOG_DATABASE_BUDGET_BYTES,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("failed to checkpoint diagnostic logs: {err}");
|
||||
tracing::warn!("failed to prune diagnostic logs: {err}");
|
||||
}
|
||||
if first_sweep {
|
||||
// Preserve the startup checkpoint without waiting for readers or writers.
|
||||
if let Err(err) = sqlx::query("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("failed to checkpoint diagnostic logs: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(pool) = legacy_pool {
|
||||
pool.close().await;
|
||||
}
|
||||
first_sweep = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,72 +95,191 @@ async fn stops_at_one_second_when_recent_rows_exceed_budget() {
|
||||
#[tokio::test]
|
||||
async fn periodic_cleanup_prunes_without_new_log_writes_or_restart() {
|
||||
let directory = super::super::test_support::unique_temp_dir();
|
||||
let sqlite = SqliteConfig::new_for_testing(directory.as_path().abs());
|
||||
let runtime = StateRuntime::init(
|
||||
SqliteConfig::new_for_testing(directory.as_path().abs()),
|
||||
sqlite.clone().with_local_logs_home(directory.join("log")),
|
||||
"test-provider".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let now = Utc::now().timestamp();
|
||||
for timestamp in [now - 11 * DAY, now] {
|
||||
sqlx::query("INSERT INTO logs (ts, ts_nanos, level, target) VALUES (?, 0, 'INFO', 'test')")
|
||||
.bind(timestamp)
|
||||
.execute(runtime.logs_pool.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// An older process can create the legacy database after the new runtime starts.
|
||||
let legacy_pool = sqlite
|
||||
.open_logs_db(&LOGS_MIGRATOR, /*telemetry_override*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
runtime.start_periodic_logs_maintenance(Duration::from_millis(10));
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let timestamps: Vec<i64> = sqlx::query_scalar("SELECT ts FROM logs ORDER BY ts")
|
||||
.fetch_all(runtime.logs_pool.as_ref())
|
||||
let now = Utc::now().timestamp();
|
||||
for round in 1..=2 {
|
||||
for pool in [runtime.logs_pool.as_ref(), &legacy_pool] {
|
||||
for timestamp in [now - 11 * DAY, now] {
|
||||
sqlx::query(
|
||||
"INSERT INTO logs (ts, ts_nanos, level, target) VALUES (?, 0, 'INFO', 'test')",
|
||||
)
|
||||
.bind(timestamp)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
if timestamps == vec![now] {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("periodic cleanup should remove expired logs while idle");
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let current: Vec<i64> = sqlx::query_scalar("SELECT ts FROM logs ORDER BY ts")
|
||||
.fetch_all(runtime.logs_pool.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let legacy: Vec<i64> = sqlx::query_scalar("SELECT ts FROM logs ORDER BY ts")
|
||||
.fetch_all(&legacy_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
if (current, legacy) == (vec![now; round], vec![now; round]) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("periodic cleanup should remove expired logs from both locations while idle");
|
||||
}
|
||||
legacy_pool.close().await;
|
||||
runtime.close().await;
|
||||
tokio::fs::remove_dir_all(directory).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_cleanup_runs_without_waiting_for_the_period() {
|
||||
async fn legacy_cleanup_skips_missing_and_shared_databases() -> anyhow::Result<()> {
|
||||
let directory = super::super::test_support::unique_temp_dir();
|
||||
tokio::fs::create_dir_all(&directory).await.unwrap();
|
||||
tokio::fs::create_dir_all(&directory).await?;
|
||||
let sqlite = SqliteConfig::new_for_testing(directory.as_path().abs());
|
||||
let legacy_path = sqlite.logs_db_path();
|
||||
for logs_home in [directory.clone(), directory.join("log")] {
|
||||
let sqlite = sqlite.clone().with_local_logs_home(logs_home);
|
||||
assert!(sqlite.open_legacy_logs_for_cleanup().await?.is_none());
|
||||
assert!(!legacy_path.exists());
|
||||
}
|
||||
let pool = sqlite
|
||||
.open_read_write_pool(&sqlite.logs_db_path())
|
||||
.await
|
||||
.unwrap();
|
||||
LOGS_MIGRATOR.run(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO logs (ts, ts_nanos, level, target) VALUES (0, 0, 'INFO', 'test')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
.open_logs_db(&LOGS_MIGRATOR, /*telemetry_override*/ None)
|
||||
.await?;
|
||||
assert!(sqlite.open_legacy_logs_for_cleanup().await?.is_none());
|
||||
pool.close().await;
|
||||
tokio::fs::remove_dir_all(directory).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let runtime = StateRuntime::init(sqlite, "test-provider".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
#[tokio::test]
|
||||
async fn legacy_writer_does_not_block_current_cleanup_and_is_retried() -> anyhow::Result<()> {
|
||||
let directory = super::super::test_support::unique_temp_dir();
|
||||
tokio::fs::create_dir_all(&directory).await?;
|
||||
let sqlite = SqliteConfig::new_for_testing(directory.as_path().abs());
|
||||
let legacy_pool = sqlite
|
||||
.open_logs_db(&LOGS_MIGRATOR, /*telemetry_override*/ None)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO logs (ts, ts_nanos, level, target) VALUES (0, 0, 'INFO', 'test')")
|
||||
.execute(&legacy_pool)
|
||||
.await?;
|
||||
let writer = legacy_pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
let runtime = StateRuntime::init(
|
||||
sqlite.with_local_logs_home(directory.join("log")),
|
||||
"test-provider".to_string(),
|
||||
)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO logs (ts, ts_nanos, level, target) VALUES (0, 0, 'INFO', 'test')")
|
||||
.execute(runtime.logs_pool.as_ref())
|
||||
.await?;
|
||||
runtime.start_periodic_logs_maintenance(Duration::from_millis(10));
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM logs")
|
||||
.fetch_one(runtime.logs_pool.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
while sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM logs")
|
||||
.fetch_one(runtime.logs_pool.as_ref())
|
||||
.await
|
||||
.unwrap()
|
||||
!= 0
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("startup cleanup should run before the 30-minute interval");
|
||||
.await?;
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM logs")
|
||||
.fetch_one(&legacy_pool)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
writer.rollback().await?;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM logs")
|
||||
.fetch_one(&legacy_pool)
|
||||
.await
|
||||
.unwrap()
|
||||
!= 0
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
legacy_pool.close().await;
|
||||
runtime.close().await;
|
||||
tokio::fs::remove_dir_all(directory).await.unwrap();
|
||||
tokio::fs::remove_dir_all(directory).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_cleanup_runs_without_waiting_for_the_period() {
|
||||
for separate_logs in [false, true] {
|
||||
let directory = super::super::test_support::unique_temp_dir();
|
||||
tokio::fs::create_dir_all(&directory).await.unwrap();
|
||||
let sqlite = SqliteConfig::new_for_testing(directory.as_path().abs());
|
||||
let legacy_path = sqlite.logs_db_path();
|
||||
let legacy_pool = sqlite
|
||||
.open_logs_db(&LOGS_MIGRATOR, /*telemetry_override*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
let sqlite = if separate_logs {
|
||||
sqlite.with_local_logs_home(directory.join("log"))
|
||||
} else {
|
||||
sqlite
|
||||
};
|
||||
let pool = sqlite
|
||||
.open_logs_db(&LOGS_MIGRATOR, /*telemetry_override*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
for pool in [&pool, &legacy_pool] {
|
||||
sqlx::query(
|
||||
"INSERT INTO logs (ts, ts_nanos, level, target) VALUES (0, 0, 'INFO', 'test')",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
pool.close().await;
|
||||
legacy_pool.close().await;
|
||||
|
||||
let runtime = StateRuntime::init(sqlite, "test-provider".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let legacy_pool = runtime
|
||||
.sqlite
|
||||
.open_read_only_pool(&legacy_path, /*busy_timeout*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let current: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM logs")
|
||||
.fetch_one(runtime.logs_pool.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let legacy: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM logs")
|
||||
.fetch_one(&legacy_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
if (current, legacy) == (0, 0) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("startup cleanup should prune both locations before the 30-minute interval");
|
||||
legacy_pool.close().await;
|
||||
runtime.close().await;
|
||||
tokio::fs::remove_dir_all(directory).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Reclaims unused SQLite pages in opted-in databases, reducing batches after stalled passes.
|
||||
//! Current and legacy log directories each retain their own maintenance lock.
|
||||
//! NFS on Linux skips opening a maintenance connection.
|
||||
use crate::RuntimeDbPath;
|
||||
use crate::SqliteConfig;
|
||||
@@ -8,8 +9,10 @@ use sqlx::Connection;
|
||||
use sqlx::SqliteConnection;
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use sqlx::sqlite::SqliteSynchronous;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
@@ -42,30 +45,18 @@ impl SqliteReclamationWorker {
|
||||
// Declared first so completion is signaled after all worker resources drop.
|
||||
let _finish = finish;
|
||||
let mut scheduled = sqlite
|
||||
.runtime_db_paths()
|
||||
.reclamation_db_paths()
|
||||
.into_iter()
|
||||
.filter_map(|db| {
|
||||
if db.background_reclamation {
|
||||
Some((db, Instant::now(), ReclamationState::default()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|db| (db, Instant::now(), ReclamationState::default()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut owner = None;
|
||||
let mut owners = BTreeMap::new();
|
||||
let mut delay = IDLE_INTERVAL;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = receiver.changed() => break,
|
||||
_ = tokio::time::sleep(delay) => {}
|
||||
}
|
||||
if owner.is_none() {
|
||||
owner = try_ownership(sqlite.home()).await.ok().flatten();
|
||||
}
|
||||
if owner.is_none() {
|
||||
continue;
|
||||
}
|
||||
delay = visit(&sqlite, &mut scheduled, &receiver).await;
|
||||
delay = visit(&sqlite, &mut scheduled, &mut owners, &receiver).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -82,6 +73,7 @@ impl SqliteReclamationWorker {
|
||||
async fn visit(
|
||||
sqlite: &SqliteConfig,
|
||||
scheduled: &mut [(RuntimeDbPath, Instant, ReclamationState)],
|
||||
owners: &mut BTreeMap<PathBuf, File>,
|
||||
shutdown: &watch::Receiver<()>,
|
||||
) -> Duration {
|
||||
for (db, due, state) in &mut *scheduled {
|
||||
@@ -92,6 +84,18 @@ async fn visit(
|
||||
if *due > started {
|
||||
continue;
|
||||
}
|
||||
let Some(home) = db.path.parent() else {
|
||||
continue;
|
||||
};
|
||||
if let std::collections::btree_map::Entry::Vacant(entry) = owners.entry(home.to_path_buf())
|
||||
{
|
||||
if let Ok(Some(owner)) = try_ownership(home).await {
|
||||
entry.insert(owner);
|
||||
} else {
|
||||
*due = started + IDLE_INTERVAL;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let result = reclaim(
|
||||
sqlite,
|
||||
&db.path,
|
||||
@@ -214,8 +218,14 @@ async fn reclaim(
|
||||
options: impl Into<ReclamationOptions>,
|
||||
shutdown: &watch::Receiver<()>,
|
||||
) -> anyhow::Result<ReclamationPass> {
|
||||
// do not perform autovacuum and cleanup on NFS as it creates a lot of load and contention on IO
|
||||
if sqlite.is_nfs().await? {
|
||||
// Check the selected logs directory. An administrator can also select NFS.
|
||||
let logs = path == sqlite.logs_db_path();
|
||||
let is_nfs = if logs {
|
||||
sqlite.logs_are_on_nfs().await?
|
||||
} else {
|
||||
sqlite.is_nfs().await?
|
||||
};
|
||||
if is_nfs {
|
||||
return Ok(ReclamationPass {
|
||||
pages: 0,
|
||||
outcome: PassOutcome::Idle,
|
||||
|
||||
@@ -4,6 +4,88 @@ use crate::runtime::test_support::unique_temp_dir;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaims_current_and_legacy_logs_with_independent_directory_locks() -> anyhow::Result<()> {
|
||||
let directory = unique_temp_dir();
|
||||
tokio::fs::create_dir_all(&directory).await?;
|
||||
let legacy = SqliteConfig::new_for_testing(directory.as_path().abs());
|
||||
let sqlite = legacy.clone().with_local_logs_home(directory.join("log"));
|
||||
let mut pools = Vec::new();
|
||||
let mut sizes = Vec::new();
|
||||
for config in [&sqlite, &legacy] {
|
||||
let pool = config
|
||||
.open_logs_db(&runtime_logs_migrator(), /*telemetry_override*/ None)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"WITH RECURSIVE n(i) AS (VALUES(1) UNION ALL SELECT i+1 FROM n WHERE i < 4096) \
|
||||
INSERT INTO logs(ts,ts_nanos,level,target,feedback_log_body) \
|
||||
SELECT unixepoch(),0,'INFO','fixture',printf('%032768d',i) FROM n",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM logs WHERE id%4 != 0")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sizes.push(tokio::fs::metadata(config.logs_db_path()).await?.len());
|
||||
pools.push(pool);
|
||||
}
|
||||
let rows = "SELECT id, feedback_log_body FROM logs ORDER BY id";
|
||||
let expected: Vec<(i64, String)> = sqlx::query_as(rows).fetch_all(&pools[0]).await?;
|
||||
let mut scheduled = sqlite
|
||||
.reclamation_db_paths()
|
||||
.into_iter()
|
||||
.map(|db| (db, Instant::now(), ReclamationState::default()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut owners = BTreeMap::new();
|
||||
let (_shutdown, receiver) = watch::channel(());
|
||||
|
||||
// A legacy process owns its directory. Current logs must still be reclaimed.
|
||||
let legacy_owner = try_ownership(legacy.home()).await?.unwrap();
|
||||
for (index, config) in [&sqlite, &legacy].into_iter().enumerate() {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
for (_, due, _) in &mut scheduled {
|
||||
*due = Instant::now();
|
||||
}
|
||||
visit(&sqlite, &mut scheduled, &mut owners, &receiver).await;
|
||||
if config.logs_are_on_nfs().await.unwrap()
|
||||
|| tokio::fs::metadata(config.logs_db_path())
|
||||
.await
|
||||
.unwrap()
|
||||
.len()
|
||||
< sizes[index]
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if index == 0 {
|
||||
assert_eq!(
|
||||
tokio::fs::metadata(legacy.logs_db_path()).await?.len(),
|
||||
sizes[1]
|
||||
);
|
||||
legacy_owner.unlock()?;
|
||||
}
|
||||
}
|
||||
for pool in pools {
|
||||
assert_eq!(
|
||||
sqlx::query_as::<_, (i64, String)>(rows)
|
||||
.fetch_all(&pool)
|
||||
.await?,
|
||||
expected
|
||||
);
|
||||
pool.close().await;
|
||||
}
|
||||
drop(owners);
|
||||
drop(legacy_owner);
|
||||
tokio::fs::remove_dir_all(directory).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_reclamation_releases_writer_and_resumes_without_data_loss()
|
||||
-> anyhow::Result<()> {
|
||||
|
||||
+108
-23
@@ -1,6 +1,7 @@
|
||||
//! Shared SQLite connection configuration.
|
||||
//! Writable pools preserve the journal and vacuum modes of initialized databases.
|
||||
//! Startup retries SQLITE_BUSY and reports other errors to callers.
|
||||
//! The configured logs directory is created and locked only when opening its database.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
@@ -12,6 +13,7 @@ use crate::migrations::repair_legacy_recency_migration_version;
|
||||
use crate::runtime::recovery::RuntimeDbInitError;
|
||||
use crate::telemetry;
|
||||
use crate::telemetry::DbKind;
|
||||
use codex_file_system::LocalLogsDir;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use log::LevelFilter;
|
||||
use sqlx::ConnectOptions;
|
||||
@@ -65,8 +67,13 @@ struct RuntimeDbSpec {
|
||||
}
|
||||
|
||||
impl RuntimeDbSpec {
|
||||
fn path(self, codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(self.filename)
|
||||
fn path(self, sqlite: &SqliteConfig) -> PathBuf {
|
||||
let home = if matches!(self.kind, DbKind::Logs) {
|
||||
sqlite.logs_home()
|
||||
} else {
|
||||
sqlite.home()
|
||||
};
|
||||
home.join(self.filename)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,19 +163,23 @@ pub struct RuntimeDbPath {
|
||||
}
|
||||
|
||||
/// Resolved configuration shared by all Codex SQLite connections.
|
||||
/// Clones share filesystem probes and quick-check attempts; a new config checks independently.
|
||||
/// Clones share directory locks, filesystem probes, and quick-check attempts.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SqliteConfig {
|
||||
sqlite_home: AbsolutePathBuf,
|
||||
local_logs_dir: Option<LocalLogsDir>,
|
||||
is_nfs: Arc<OnceCell<bool>>,
|
||||
logs_is_nfs: Arc<OnceCell<bool>>,
|
||||
quick_check_manager: SqliteQuickCheckManager,
|
||||
}
|
||||
|
||||
// Cached filesystem information and validation history are operational state,
|
||||
// Directory locks, cached filesystem information, and validation history are operational state,
|
||||
// not part of configuration equality.
|
||||
impl PartialEq for SqliteConfig {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.sqlite_home == other.sqlite_home
|
||||
&& self.local_logs_dir.as_ref().map(LocalLogsDir::path)
|
||||
== other.local_logs_dir.as_ref().map(LocalLogsDir::path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +189,9 @@ impl SqliteConfig {
|
||||
pub fn from_sqlite_home(sqlite_home: AbsolutePathBuf) -> Self {
|
||||
Self {
|
||||
sqlite_home,
|
||||
local_logs_dir: None,
|
||||
is_nfs: Arc::new(OnceCell::new()),
|
||||
logs_is_nfs: Arc::new(OnceCell::new()),
|
||||
quick_check_manager: SqliteQuickCheckManager::new(),
|
||||
}
|
||||
}
|
||||
@@ -191,42 +204,55 @@ impl SqliteConfig {
|
||||
self.sqlite_home.as_path()
|
||||
}
|
||||
|
||||
/// Select the logs directory without accessing it until its database is opened.
|
||||
pub fn with_local_logs_home(mut self, home: PathBuf) -> Self {
|
||||
self.local_logs_dir = Some(LocalLogsDir::new(home));
|
||||
self.logs_is_nfs = Arc::new(OnceCell::new());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn logs_home(&self) -> &Path {
|
||||
self.local_logs_dir
|
||||
.as_ref()
|
||||
.map_or(self.home(), LocalLogsDir::path)
|
||||
}
|
||||
|
||||
/// Detects if the sqlite_home directory is under the NFS mount (once per the lifetime)
|
||||
pub async fn is_nfs(&self) -> anyhow::Result<bool> {
|
||||
self.is_nfs
|
||||
.get_or_try_init(|| async {
|
||||
let sqlite_home = self.home().to_path_buf();
|
||||
Ok(
|
||||
tokio::task::spawn_blocking(move || codex_file_system::is_nfs(&sqlite_home))
|
||||
.await??,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.copied()
|
||||
probe_nfs(self.home(), &self.is_nfs).await
|
||||
}
|
||||
|
||||
pub(crate) async fn logs_are_on_nfs(&self) -> anyhow::Result<bool> {
|
||||
probe_nfs(self.logs_home(), &self.logs_is_nfs).await
|
||||
}
|
||||
|
||||
/// Return the path to the primary state database.
|
||||
pub fn state_db_path(&self) -> PathBuf {
|
||||
STATE_DB.path(self.home())
|
||||
STATE_DB.path(self)
|
||||
}
|
||||
|
||||
/// Return the path to the logs database.
|
||||
pub fn logs_db_path(&self) -> PathBuf {
|
||||
LOGS_DB.path(self.home())
|
||||
LOGS_DB.path(self)
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_logs_db_path(&self) -> Option<PathBuf> {
|
||||
let path = self.home().join(LOGS_DB_FILENAME);
|
||||
(path != self.logs_db_path()).then_some(path)
|
||||
}
|
||||
|
||||
/// Return the path to the goals database.
|
||||
pub fn goals_db_path(&self) -> PathBuf {
|
||||
GOALS_DB.path(self.home())
|
||||
GOALS_DB.path(self)
|
||||
}
|
||||
|
||||
/// Return the path to the memories database.
|
||||
pub fn memories_db_path(&self) -> PathBuf {
|
||||
MEMORIES_DB.path(self.home())
|
||||
MEMORIES_DB.path(self)
|
||||
}
|
||||
|
||||
pub(crate) fn memories_v2_db_path(&self) -> PathBuf {
|
||||
MEMORIES_V2_DB.path(self.home())
|
||||
MEMORIES_V2_DB.path(self)
|
||||
}
|
||||
|
||||
pub(crate) async fn open_memories_v2_db(&self) -> anyhow::Result<SqlitePool> {
|
||||
@@ -240,12 +266,12 @@ impl SqliteConfig {
|
||||
|
||||
/// Return the path to the durable user-message queue database.
|
||||
pub fn queue_db_path(&self) -> PathBuf {
|
||||
QUEUE_DB.path(self.home())
|
||||
QUEUE_DB.path(self)
|
||||
}
|
||||
|
||||
/// Return the path to the paginated thread-history database.
|
||||
pub fn thread_history_db_path(&self) -> PathBuf {
|
||||
THREAD_HISTORY_DB.path(self.home())
|
||||
THREAD_HISTORY_DB.path(self)
|
||||
}
|
||||
|
||||
/// Return the paths to every database managed by the state runtime.
|
||||
@@ -254,12 +280,24 @@ impl SqliteConfig {
|
||||
.iter()
|
||||
.map(|spec| RuntimeDbPath {
|
||||
label: spec.label,
|
||||
path: spec.path(self.home()),
|
||||
path: spec.path(self),
|
||||
background_reclamation: spec.background_reclamation,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn reclamation_db_paths(&self) -> Vec<RuntimeDbPath> {
|
||||
self.runtime_db_paths()
|
||||
.into_iter()
|
||||
.filter(|db| db.background_reclamation)
|
||||
.chain(self.legacy_logs_db_path().map(|path| RuntimeDbPath {
|
||||
label: "legacy log DB",
|
||||
path,
|
||||
background_reclamation: true,
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) async fn open_state_db(
|
||||
&self,
|
||||
migrator: &Migrator,
|
||||
@@ -323,7 +361,7 @@ impl SqliteConfig {
|
||||
migrator: &Migrator,
|
||||
telemetry_override: Option<&dyn DbTelemetry>,
|
||||
) -> anyhow::Result<SqlitePool> {
|
||||
let path = spec.path(self.home());
|
||||
let path = spec.path(self);
|
||||
let started = Instant::now();
|
||||
let pool_result = self
|
||||
.open_read_write_pool_with_spec(path.as_path(), Some(spec), telemetry_override)
|
||||
@@ -363,6 +401,7 @@ impl SqliteConfig {
|
||||
}
|
||||
|
||||
/// Open a writable Codex SQLite database, creating it if necessary.
|
||||
/// Keep this configuration alive to retain the cleanup lock for local logs.
|
||||
pub async fn open_read_write_pool(&self, path: &Path) -> anyhow::Result<SqlitePool> {
|
||||
self.open_read_write_pool_with_spec(
|
||||
path, /*recover_spec*/ None, /*telemetry_override*/ None,
|
||||
@@ -376,6 +415,7 @@ impl SqliteConfig {
|
||||
recover_spec: Option<RuntimeDbSpec>,
|
||||
telemetry_override: Option<&dyn DbTelemetry>,
|
||||
) -> anyhow::Result<SqlitePool> {
|
||||
self.prepare_logs_directory(path).await?;
|
||||
let recovery = recover_spec.map_or(RecoveryMode::Unavailable, |spec| spec.recovery);
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
@@ -423,11 +463,13 @@ impl SqliteConfig {
|
||||
}
|
||||
|
||||
/// Open an existing Codex SQLite database without creating or modifying it.
|
||||
/// Keep this configuration alive to retain the cleanup lock for local logs.
|
||||
pub async fn open_read_only_pool(
|
||||
&self,
|
||||
path: &Path,
|
||||
busy_timeout: Option<Duration>,
|
||||
) -> Result<SqlitePool, Error> {
|
||||
self.prepare_logs_directory(path).await.map_err(Error::Io)?;
|
||||
let mut options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(false)
|
||||
@@ -441,6 +483,39 @@ impl SqliteConfig {
|
||||
.connect_with(options)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Open old logs for cleanup without creating, migrating, or rebuilding the database.
|
||||
pub(crate) async fn open_legacy_logs_for_cleanup(&self) -> anyhow::Result<Option<SqlitePool>> {
|
||||
let Some(path) = self.legacy_logs_db_path() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !tokio::fs::try_exists(&path).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(false)
|
||||
.busy_timeout(Duration::ZERO)
|
||||
.log_statements(LevelFilter::Off);
|
||||
SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
async fn prepare_logs_directory(&self, path: &Path) -> std::io::Result<()> {
|
||||
// Login, login status, and logout load configuration without opening SQLite.
|
||||
// Create and lock the logs directory only when opening its database.
|
||||
// An unavailable log_dir must not prevent those commands from loading configuration.
|
||||
if let Some(directory) = &self.local_logs_dir
|
||||
&& path == self.logs_db_path()
|
||||
{
|
||||
directory.initialize().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_once(options: &SqliteConnectOptions) -> Result<SqlitePool, Error> {
|
||||
@@ -527,6 +602,16 @@ async fn connect_once(options: &SqliteConnectOptions) -> Result<SqlitePool, Erro
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
async fn probe_nfs(home: &Path, probe: &OnceCell<bool>) -> anyhow::Result<bool> {
|
||||
probe
|
||||
.get_or_try_init(|| async {
|
||||
let home = home.to_path_buf();
|
||||
Ok(tokio::task::spawn_blocking(move || codex_file_system::is_nfs(&home)).await??)
|
||||
})
|
||||
.await
|
||||
.copied()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sqlite_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -54,7 +54,7 @@ async fn runtime_opens_recover_or_report_corruption_by_database_policy() -> anyh
|
||||
let home = root.join(spec.label);
|
||||
tokio::fs::create_dir_all(&home).await?;
|
||||
let sqlite = SqliteConfig::new_for_testing(home.as_path().abs());
|
||||
let path = spec.path(&home);
|
||||
let path = spec.path(&sqlite);
|
||||
let fixture = path.with_extension("fixture");
|
||||
let pool = sqlx::SqlitePool::connect_with(
|
||||
sqlx::sqlite::SqliteConnectOptions::new()
|
||||
|
||||
@@ -8,6 +8,89 @@ use sqlx::sqlite::SqliteConnectOptions;
|
||||
use sqlx::sqlite::SqliteJournalMode;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_log_directory_is_initialized_on_use_and_retries_after_failure()
|
||||
-> anyhow::Result<()> {
|
||||
let sqlite_home = crate::runtime::test_support::unique_temp_dir();
|
||||
tokio::fs::create_dir_all(&sqlite_home).await?;
|
||||
let _cleanup = scopeguard::guard(sqlite_home.clone(), |sqlite_home| {
|
||||
let _ = std::fs::remove_dir_all(sqlite_home);
|
||||
});
|
||||
let logs_home = sqlite_home.join("logs");
|
||||
tokio::fs::write(&logs_home, "not a directory").await?;
|
||||
let sqlite = SqliteConfig::new_for_testing(sqlite_home.as_path().abs());
|
||||
let fallback_logs_path = sqlite.logs_db_path();
|
||||
let sqlite = sqlite.with_local_logs_home(logs_home.clone());
|
||||
|
||||
// Opening unrelated state must not require the configured logs directory.
|
||||
let pool = sqlite.open_read_write_pool(&sqlite.state_db_path()).await?;
|
||||
pool.close().await;
|
||||
assert!(
|
||||
crate::StateRuntime::init(sqlite.clone(), "openai".to_string())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(!fallback_logs_path.exists());
|
||||
|
||||
// A failed initialization must remain retryable without replacing the config.
|
||||
tokio::fs::remove_file(&logs_home).await?;
|
||||
let runtime = crate::StateRuntime::init(sqlite.clone(), "openai".to_string()).await?;
|
||||
assert!(sqlite.logs_db_path().is_file());
|
||||
assert!(!fallback_logs_path.exists());
|
||||
runtime.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn configured_log_directory_lock_is_shared_by_reader_and_writer_config_clones()
|
||||
-> anyhow::Result<()> {
|
||||
let sqlite_home = crate::runtime::test_support::unique_temp_dir();
|
||||
let logs_home = sqlite_home.join("logs");
|
||||
tokio::fs::create_dir_all(&logs_home).await?;
|
||||
let _cleanup = scopeguard::guard(sqlite_home.clone(), |sqlite_home| {
|
||||
let _ = std::fs::remove_dir_all(sqlite_home);
|
||||
});
|
||||
let initial = SqliteConfig::new_for_testing(logs_home.as_path().abs());
|
||||
let logs_path = initial.logs_db_path();
|
||||
initial
|
||||
.open_read_write_pool(&logs_path)
|
||||
.await?
|
||||
.close()
|
||||
.await;
|
||||
|
||||
for read_only in [false, true] {
|
||||
let sqlite = SqliteConfig::new_for_testing(sqlite_home.as_path().abs())
|
||||
.with_local_logs_home(logs_home.clone());
|
||||
let clone = sqlite.clone();
|
||||
let cleanup_lock = std::fs::File::open(&logs_home)?;
|
||||
cleanup_lock.try_lock()?;
|
||||
cleanup_lock.unlock()?;
|
||||
|
||||
let pool = if read_only {
|
||||
sqlite
|
||||
.open_read_only_pool(&logs_path, /*busy_timeout*/ None)
|
||||
.await?
|
||||
} else {
|
||||
sqlite.open_read_write_pool(&logs_path).await?
|
||||
};
|
||||
assert!(matches!(
|
||||
cleanup_lock.try_lock(),
|
||||
Err(std::fs::TryLockError::WouldBlock)
|
||||
));
|
||||
drop(sqlite);
|
||||
assert!(matches!(
|
||||
cleanup_lock.try_lock(),
|
||||
Err(std::fs::TryLockError::WouldBlock)
|
||||
));
|
||||
|
||||
pool.close().await;
|
||||
drop(clone);
|
||||
cleanup_lock.try_lock()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn sqlite_home_filesystem_probe_is_shared_by_clones_until_config_replacement()
|
||||
|
||||
Reference in New Issue
Block a user