Files
codex/codex-rs/thread-store/src/local/rollout_lineage.rs
T
jif 1cc81ca89a Support compression for shared rollout lineages (#41357)
## Why

Rollout compression skips files that participate in paginated fork lineages because lineage readers rely on byte offsets into the original JSONL. This prevents shared rollout history from being compressed.

## What changed

- Add a seekable rollout reader that preserves logical JSONL offsets for plain and zstd-compressed files without materializing compressed data beside the rollout.
- Use the reader for lineage validation, model-context replay, and SQLite history projection, including when compression races with a read.
- Add the opt-in `local_thread_store_shared_compression` feature. The default mode continues to leave shared lineages uncompressed for compatibility, while the new mode includes referenced sources and fork pointers.
- Record the uncompressed size in new zstd frames so common prefix-bound checks can avoid decoding the full file.

## Testing

Add coverage for seekable compressed reads, prefix validation across zstd frame shapes, compressed lineage replay and projection, concurrent forks, and compression-mode behavior.

GitOrigin-RevId: 83e519f7eabf7448977eff37c55899858cc17a07
2026-08-28 16:04:26 +00:00

284 lines
10 KiB
Rust

use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HistoryPosition;
use codex_protocol::protocol::ThreadHistoryMode;
use super::LocalThreadStore;
use super::thread_rollout_resolver;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
/// One immutable rollout range contributing to a paginated thread's history.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct RolloutLineageSegment {
pub(super) rollout_id: ThreadId,
pub(super) rollout_path: PathBuf,
pub(super) start_ordinal: u64,
pub(super) end: Option<HistoryPosition>,
}
/// Ordered rollout ranges contributing to one forked history.
///
/// This is the only local abstraction that follows SessionMeta.history_base pointers. Readers
/// consume its bounded rollout segments without resolving or mutating fork pointers themselves.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct RolloutLineage {
pub(super) segments: Vec<RolloutLineageSegment>,
}
impl LocalThreadStore {
pub(super) async fn resolve_rollout_lineage(
&self,
requested_thread_id: ThreadId,
) -> ThreadStoreResult<RolloutLineage> {
self.resolve_rollout_lineage_with_representation(
requested_thread_id,
LineageRepresentation::Existing,
)
.await
}
pub(super) async fn resolve_rollout_lineage_for_reference(
&self,
requested_thread_id: ThreadId,
) -> ThreadStoreResult<RolloutLineage> {
self.resolve_rollout_lineage_with_representation(
requested_thread_id,
LineageRepresentation::PlainForReference,
)
.await
}
async fn resolve_rollout_lineage_with_representation(
&self,
requested_thread_id: ThreadId,
representation: LineageRepresentation,
) -> ThreadStoreResult<RolloutLineage> {
let mut segments = Vec::new();
let mut seen = HashSet::new();
let mut next_rollout_id = None;
let mut end = None;
loop {
let coordination_id = next_rollout_id.unwrap_or(requested_thread_id);
let _writer_guard = match representation {
LineageRepresentation::Existing => None,
LineageRepresentation::PlainForReference => {
Some(self.live_writer_locks.lock(coordination_id).await)
}
};
let (rollout_id, rollout_path) = match next_rollout_id {
Some(rollout_id) => {
let rollout_path = resolve_rollout_path_by_id(self, rollout_id)
.await?
.ok_or_else(|| malformed_lineage(rollout_id, "missing source rollout"))?;
(rollout_id, rollout_path)
}
None => {
let resolved = thread_rollout_resolver::resolve_current_including_archived(
self,
requested_thread_id,
)
.await?
.ok_or_else(|| {
malformed_lineage(requested_thread_id, "missing source rollout")
})?;
(resolved.rollout_id, resolved.path)
}
};
if !seen.insert(rollout_id) {
return Err(malformed_lineage(requested_thread_id, "cycle detected"));
}
let rollout_path = match representation {
LineageRepresentation::Existing => rollout_path,
LineageRepresentation::PlainForReference => super::helpers::scoped_rollout_path(
self.config.codex_home.clone(),
rollout_path.as_path(),
"Codex home",
)?,
};
let meta = codex_rollout::read_session_meta_line(rollout_path.as_path())
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!(
"failed to read lineage metadata {}: {err}",
rollout_path.display()
),
})?;
if next_rollout_id.is_none() && meta.meta.id != requested_thread_id {
return Err(malformed_lineage(
requested_thread_id,
"source rollout belongs to another thread",
));
}
if meta.meta.history_mode != ThreadHistoryMode::Paginated {
return Err(malformed_lineage(
requested_thread_id,
"source rollout is not paginated",
));
}
let rollout_path = match representation {
LineageRepresentation::Existing => rollout_path,
LineageRepresentation::PlainForReference
if next_rollout_id.is_none() && meta.meta.history_base.is_none() =>
{
// A newly shared standalone source must remain readable by older binaries.
codex_rollout::materialize_rollout_for_reference(rollout_path.as_path())
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!(
"failed to materialize referenced rollout {}: {err}",
rollout_path.display()
),
})?
}
// Already-shared compressed history requires a compatible reader regardless of
// new forks. Read it without publishing decoded copies into ancestors' folders;
// their owners may concurrently archive or unarchive those immutable files.
LineageRepresentation::PlainForReference => rollout_path,
};
if let Some(end) = end {
validate_cutoff_bounds(requested_thread_id, rollout_path.as_path(), &end).await?;
}
let start_ordinal = match meta.meta.history_base {
Some(base) => base.end_ordinal_exclusive.checked_add(1).ok_or_else(|| {
malformed_lineage(requested_thread_id, "source ordinal overflow")
})?,
None => 1,
};
segments.push(RolloutLineageSegment {
rollout_id,
rollout_path,
start_ordinal,
end,
});
let Some(base) = meta.meta.history_base else {
break;
};
next_rollout_id = Some(base.thread_id);
end = Some(base);
}
segments.reverse();
Ok(RolloutLineage { segments })
}
}
async fn resolve_rollout_path_by_id(
store: &LocalThreadStore,
rollout_id: ThreadId,
) -> ThreadStoreResult<Option<PathBuf>> {
codex_rollout::find_rollout_path_by_rollout_id(store.config.codex_home.as_path(), rollout_id)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to locate rollout {rollout_id}: {err}"),
})
}
#[derive(Clone, Copy)]
enum LineageRepresentation {
Existing,
PlainForReference,
}
impl RolloutLineage {
pub(super) fn segments(&self) -> &[RolloutLineageSegment] {
self.segments.as_slice()
}
pub(super) fn segment_index_for_ordinal(&self, ordinal: u64) -> Option<usize> {
self.segments.iter().position(|segment| {
ordinal >= segment.start_ordinal()
&& segment
.end_ordinal()
.is_none_or(|end_ordinal| ordinal < end_ordinal)
})
}
pub(super) async fn truncate_at(
mut self,
end: HistoryPosition,
) -> ThreadStoreResult<RolloutLineage> {
let segment_index = self
.segments
.iter()
.position(|segment| segment.rollout_id == end.thread_id)
.ok_or_else(|| ThreadStoreError::Internal {
message: "fork position is outside the source lineage".to_string(),
})?;
self.segments.truncate(segment_index + 1);
let segment = self
.segments
.last_mut()
.ok_or_else(|| ThreadStoreError::Internal {
message: "rollout lineage has no segments".to_string(),
})?;
validate_cutoff_bounds(end.thread_id, segment.rollout_path.as_path(), &end).await?;
segment.end = Some(end);
Ok(self)
}
}
impl RolloutLineageSegment {
pub(super) fn rollout_id(&self) -> ThreadId {
self.rollout_id
}
pub(super) fn start_ordinal(&self) -> u64 {
self.start_ordinal
}
pub(super) fn end_ordinal(&self) -> Option<u64> {
self.end.map(|end| end.end_ordinal_exclusive)
}
}
async fn validate_cutoff_bounds(
requested_thread_id: ThreadId,
rollout_path: &Path,
end: &HistoryPosition,
) -> ThreadStoreResult<()> {
if end.end_ordinal_exclusive == 0 {
return Err(malformed_lineage(
requested_thread_id,
"cutoff cannot include source session metadata",
));
}
let path = rollout_path.to_path_buf();
let end_byte_offset = end.end_byte_offset;
let contains_prefix = tokio::task::spawn_blocking(move || {
codex_rollout::rollout_contains_prefix(&path, end_byte_offset)
})
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to join rollout prefix validation: {err}"),
})?
.map_err(|err| ThreadStoreError::Internal {
message: format!(
"failed to read lineage metadata {}: {err}",
rollout_path.display()
),
})?;
if !contains_prefix {
return Err(malformed_lineage(
requested_thread_id,
"cutoff byte offset is past the source rollout",
));
}
Ok(())
}
fn malformed_lineage(thread_id: ThreadId, detail: &str) -> ThreadStoreError {
ThreadStoreError::InvalidRequest {
message: format!("invalid paginated history lineage for {thread_id}: {detail}"),
}
}
#[cfg(test)]
#[path = "rollout_lineage_tests.rs"]
mod tests;