Materialize paginated thread history in SQLite (#32923)

## What changed

- Project durable paginated rollout records into rebuildable SQLite tables for turns, items, and projection progress while keeping JSONL as the source of truth.
- Add cursor-based local reads for turn summaries and thread or per-turn items, including archived-thread and history-mode validation.
- Serialize writes, shutdown, and deletion per thread so projection updates cannot race cleanup, and remove projected rows when a thread is deleted.
- Preserve stable item ordering while updating repeated snapshots and expose structured turn error details.

## Testing

- Cover projection, snapshot updates, summary recovery, pagination, failure handling, shutdown flushing, legacy-mode rejection, and deletion races.

GitOrigin-RevId: 34533185ece7f1f5690004c7f12b0f1f34dc2640
This commit is contained in:
Owen Lin authored and copyberry committed 2026-07-14 02:00:39 +00:00
1 parent f96cf4d1c3
commit 0ef9fa4d65
15 files changed
+2104 -88

No files matched your search

+53 -12
View File
@@ -7,6 +7,8 @@ mod live_writer;
mod model_context;
mod read_thread;
mod search_threads;
mod thread_history;
mod thread_history_materialization;
mod unarchive_thread;
mod update_thread_metadata;
@@ -22,12 +24,17 @@ use std::collections::hash_map::Entry;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::OnceCell;
use tokio::sync::OwnedMutexGuard;
use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
use crate::CreateThreadParams;
use crate::DeleteThreadParams;
use crate::ItemPage;
use crate::ListItemsParams;
use crate::ListThreadsParams;
use crate::ListTurnsParams;
use crate::LoadThreadHistoryParams;
use crate::ReadThreadByRolloutPathParams;
use crate::ReadThreadParams;
@@ -42,6 +49,7 @@ use crate::ThreadStore;
use crate::ThreadStoreError;
use crate::ThreadStoreFuture;
use crate::ThreadStoreResult;
use crate::TurnPage;
use crate::UpdateThreadMetadataParams;
/// Local filesystem/SQLite-backed implementation of [`ThreadStore`].
@@ -61,7 +69,9 @@ use crate::UpdateThreadMetadataParams;
pub struct LocalThreadStore {
pub(super) config: LocalThreadStoreConfig,
live_recorders: Arc<Mutex<HashMap<ThreadId, LiveRecorderEntry>>>,
live_writer_locks: Arc<LiveWriterLocks>,
state_db: Option<StateDbHandle>,
thread_history_db: Arc<OnceCell<sqlx::SqlitePool>>,
}
struct LiveRecorderEntry {
@@ -72,6 +82,26 @@ struct LiveRecorderEntry {
history_mode: ThreadHistoryMode,
}
#[derive(Default)]
struct LiveWriterLocks {
// Keep per-thread locks after a writer goes idle. Removing one while another caller is about
// to acquire it could let two operations for the same thread run at once.
by_thread: Mutex<HashMap<ThreadId, Arc<Mutex<()>>>>,
}
impl LiveWriterLocks {
async fn lock(&self, thread_id: ThreadId) -> OwnedMutexGuard<()> {
let lock = self
.by_thread
.lock()
.await
.entry(thread_id)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
lock.lock_owned().await
}
}
/// Process-scoped configuration for local thread storage.
///
/// This describes where local storage lives. New-thread rollout metadata such
@@ -108,7 +138,9 @@ impl LocalThreadStore {
Self {
config,
live_recorders: Arc::new(Mutex::new(HashMap::new())),
live_writer_locks: Arc::new(LiveWriterLocks::default()),
state_db,
thread_history_db: Arc::new(OnceCell::new()),
}
}
@@ -117,6 +149,17 @@ impl LocalThreadStore {
self.state_db.clone()
}
async fn thread_history_db(&self) -> ThreadStoreResult<&sqlx::SqlitePool> {
self.thread_history_db
.get_or_try_init(|| async {
codex_state::open_thread_history_db(self.config.sqlite_home.as_path()).await
})
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to open thread history database: {err}"),
})
}
/// Read a local rollout-backed thread by path.
pub async fn read_thread_by_rollout_path(
&self,
@@ -138,18 +181,6 @@ impl LocalThreadStore {
live_writer::rollout_path(self, thread_id).await
}
pub(super) async fn live_recorder(
&self,
thread_id: ThreadId,
) -> ThreadStoreResult<RolloutRecorder> {
self.live_recorders
.lock()
.await
.get(&thread_id)
.map(|entry| entry.recorder.clone())
.ok_or(ThreadStoreError::ThreadNotFound { thread_id })
}
pub(super) async fn ensure_live_recorder_absent(
&self,
thread_id: ThreadId,
@@ -237,6 +268,16 @@ impl LocalThreadStore {
)
.await
}
/// Lists projection-backed turns without enabling app-server routing yet.
pub async fn list_turns(&self, params: ListTurnsParams) -> ThreadStoreResult<TurnPage> {
thread_history::list_turns(self, params).await
}
/// Lists projection-backed items without enabling app-server routing yet.
pub async fn list_items(&self, params: ListItemsParams) -> ThreadStoreResult<ItemPage> {
thread_history::list_items(self, params).await
}
}
impl ThreadStore for LocalThreadStore {