mirror of
https://github.com/openai/codex.git
synced 2026-11-13 22:47:20 +00:00
## 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
284 lines
10 KiB
Rust
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;
|