feat(journal): execute frozen body inputs with durable delivery and erasure fences
This commit is contained in:
@@ -13,6 +13,14 @@
|
||||
//! observation share only this domain; they do not enable or modify each other's Features.
|
||||
//! The provider owns authorization to recapture a current Worker. A capture itself proves neither
|
||||
//! completion nor approval and grants no business-mutation authority.
|
||||
//!
|
||||
//! Recording Hosts use the common committed replay through the guarded APIs below.
|
||||
//! [`recording_catalog_worker_guarded`] and [`recording_catalog_fs_guarded`] expose
|
||||
//! descriptors only to an admission callback under the caller's existing publication/
|
||||
//! erasure guard and persisted active selector, without allocating a full body DTO.
|
||||
//! [`capture_recording_slice_worker_guarded`] and [`capture_recording_slice_fs_guarded`]
|
||||
//! separately construct bounded body-bearing inputs. Admission descriptors are not
|
||||
//! sufficient Job inputs; saved recording DTOs are read/sliced without live storage.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -21,12 +29,14 @@ use crate::{
|
||||
LoggedSessionHistoryOrigin as HistoryOrigin,
|
||||
};
|
||||
mod body;
|
||||
mod recording;
|
||||
use agen::{HistoryEntry, Item, Role};
|
||||
pub use body::*;
|
||||
use protocol::{
|
||||
SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot,
|
||||
SessionSnapshotEntryData,
|
||||
};
|
||||
pub use recording::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT: usize = 20;
|
||||
@@ -50,7 +60,8 @@ impl SessionEntryRef {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
let suffix = value.strip_prefix('E')?;
|
||||
if suffix.is_empty()
|
||||
|| suffix.len() > 64
|
||||
// Legacy Host boundary identities contain both Session and Segment UUIDs.
|
||||
|| suffix.len() > 256
|
||||
|| !suffix
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
@@ -822,7 +833,7 @@ impl SessionCapture {
|
||||
}
|
||||
}
|
||||
|
||||
fn public_provenance(origin: &HistoryOrigin) -> SessionEntryProvenance {
|
||||
pub(crate) fn public_provenance(origin: &HistoryOrigin) -> SessionEntryProvenance {
|
||||
match origin {
|
||||
HistoryOrigin::HumanInput { .. } | HistoryOrigin::LocalHumanInput => {
|
||||
SessionEntryProvenance::HumanInput
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use super::*;
|
||||
use serde::de::Error as _;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
|
||||
pub const CAPTURE_VERSION: u32 = 1;
|
||||
@@ -81,6 +81,17 @@ impl CaptureContentPart {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum CaptureBody {
|
||||
/// Canonical recording bytes retain the historical Journal descriptor contract.
|
||||
/// A nonzero offset or short text is an explicit fragment, never a full body.
|
||||
/// History entries retain their original raw segment-history row, including
|
||||
/// hidden rows in the count. A typed Host boundary has no history row: its
|
||||
/// `source.entry_index` is the next-history gap at the original boundary.
|
||||
CanonicalPublic {
|
||||
descriptor: crate::public_material::PublicMaterialDescriptor,
|
||||
byte_offset: u64,
|
||||
text: String,
|
||||
boundary: Option<crate::public_material::PublicRunBoundary>,
|
||||
},
|
||||
Message {
|
||||
role: CaptureRole,
|
||||
content: Vec<CaptureContentPart>,
|
||||
@@ -175,6 +186,7 @@ impl CaptureBody {
|
||||
|
||||
pub(super) fn to_item(&self) -> Item {
|
||||
match self {
|
||||
Self::CanonicalPublic { text, .. } => Item::system_message(text.clone()),
|
||||
Self::Message { role, content } => Item::Message {
|
||||
id: None,
|
||||
status: None,
|
||||
@@ -214,6 +226,7 @@ impl CaptureBody {
|
||||
|
||||
fn fields(&self) -> Vec<(RawField, &str)> {
|
||||
match self {
|
||||
Self::CanonicalPublic { text, .. } => vec![(RawField::CanonicalPublic, text)],
|
||||
Self::Message { content, .. } => content
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -235,6 +248,16 @@ impl CaptureBody {
|
||||
|
||||
fn metadata(&self, field: RawField) -> CaptureBodyMetadata {
|
||||
match self {
|
||||
Self::CanonicalPublic {
|
||||
descriptor,
|
||||
byte_offset,
|
||||
boundary,
|
||||
..
|
||||
} => CaptureBodyMetadata::CanonicalPublic {
|
||||
descriptor: descriptor.clone(),
|
||||
byte_offset: *byte_offset,
|
||||
boundary: *boundary,
|
||||
},
|
||||
Self::Message { role, content } => CaptureBodyMetadata::Message {
|
||||
role: *role,
|
||||
refusal: matches!(field, RawField::MessagePart(i) if matches!(content.get(i), Some(CaptureContentPart::Refusal { .. }))),
|
||||
@@ -264,6 +287,9 @@ impl CaptureBody {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CaptureSource {
|
||||
pub segment_id: String,
|
||||
/// Original raw segment-history position, not captured/public view order.
|
||||
/// For `CanonicalPublic` Host boundaries only, this is the next-history gap;
|
||||
/// several typed Host events may share that gap with the following history row.
|
||||
pub entry_index: u64,
|
||||
pub provenance: SessionEntryProvenance,
|
||||
/// Full original origin when supplied by the committed-history adapter.
|
||||
@@ -375,7 +401,7 @@ impl Write for ByteCounter {
|
||||
}
|
||||
}
|
||||
|
||||
fn json_size<T: Serialize>(value: &T, limit: usize) -> Result<usize, CaptureError> {
|
||||
pub(super) fn json_size<T: Serialize>(value: &T, limit: usize) -> Result<usize, CaptureError> {
|
||||
let mut counter = ByteCounter { bytes: 0, limit };
|
||||
serde_json::to_writer(&mut counter, value).map_err(|_| CaptureError::ResourceLimit)?;
|
||||
Ok(counter.bytes)
|
||||
@@ -403,6 +429,7 @@ impl CaptureDto {
|
||||
identity(&self.segment_id, limits)?;
|
||||
let mut ids = HashSet::new();
|
||||
let mut sources = HashSet::new();
|
||||
let mut recording_source_floor = HashMap::<&str, u64>::new();
|
||||
let mut previous = None;
|
||||
for entry in &self.entries {
|
||||
identity(&entry.entry_id, limits)?;
|
||||
@@ -419,7 +446,16 @@ impl CaptureDto {
|
||||
}
|
||||
previous = Some(entry.order);
|
||||
identity(&entry.source.segment_id, limits)?;
|
||||
if !sources.insert((&entry.source.segment_id, entry.source.entry_index)) {
|
||||
let host_boundary = matches!(
|
||||
&entry.body,
|
||||
CaptureBody::CanonicalPublic {
|
||||
boundary: Some(_),
|
||||
..
|
||||
}
|
||||
);
|
||||
if !host_boundary
|
||||
&& !sources.insert((&entry.source.segment_id, entry.source.entry_index))
|
||||
{
|
||||
return Err(CaptureError::Invalid("duplicate source position"));
|
||||
}
|
||||
if !entry.policy.any() {
|
||||
@@ -443,6 +479,79 @@ impl CaptureDto {
|
||||
}
|
||||
}
|
||||
match &entry.body {
|
||||
CaptureBody::CanonicalPublic {
|
||||
descriptor,
|
||||
byte_offset,
|
||||
text,
|
||||
boundary,
|
||||
} => {
|
||||
if descriptor.identity.entry_ref != format!("E{}", entry.entry_id)
|
||||
|| descriptor.identity.segment_id.to_string() != entry.source.segment_id
|
||||
|| descriptor.canonical_order != entry.order
|
||||
|| text.is_empty()
|
||||
|| descriptor.total_bytes == 0
|
||||
|| byte_offset
|
||||
.checked_add(text.len() as u64)
|
||||
.is_none_or(|end| end > descriptor.total_bytes)
|
||||
|| descriptor.sha256.len() != 64
|
||||
|| !descriptor
|
||||
.sha256
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
|| !entry.policy.recording_material
|
||||
|| entry.policy.memory_evidence
|
||||
|| entry.policy.observation_readable
|
||||
{
|
||||
return Err(CaptureError::Invalid("canonical descriptor"));
|
||||
}
|
||||
match (&descriptor.origin, &entry.source.history_origin, boundary) {
|
||||
(
|
||||
crate::public_material::PublicMaterialOrigin::History { origin },
|
||||
Some(stored),
|
||||
None,
|
||||
) if origin == stored => {}
|
||||
(
|
||||
crate::public_material::PublicMaterialOrigin::HostRunBoundary,
|
||||
None,
|
||||
Some(_),
|
||||
) => {}
|
||||
_ => return Err(CaptureError::Invalid("canonical origin")),
|
||||
}
|
||||
// A partial DTO cannot prove the hidden history extent, but can
|
||||
// reject aliases and reversals of original per-Segment positions.
|
||||
// Host events anchor a gap, not a fabricated history row.
|
||||
let floor = recording_source_floor
|
||||
.entry(&entry.source.segment_id)
|
||||
.or_default();
|
||||
if entry.source.entry_index < *floor {
|
||||
return Err(CaptureError::Invalid("recording source position order"));
|
||||
}
|
||||
*floor = if boundary.is_some() {
|
||||
entry.source.entry_index
|
||||
} else {
|
||||
entry
|
||||
.source
|
||||
.entry_index
|
||||
.checked_add(1)
|
||||
.ok_or(CaptureError::Invalid("source position overflow"))?
|
||||
};
|
||||
if *byte_offset == 0 && text.len() as u64 == descriptor.total_bytes {
|
||||
let digest: String = Sha256::digest(text.as_bytes())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
if digest != descriptor.sha256 {
|
||||
return Err(CaptureError::Invalid("canonical digest"));
|
||||
}
|
||||
if let Some(boundary) = boundary {
|
||||
let expected =
|
||||
serde_json::json!({"kind":"run_boundary", "boundary":boundary});
|
||||
if serde_json::from_str::<serde_json::Value>(text)? != expected {
|
||||
return Err(CaptureError::Invalid("run boundary body"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CaptureBody::Message { content, .. } if content.is_empty() => {
|
||||
return Err(CaptureError::Invalid("empty message parts"));
|
||||
}
|
||||
@@ -493,6 +602,7 @@ pub struct ScopedSessionEntryRef {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RawField {
|
||||
CanonicalPublic,
|
||||
MessagePart(usize),
|
||||
PublicSystem,
|
||||
ToolArguments,
|
||||
@@ -515,6 +625,11 @@ pub struct RawRead {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum CaptureBodyMetadata {
|
||||
CanonicalPublic {
|
||||
descriptor: crate::public_material::PublicMaterialDescriptor,
|
||||
byte_offset: u64,
|
||||
boundary: Option<crate::public_material::PublicRunBoundary>,
|
||||
},
|
||||
Message {
|
||||
role: CaptureRole,
|
||||
refusal: bool,
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
//! Host capture and pure canonical slicing. No Job state or live-reader fallback lives here.
|
||||
|
||||
use super::*;
|
||||
use crate::committed_replay::CommittedPublicReplay;
|
||||
use crate::public_material::{
|
||||
PublicMaterialDescriptor, PublicMaterialError, PublicMaterialLimits, PublicMaterialUse,
|
||||
};
|
||||
use crate::retention::SessionRetentionStore;
|
||||
use crate::{SegmentId, SessionId};
|
||||
use std::path::Path;
|
||||
|
||||
/// Continuation in canonical Journal order, not an observation/Memory view cursor.
|
||||
/// A Host must reconcile the descriptor at this position with its saved checkpoint.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RecordingPosition {
|
||||
pub canonical_order: u64,
|
||||
pub byte_offset: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RecordingCaptureSlice {
|
||||
pub capture: CaptureDto,
|
||||
pub next: Option<RecordingPosition>,
|
||||
}
|
||||
|
||||
/// Inspect recording admission descriptors without constructing a body-bearing DTO.
|
||||
///
|
||||
/// The Host must authorize the source and hold the **same** Session publication/erasure
|
||||
/// guard from before reading its persisted active pointer until this function returns
|
||||
/// (including the callback). Pass that pointer's Segment, not a filename/mtime-derived
|
||||
/// selector. This function verifies the guard and stored Session identities but does
|
||||
/// not authorize the caller or establish that the supplied guard fences this root.
|
||||
/// No lock is recursively acquired, no active pointer is reread, and no replay handle
|
||||
/// or body-read capability is exposed to `inspect`.
|
||||
///
|
||||
/// The common committed recording replay fixes per-file ends before decoding. It
|
||||
/// retains only descriptors/locations, decoding one record at a time and streaming
|
||||
/// canonical bodies into their digests. `replay_limits` bound record allocation,
|
||||
/// aggregate storage extent, entries and Segments; there is no full-capture text/JSON
|
||||
/// allocation or `CaptureLimits` ceiling. The callback receives only descriptors in
|
||||
/// canonical order and may return a Host-specific mapping (including a `Result`). It
|
||||
/// runs once on success and not at all on replay/identity/resource errors.
|
||||
///
|
||||
/// This catalog is for Host admission, **not** a descriptor-only Job input. Construct
|
||||
/// the Job's bounded body input with the guarded slice API at its own authorized
|
||||
/// boundary; after saving it, execute only that immutable value. Neither descriptors
|
||||
/// nor a completed callback authorize delayed adoption after erasure.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use std::path::Path;
|
||||
/// # use session_store::{SessionId, SegmentId};
|
||||
/// # use session_store::retention::SessionRetentionGuard;
|
||||
/// # use session_store::public_material::PublicMaterialError;
|
||||
/// # fn admit(root: &Path, session: SessionId, active: SegmentId,
|
||||
/// # guard: &SessionRetentionGuard) -> Result<Vec<u64>, PublicMaterialError> {
|
||||
/// // `active` was resolved from persisted Host metadata while holding `guard`.
|
||||
/// session_store::capture::recording_catalog_worker_guarded(
|
||||
/// root, session, active, Default::default(), guard,
|
||||
/// |descriptors| descriptors.iter().map(|d| d.total_bytes).collect(),
|
||||
/// )
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn recording_catalog_worker_guarded<T>(
|
||||
session_root: &Path,
|
||||
expected_session: SessionId,
|
||||
active_segment: SegmentId,
|
||||
replay_limits: PublicMaterialLimits,
|
||||
guard: &crate::retention::SessionRetentionGuard,
|
||||
inspect: impl FnOnce(&[PublicMaterialDescriptor]) -> T,
|
||||
) -> Result<T, PublicMaterialError> {
|
||||
if guard.session_id() != expected_session {
|
||||
return Err(PublicMaterialError::Inconsistent);
|
||||
}
|
||||
let replay = CommittedPublicReplay::open_worker_guarded(
|
||||
session_root,
|
||||
active_segment,
|
||||
replay_limits,
|
||||
None,
|
||||
PublicMaterialUse::Recording,
|
||||
)?;
|
||||
if replay.session_id() != expected_session {
|
||||
return Err(PublicMaterialError::Inconsistent);
|
||||
}
|
||||
Ok(inspect(&replay.into_recording_descriptors()))
|
||||
}
|
||||
|
||||
/// FsStore-layout counterpart of [`recording_catalog_worker_guarded`].
|
||||
///
|
||||
/// `sessions_root` is the directory containing per-Session directories. Invoke this
|
||||
/// inside the Host's `with_recording_boundary` callback, passing **its existing guard
|
||||
/// and persisted active Segment**. That same external retention control store must
|
||||
/// fence publishers, pointer changes, archive moves and erasure. Do not reacquire the
|
||||
/// guard or use a separately opened legacy public reader inside the boundary.
|
||||
/// All descriptor-only, allocation and callback guarantees of the Worker variant
|
||||
/// apply; this function does not construct an unbounded transient `CaptureDto`.
|
||||
pub fn recording_catalog_fs_guarded<T>(
|
||||
sessions_root: &Path,
|
||||
session: SessionId,
|
||||
active_segment: SegmentId,
|
||||
replay_limits: PublicMaterialLimits,
|
||||
guard: &crate::retention::SessionRetentionGuard,
|
||||
inspect: impl FnOnce(&[PublicMaterialDescriptor]) -> T,
|
||||
) -> Result<T, PublicMaterialError> {
|
||||
if guard.session_id() != session {
|
||||
return Err(PublicMaterialError::Inconsistent);
|
||||
}
|
||||
let replay = CommittedPublicReplay::open_fs_guarded(
|
||||
sessions_root,
|
||||
session,
|
||||
active_segment,
|
||||
replay_limits,
|
||||
)?;
|
||||
Ok(inspect(&replay.into_recording_descriptors()))
|
||||
}
|
||||
|
||||
/// Capture the selected original lineage, including typed Host run boundaries.
|
||||
/// The caller must hold the Session publication/erasure guard while selecting the
|
||||
/// active pointer and through this call. Runtime publishers use this same lock
|
||||
/// for appends and Segment creation. No lock is recursively acquired here.
|
||||
pub fn capture_recording_worker_guarded(
|
||||
session_root: &Path,
|
||||
expected_session: SessionId,
|
||||
active_segment: SegmentId,
|
||||
replay_limits: PublicMaterialLimits,
|
||||
capture_limits: CaptureLimits,
|
||||
guard: &crate::retention::SessionRetentionGuard,
|
||||
) -> Result<CaptureDto, PublicMaterialError> {
|
||||
if guard.session_id() != expected_session {
|
||||
return Err(PublicMaterialError::Inconsistent);
|
||||
}
|
||||
let replay = CommittedPublicReplay::open_worker_guarded(
|
||||
session_root,
|
||||
active_segment,
|
||||
replay_limits,
|
||||
None,
|
||||
PublicMaterialUse::Recording,
|
||||
)?;
|
||||
if replay.session_id() != expected_session {
|
||||
return Err(PublicMaterialError::Inconsistent);
|
||||
}
|
||||
replay.into_recording_dto(capture_limits)
|
||||
}
|
||||
|
||||
/// Standalone convenience entry; Hosts resolving a mutable active pointer should
|
||||
/// instead hold the fence while resolving it and call the guarded entry.
|
||||
pub fn capture_recording_worker(
|
||||
session_root: &Path,
|
||||
expected_session: SessionId,
|
||||
active_segment: SegmentId,
|
||||
retention: &SessionRetentionStore,
|
||||
replay_limits: PublicMaterialLimits,
|
||||
capture_limits: CaptureLimits,
|
||||
) -> Result<CaptureDto, PublicMaterialError> {
|
||||
let guard = retention
|
||||
.lock_live(expected_session)
|
||||
.map_err(|error| match error {
|
||||
crate::StoreError::SessionErased(_) => PublicMaterialError::Erased,
|
||||
_ => PublicMaterialError::Unavailable,
|
||||
})?;
|
||||
capture_recording_worker_guarded(
|
||||
session_root,
|
||||
expected_session,
|
||||
active_segment,
|
||||
replay_limits,
|
||||
capture_limits,
|
||||
&guard,
|
||||
)
|
||||
}
|
||||
|
||||
/// Bounded construction for one Job input: descriptor replay is bounded separately;
|
||||
/// only at most the requested body budget is materialized from the fixed files.
|
||||
pub fn capture_recording_slice_worker_guarded(
|
||||
session_root: &Path,
|
||||
expected_session: SessionId,
|
||||
active_segment: SegmentId,
|
||||
replay_limits: PublicMaterialLimits,
|
||||
start: RecordingPosition,
|
||||
max_entries: usize,
|
||||
max_serialized_bytes: usize,
|
||||
guard: &crate::retention::SessionRetentionGuard,
|
||||
) -> Result<RecordingCaptureSlice, PublicMaterialError> {
|
||||
if guard.session_id() != expected_session {
|
||||
return Err(PublicMaterialError::Inconsistent);
|
||||
}
|
||||
let replay = CommittedPublicReplay::open_worker_guarded(
|
||||
session_root,
|
||||
active_segment,
|
||||
replay_limits,
|
||||
None,
|
||||
PublicMaterialUse::Recording,
|
||||
)?;
|
||||
if replay.session_id() != expected_session {
|
||||
return Err(PublicMaterialError::Inconsistent);
|
||||
}
|
||||
replay.recording_slice(start, max_entries, max_serialized_bytes)
|
||||
}
|
||||
|
||||
/// The standalone FsStore layout uses the same committed replay and fixed values.
|
||||
pub fn capture_recording_fs_guarded(
|
||||
sessions_root: &Path,
|
||||
session: SessionId,
|
||||
active_segment: SegmentId,
|
||||
replay_limits: PublicMaterialLimits,
|
||||
capture_limits: CaptureLimits,
|
||||
guard: &crate::retention::SessionRetentionGuard,
|
||||
) -> Result<CaptureDto, PublicMaterialError> {
|
||||
if guard.session_id() != session {
|
||||
return Err(PublicMaterialError::Inconsistent);
|
||||
}
|
||||
CommittedPublicReplay::open_fs_guarded(sessions_root, session, active_segment, replay_limits)?
|
||||
.into_recording_dto(capture_limits)
|
||||
}
|
||||
|
||||
/// Bounded standalone capture; the caller holds its FsStore retention fence.
|
||||
pub fn capture_recording_slice_fs_guarded(
|
||||
sessions_root: &Path,
|
||||
session: SessionId,
|
||||
active_segment: SegmentId,
|
||||
replay_limits: PublicMaterialLimits,
|
||||
start: RecordingPosition,
|
||||
max_entries: usize,
|
||||
max_serialized_bytes: usize,
|
||||
guard: &crate::retention::SessionRetentionGuard,
|
||||
) -> Result<RecordingCaptureSlice, PublicMaterialError> {
|
||||
if guard.session_id() != session {
|
||||
return Err(PublicMaterialError::Inconsistent);
|
||||
}
|
||||
CommittedPublicReplay::open_fs_guarded(sessions_root, session, active_segment, replay_limits)?
|
||||
.recording_slice(start, max_entries, max_serialized_bytes)
|
||||
}
|
||||
|
||||
impl CaptureDto {
|
||||
/// Select the oldest canonical prefix fitting both budgets, including JSON
|
||||
/// escaping, metadata and continuation. Only immutable owned bytes are read.
|
||||
/// A later Job can recapture at `next`; never combine live pages into this value.
|
||||
pub fn recording_slice(
|
||||
&self,
|
||||
start: RecordingPosition,
|
||||
max_entries: usize,
|
||||
max_serialized_bytes: usize,
|
||||
) -> Result<RecordingCaptureSlice, CaptureError> {
|
||||
if max_entries == 0 {
|
||||
return Err(CaptureError::BudgetTooSmall);
|
||||
}
|
||||
if start.canonical_order > self.view_entry_count {
|
||||
return Err(CaptureError::InvalidRange);
|
||||
}
|
||||
let mut result = RecordingCaptureSlice {
|
||||
capture: CaptureDto {
|
||||
version: self.version,
|
||||
session_id: self.session_id.clone(),
|
||||
segment_id: self.segment_id.clone(),
|
||||
view_entry_count: self.view_entry_count,
|
||||
entries: Vec::new(),
|
||||
},
|
||||
next: (start.canonical_order < self.view_entry_count).then_some(start),
|
||||
};
|
||||
if body::json_size(&result, max_serialized_bytes).is_err() {
|
||||
return Err(CaptureError::BudgetTooSmall);
|
||||
}
|
||||
if start.canonical_order == self.view_entry_count {
|
||||
if start.byte_offset != 0 {
|
||||
return Err(CaptureError::InvalidRange);
|
||||
}
|
||||
result.capture.validate(CaptureLimits {
|
||||
max_bytes: max_serialized_bytes,
|
||||
max_text_bytes: max_serialized_bytes,
|
||||
max_entries,
|
||||
..Default::default()
|
||||
})?;
|
||||
return Ok(result);
|
||||
}
|
||||
let first = self
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| entry.order == start.canonical_order)
|
||||
.ok_or(CaptureError::InvalidRange)?;
|
||||
let mut expected_order = start.canonical_order;
|
||||
for entry in self.entries.iter().skip(first).take(max_entries) {
|
||||
if entry.order != expected_order {
|
||||
return Err(CaptureError::InvalidRange);
|
||||
}
|
||||
let CaptureBody::CanonicalPublic {
|
||||
descriptor,
|
||||
byte_offset,
|
||||
text,
|
||||
boundary,
|
||||
} = &entry.body
|
||||
else {
|
||||
return Err(CaptureError::Invalid("not a recording capture"));
|
||||
};
|
||||
if !entry.policy.recording_material {
|
||||
return Err(CaptureError::PolicyDenied);
|
||||
}
|
||||
let offset = if entry.order == start.canonical_order {
|
||||
start.byte_offset
|
||||
} else {
|
||||
*byte_offset
|
||||
};
|
||||
let local = offset
|
||||
.checked_sub(*byte_offset)
|
||||
.and_then(|o| usize::try_from(o).ok())
|
||||
.ok_or(CaptureError::InvalidRange)?;
|
||||
if local > text.len()
|
||||
|| !text.is_char_boundary(local)
|
||||
|| offset >= descriptor.total_bytes
|
||||
{
|
||||
return Err(CaptureError::InvalidRange);
|
||||
}
|
||||
let item = CaptureEntryDto {
|
||||
entry_id: entry.entry_id.clone(),
|
||||
order: entry.order,
|
||||
source: entry.source.clone(),
|
||||
policy: entry.policy,
|
||||
body: CaptureBody::CanonicalPublic {
|
||||
descriptor: descriptor.clone(),
|
||||
byte_offset: offset,
|
||||
text: String::new(),
|
||||
boundary: *boundary,
|
||||
},
|
||||
};
|
||||
result.capture.entries.push(item);
|
||||
// At most budget bytes are considered before allocating a trial fragment.
|
||||
let mut low = 0;
|
||||
let mut high = text.len().saturating_sub(local).min(max_serialized_bytes);
|
||||
let prior_next = result.next;
|
||||
while low < high {
|
||||
let mid = low + (high - low).div_ceil(2);
|
||||
let mut end = local + mid;
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
if let CaptureBody::CanonicalPublic { text: fragment, .. } =
|
||||
&mut result.capture.entries.last_mut().unwrap().body
|
||||
{
|
||||
*fragment = text[local..end].to_owned();
|
||||
}
|
||||
let next_offset = offset + (end - local) as u64;
|
||||
result.next = if next_offset < descriptor.total_bytes {
|
||||
Some(RecordingPosition {
|
||||
canonical_order: entry.order,
|
||||
byte_offset: next_offset,
|
||||
})
|
||||
} else {
|
||||
(entry.order + 1 < self.view_entry_count).then_some(RecordingPosition {
|
||||
canonical_order: entry.order + 1,
|
||||
byte_offset: 0,
|
||||
})
|
||||
};
|
||||
if body::json_size(&result, max_serialized_bytes).is_ok() {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
let mut end = local + low;
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
if end == local {
|
||||
result.capture.entries.pop();
|
||||
result.next = prior_next;
|
||||
if result.capture.entries.is_empty() {
|
||||
return Err(CaptureError::BudgetTooSmall);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let CaptureBody::CanonicalPublic { text: fragment, .. } =
|
||||
&mut result.capture.entries.last_mut().unwrap().body
|
||||
{
|
||||
*fragment = text[local..end].to_owned();
|
||||
}
|
||||
let next_offset = offset + (end - local) as u64;
|
||||
result.next = if next_offset < descriptor.total_bytes {
|
||||
Some(RecordingPosition {
|
||||
canonical_order: entry.order,
|
||||
byte_offset: next_offset,
|
||||
})
|
||||
} else {
|
||||
(entry.order + 1 < self.view_entry_count).then_some(RecordingPosition {
|
||||
canonical_order: entry.order + 1,
|
||||
byte_offset: 0,
|
||||
})
|
||||
};
|
||||
if next_offset < descriptor.total_bytes {
|
||||
break;
|
||||
}
|
||||
expected_order = entry.order + 1;
|
||||
}
|
||||
body::json_size(&result, max_serialized_bytes).map_err(|_| CaptureError::BudgetTooSmall)?;
|
||||
result.capture.validate(CaptureLimits {
|
||||
max_bytes: max_serialized_bytes,
|
||||
max_text_bytes: max_serialized_bytes,
|
||||
max_entries,
|
||||
..Default::default()
|
||||
})?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Resolve canonical text already in the value. No original storage is consulted.
|
||||
pub fn recording_text(
|
||||
&self,
|
||||
order: u64,
|
||||
) -> Result<(&crate::public_material::PublicMaterialDescriptor, u64, &str), CaptureError> {
|
||||
let entry = self
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.order == order)
|
||||
.ok_or(CaptureError::UnknownReference)?;
|
||||
if !entry.policy.recording_material {
|
||||
return Err(CaptureError::PolicyDenied);
|
||||
}
|
||||
match &entry.body {
|
||||
CaptureBody::CanonicalPublic {
|
||||
descriptor,
|
||||
byte_offset,
|
||||
text,
|
||||
..
|
||||
} => Ok((descriptor, *byte_offset, text)),
|
||||
_ => Err(CaptureError::Invalid("not canonical recording text")),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ pub(crate) fn read_without_atime_bounded(path: &Path, max_bytes: u64) -> io::Res
|
||||
}
|
||||
|
||||
pub mod capture;
|
||||
mod committed_replay;
|
||||
pub mod event_trace;
|
||||
pub mod fs_store;
|
||||
pub mod history;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,13 @@ pub struct SessionRetentionStore {
|
||||
/// Do not recursively acquire the same Session lock inside a guarded callback.
|
||||
pub struct SessionRetentionGuard {
|
||||
_file: File,
|
||||
session_id: SessionId,
|
||||
}
|
||||
|
||||
impl SessionRetentionGuard {
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.session_id
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionRetentionStore {
|
||||
@@ -71,7 +78,10 @@ impl SessionRetentionStore {
|
||||
}
|
||||
let file = options.open(self.root.join(format!("{session}.lock")))?;
|
||||
FileExt::lock_exclusive(&file)?;
|
||||
Ok(SessionRetentionGuard { _file: file })
|
||||
Ok(SessionRetentionGuard {
|
||||
_file: file,
|
||||
session_id: session,
|
||||
})
|
||||
}
|
||||
|
||||
fn read(&self, session: SessionId) -> Result<RetentionState, StoreError> {
|
||||
|
||||
Reference in New Issue
Block a user