Read compressed rollouts and materialize before append (#25087)

## Why

Local rollout compression needs a cold `.jsonl.zst` representation
without letting compressed physical paths leak into append-mode writers.
The unsafe case is resume or metadata update code successfully reading a
compressed rollout and then appending raw JSONL bytes to the zstd file.

This PR folds the former #25088 materialization slice into the
read-support PR so the reader changes and append-safety invariant land
together.

## What Changed

- Teach rollout readers, discovery, listing, search, and ID lookup to
understand compressed `.jsonl.zst` rollouts.
- Keep `.jsonl` as the logical/stored rollout path while allowing read
paths to open either plain or compressed storage.
- Materialize compressed rollouts back to plain `.jsonl` before
append-mode writes, including resume and direct metadata append paths.
- Preserve compressed-file permissions when materializing back to plain
JSONL.
- Refresh thread-store resolved rollout paths after compatibility
metadata writes so reconciliation follows the materialized file.
- Avoid treating transient compression temp files as real rollout lookup
results.

## Remaining Stack

#25089 remains the separate worker PR. It is based directly on this PR
and stays behind the disabled `local_thread_store_compression` feature
flag.

The worker still has a broader coordination question: a resume or
metadata update can race with background compression while a plain file
is being replaced by `.jsonl.zst`. This PR handles the read and
materialize-before-append primitives; it does not make the worker
production-ready.

## Validation

- `just test -p codex-rollout`
- `just test -p codex-thread-store`
- `just fix -p codex-rollout`
- `just fix -p codex-thread-store`
- `just bazel-lock-check`
This commit is contained in:
jif-oai authored and GitHub committed 2026-06-01 15:14:19 +02:00
1 parent f27bbbd49c
commit a8a6071279
16 files changed
+924 -131

No files matched your search

+20 -11
View File
@@ -90,7 +90,7 @@ async fn sqlite_rollout_path_can_load_history_for_thread(
path: &std::path::Path,
thread_id: codex_protocol::ThreadId,
) -> bool {
if !tokio::fs::try_exists(path).await.unwrap_or(false) {
if codex_rollout::existing_rollout_path(path).await.is_none() {
return false;
}
// SQLite metadata can outlive a moved/recreated rollout path. When history is
@@ -107,7 +107,7 @@ pub(super) async fn read_thread_by_rollout_path(
include_archived: bool,
include_history: bool,
) -> ThreadStoreResult<StoredThread> {
let path = resolve_requested_rollout_path(store, rollout_path)?;
let path = resolve_requested_rollout_path(store, rollout_path).await?;
let mut thread = read_thread_from_rollout_path(store, path).await?;
if !include_archived && thread.archived_at.is_some() {
return Err(ThreadStoreError::InvalidRequest {
@@ -134,7 +134,7 @@ pub(super) async fn read_thread_by_rollout_path(
Ok(thread)
}
fn resolve_requested_rollout_path(
async fn resolve_requested_rollout_path(
store: &LocalThreadStore,
rollout_path: std::path::PathBuf,
) -> ThreadStoreResult<std::path::PathBuf> {
@@ -143,7 +143,15 @@ fn resolve_requested_rollout_path(
} else {
rollout_path
};
std::fs::canonicalize(&path).map_err(|err| ThreadStoreError::InvalidRequest {
let Some(path) = codex_rollout::existing_rollout_path(path.as_path()).await else {
return Err(ThreadStoreError::InvalidRequest {
message: format!(
"failed to resolve rollout path `{}`: file does not exist",
path.display()
),
});
};
std::fs::canonicalize(path.as_path()).map_err(|err| ThreadStoreError::InvalidRequest {
message: format!("failed to resolve rollout path `{}`: {err}", path.display()),
})
}
@@ -172,11 +180,9 @@ async fn resolve_rollout_path(
include_archived: bool,
) -> ThreadStoreResult<Option<std::path::PathBuf>> {
if let Ok(path) = live_writer::rollout_path(store, thread_id).await
&& tokio::fs::try_exists(path.as_path()).await.map_err(|err| {
ThreadStoreError::InvalidRequest {
message: format!("failed to check rollout path for thread id {thread_id}: {err}"),
}
})?
&& codex_rollout::existing_rollout_path(path.as_path())
.await
.is_some()
&& (include_archived || !rollout_path_is_archived(store.config.codex_home.as_path(), &path))
{
return Ok(Some(path));
@@ -233,6 +239,7 @@ async fn read_thread_from_rollout_path(
.ok_or_else(|| ThreadStoreError::Internal {
message: format!("failed to read thread id from {}", path.display()),
})?;
thread.rollout_path = Some(codex_rollout::plain_rollout_path(path.as_path()));
if let Ok(meta_line) = read_session_meta_line(path.as_path()).await {
thread.forked_from_id = meta_line.meta.forked_from_id;
thread.parent_thread_id = meta_line.meta.parent_thread_id;
@@ -287,6 +294,7 @@ async fn stored_thread_from_sqlite_metadata(
.await
.ok()
.map(|meta_line| meta_line.meta);
let rollout_path = codex_rollout::plain_rollout_path(metadata.rollout_path.as_path());
let forked_from_id = session_meta.as_ref().and_then(|meta| meta.forked_from_id);
let parent_thread_id = session_meta.as_ref().and_then(|meta| meta.parent_thread_id);
let preview = metadata
@@ -298,7 +306,7 @@ async fn stored_thread_from_sqlite_metadata(
permission_profile_from_metadata_value(&metadata.sandbox_policy, metadata.cwd.as_path());
StoredThread {
thread_id: metadata.id,
rollout_path: Some(metadata.rollout_path),
rollout_path: Some(rollout_path),
forked_from_id,
parent_thread_id,
preview,
@@ -360,9 +368,10 @@ fn stored_thread_from_meta_line(
.and_then(|meta| meta.modified().ok())
.map(DateTime::<Utc>::from)
.unwrap_or(created_at);
let rollout_path = codex_rollout::plain_rollout_path(path.as_path());
StoredThread {
thread_id: meta_line.meta.id,
rollout_path: Some(path),
rollout_path: Some(rollout_path),
forked_from_id: meta_line.meta.forked_from_id,
parent_thread_id: meta_line.meta.parent_thread_id,
preview: String::new(),