feat: observe retained worker sessions without restore

This commit is contained in:
2026-09-18 04:27:38 +09:00
parent ae7f0f0446
commit 7a61a26670
26 changed files with 834 additions and 124 deletions
+4
View File
@@ -57,6 +57,10 @@ pub use history::{
};
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use paste_artifact::PasteArtifactLimits;
pub use public_snapshot::{
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, RetainedSessionIdentity, RetainedSessionSnapshot,
RetainedSnapshotReadError, project_session_snapshot, read_retained_session_snapshot,
};
pub use segment::{
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
+170 -1
View File
@@ -7,11 +7,119 @@ use protocol::{
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
};
use std::path::Path;
use thiserror::Error;
use crate::{
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
LoggedSessionHistoryOrigin, SessionId, SystemItem,
LoggedSessionHistoryOrigin, SessionId, Store, StoreError, SystemItem, WorkerAggregateStore,
WorkerMetadataStore, WorkerSessionStore, WorkerStoreError,
};
/// Projection limit leaves one MiB for the typed API envelope so a public
/// retained-session response remains below the 16 MiB response ceiling.
pub const DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES: u64 = 15 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetainedSessionIdentity {
pub session_id: String,
pub segment_id: String,
pub entry_count: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RetainedSessionSnapshot {
pub identity: RetainedSessionIdentity,
pub snapshot: SessionSnapshot,
}
#[derive(Debug, Error)]
pub enum RetainedSnapshotReadError {
#[error("retained worker state is unavailable")]
RetentionMissing,
#[error("retained worker state has no active session pointer")]
ActivePointerMissing,
#[error("retained session requires migration")]
MigrationRequired,
#[error("retained session log is corrupt")]
CorruptLog,
#[error("retained session snapshot exceeds the observation limit")]
SnapshotTooLarge,
}
/// Read the active retained Session without creating, migrating, restoring, or
/// rewriting any persisted state.
pub fn read_retained_session_snapshot(
aggregate_root: &Path,
worker_name: &str,
max_bytes: u64,
) -> Result<RetainedSessionSnapshot, RetainedSnapshotReadError> {
let aggregate = WorkerAggregateStore::open_read_only(aggregate_root, worker_name)
.map_err(map_worker_store_error)?;
let metadata = aggregate
.read_by_name(worker_name)
.map_err(map_worker_store_error)?
.ok_or(RetainedSnapshotReadError::RetentionMissing)?;
let active = metadata
.active
.ok_or(RetainedSnapshotReadError::ActivePointerMissing)?;
let segment_id = active
.segment_id
.ok_or(RetainedSnapshotReadError::ActivePointerMissing)?;
let store = WorkerSessionStore::open_read_only(aggregate_root.join("session"))
.map_err(map_store_error)?;
if store.segment_log_len(segment_id).map_err(map_store_error)? > max_bytes {
return Err(RetainedSnapshotReadError::SnapshotTooLarge);
}
let entries = store
.read_all(active.session_id, segment_id)
.map_err(map_store_error)?;
let snapshot = project_session_snapshot(active.session_id, &entries);
if serde_json::to_vec(&snapshot)
.map_err(|_| RetainedSnapshotReadError::CorruptLog)?
.len() as u64
> max_bytes
{
return Err(RetainedSnapshotReadError::SnapshotTooLarge);
}
Ok(RetainedSessionSnapshot {
identity: RetainedSessionIdentity {
session_id: active.session_id.to_string(),
segment_id: segment_id.to_string(),
entry_count: entries.len() as u64,
},
snapshot,
})
}
fn map_worker_store_error(error: WorkerStoreError) -> RetainedSnapshotReadError {
match error {
WorkerStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
RetainedSnapshotReadError::RetentionMissing
}
WorkerStoreError::Serde(_) | WorkerStoreError::InvalidWorkerName(_) => {
RetainedSnapshotReadError::CorruptLog
}
WorkerStoreError::Io(_) => RetainedSnapshotReadError::CorruptLog,
}
}
fn map_store_error(error: StoreError) -> RetainedSnapshotReadError {
match error {
StoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
RetainedSnapshotReadError::RetentionMissing
}
StoreError::Corrupt { message, .. } if message.contains("requires migration") => {
RetainedSnapshotReadError::MigrationRequired
}
StoreError::Io(_) | StoreError::Serde(_) | StoreError::Corrupt { .. } => {
RetainedSnapshotReadError::CorruptLog
}
_ => RetainedSnapshotReadError::CorruptLog,
}
}
/// Project a complete current-segment log. A valid segment starts with one
/// canonical annotated SegmentStart record; malformed partial input uses the
/// nil session only to keep the public failure projection deterministic.
@@ -559,4 +667,65 @@ mod tests {
SessionEntryProvenance::ModelOutput
);
}
#[test]
fn retained_snapshot_read_projects_active_segment_without_rewriting_files() {
let root = tempfile::tempdir().unwrap();
let aggregate_root = root.path().join("worker-a");
let aggregate = WorkerAggregateStore::new(&aggregate_root, "worker-a").unwrap();
let session_id = crate::new_session_id();
let segment_id = crate::new_segment_id();
aggregate
.write(&crate::WorkerMetadata::new(
"worker-a",
Some(crate::WorkerActiveSegmentRef::active_segment(
session_id, segment_id,
)),
))
.unwrap();
let session = WorkerSessionStore::new(aggregate_root.join("session")).unwrap();
session.create_segment(session_id, segment_id, &[]).unwrap();
let metadata_before = std::fs::read(aggregate_root.join("metadata.json")).unwrap();
let manifest_before = std::fs::read(aggregate_root.join("session/session.json")).unwrap();
let retained = read_retained_session_snapshot(
&aggregate_root,
"worker-a",
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
)
.unwrap();
assert_eq!(retained.identity.session_id, session_id.to_string());
assert_eq!(retained.identity.segment_id, segment_id.to_string());
assert_eq!(retained.identity.entry_count, 0);
assert!(retained.snapshot.entries.is_empty());
assert_eq!(
std::fs::read(aggregate_root.join("metadata.json")).unwrap(),
metadata_before
);
assert_eq!(
std::fs::read(aggregate_root.join("session/session.json")).unwrap(),
manifest_before
);
assert!(matches!(
read_retained_session_snapshot(&aggregate_root, "worker-a", 0),
Err(RetainedSnapshotReadError::SnapshotTooLarge)
));
}
#[test]
fn retained_snapshot_read_does_not_create_missing_aggregate() {
let root = tempfile::tempdir().unwrap();
let aggregate = root.path().join("missing-worker");
let error = read_retained_session_snapshot(
&aggregate,
"missing-worker",
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
)
.unwrap_err();
assert!(matches!(error, RetainedSnapshotReadError::RetentionMissing));
assert!(!aggregate.exists());
}
}
@@ -383,6 +383,27 @@ impl WorkerAggregateStore {
})
}
/// Open an existing aggregate without creating or rewriting retained files.
pub fn open_read_only(
root: impl Into<PathBuf>,
worker_name: impl Into<String>,
) -> Result<Self, WorkerStoreError> {
let root = root.into();
let worker_name = worker_name.into();
validate_worker_name(&worker_name)?;
if !root.is_dir() {
return Err(WorkerStoreError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"worker aggregate is not retained",
)));
}
Ok(Self {
update_lock: metadata_lock(&root),
root,
worker_name,
})
}
fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
validate_worker_name(worker_name)?;
if worker_name == self.worker_name {
@@ -84,6 +84,32 @@ impl WorkerSessionStore {
})
}
/// Open an already-retained Session without creating directories or migrating
/// persisted data. Observation paths must never mutate retained state.
pub fn open_read_only(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
let root = root.into();
let bytes = fs::read(root.join(SESSION_FILE))?;
let manifest: SessionManifest = serde_json::from_slice(&bytes)?;
if manifest.schema_version != SESSION_SCHEMA_VERSION {
return Err(StoreError::Corrupt {
line: 0,
message: format!(
"Worker Session schema version {} requires migration; expected {}",
manifest.schema_version, SESSION_SCHEMA_VERSION
),
});
}
Ok(Self {
root,
session_id: Arc::new(Mutex::new(Some(manifest.session_id))),
append_lock: Arc::new(Mutex::new(())),
})
}
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
Ok(fs::metadata(self.log_path(segment_id))?.len())
}
pub fn root_dir(&self) -> &Path {
&self.root
}