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

@@ -0,0 +1,108 @@
use std::io::SeekFrom;
use std::path::Path;
use chrono::DateTime;
use codex_app_server_protocol::project_rollout_line;
use codex_protocol::ThreadId;
use codex_protocol::protocol::RolloutLine;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncSeekExt;
use tracing::warn;
use super::LocalThreadStore;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
pub(super) async fn materialize_to_sqlite(
store: &LocalThreadStore,
thread_id: ThreadId,
rollout_path: &Path,
start_offset: u64,
) -> ThreadStoreResult<()> {
let (lines, next_offset) = read_durable_rollout_lines(rollout_path, start_offset).await?;
if lines.is_empty() && start_offset == next_offset {
return Ok(());
}
let projections = lines
.iter()
.map(|line| {
let created_at_ms = DateTime::parse_from_rfc3339(line.timestamp.as_str())
.map(|timestamp| timestamp.timestamp_millis())
.map_err(thread_history_error)?;
Ok((line.ordinal, created_at_ms, project_rollout_line(line)))
})
.collect::<ThreadStoreResult<Vec<_>>>()?;
super::thread_history::apply_projection(
store,
thread_id,
start_offset,
next_offset,
projections,
)
.await
}
async fn read_durable_rollout_lines(
rollout_path: &Path,
start_offset: u64,
) -> ThreadStoreResult<(Vec<RolloutLine>, u64)> {
let next_offset = tokio::fs::metadata(rollout_path)
.await
.map_err(thread_store_io_error)?
.len();
let byte_count =
next_offset
.checked_sub(start_offset)
.ok_or_else(|| ThreadStoreError::Internal {
message: "durable rollout shrank before projection".to_string(),
})?;
let byte_count = usize::try_from(byte_count).map_err(|_| ThreadStoreError::Internal {
message: "durable rollout append exceeds addressable memory".to_string(),
})?;
let mut bytes = vec![0; byte_count];
let mut file = tokio::fs::File::open(rollout_path)
.await
.map_err(thread_store_io_error)?;
file.seek(SeekFrom::Start(start_offset))
.await
.map_err(thread_store_io_error)?;
file.read_exact(bytes.as_mut_slice())
.await
.map_err(thread_store_io_error)?;
if bytes.last().is_some_and(|byte| *byte != b'\n') {
return Err(ThreadStoreError::Internal {
message: "durable rollout append is not newline terminated".to_string(),
});
}
let text = std::str::from_utf8(bytes.as_slice()).map_err(thread_history_error)?;
let mut lines = Vec::new();
for line in text.lines().filter(|line| !line.is_empty()) {
match serde_json::from_str(line) {
Ok(line) => lines.push(line),
Err(err) => {
// A failed append can leave a partial record behind. The rollout writer repairs
// its newline before retrying, so skip rejected lines just like the canonical
// rollout loader and keep projecting the valid retry that follows.
warn!("skipping rejected rollout line while projecting {rollout_path:?}: {err}");
}
}
}
Ok((lines, next_offset))
}
fn thread_history_error(err: impl std::fmt::Display) -> ThreadStoreError {
ThreadStoreError::Internal {
message: format!("failed to project thread history: {err}"),
}
}
fn thread_store_io_error(err: std::io::Error) -> ThreadStoreError {
ThreadStoreError::Internal {
message: err.to_string(),
}
}
#[cfg(test)]
#[path = "thread_history_materialization_tests.rs"]
mod tests;