From a5a7f30398b74f8f88237f2afca65be37184335e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:56:12 +0000 Subject: [PATCH 01/11] chore: record implementation plan From e19be430868b0d61a371bace289b5bc15167b69a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:31:53 +0000 Subject: [PATCH 02/11] simplify(storage-report): bound metadata sampling Co-authored-by: ScriptedAlchemy <25274700+ScriptedAlchemy@users.noreply.github.com> --- .../src/commands/profile_storage.rs | 151 ++- .../src/code_index_generations.rs | 4 +- .../src/retention/footprint.rs | 162 ++- .../src/retention/orphan_stores/pages.rs | 16 +- .../src/retention/storage_report.rs | 1066 +++++++++++++++-- .../tracedecay-mcp/src/handlers/admin_cli.rs | 12 +- 6 files changed, 1260 insertions(+), 151 deletions(-) diff --git a/crates/tracedecay-cli/src/commands/profile_storage.rs b/crates/tracedecay-cli/src/commands/profile_storage.rs index ddb56a503b..15b4eba671 100644 --- a/crates/tracedecay-cli/src/commands/profile_storage.rs +++ b/crates/tracedecay-cli/src/commands/profile_storage.rs @@ -314,6 +314,10 @@ fn merge_storage_report_page( report: &mut tracedecay_maintenance::retention::storage_report::StorageReport, page: tracedecay_maintenance::retention::storage_report::StorageReport, ) { + let unregistered_size = report + .coverage + .unregistered_size + .merge(page.coverage.unregistered_size); if report.profile_root.is_empty() { report.profile_root = page.profile_root; } @@ -335,6 +339,7 @@ fn merge_storage_report_page( .user_sessions_db_bytes .max(page.user_sessions_db_bytes); report.coverage = page.coverage; + report.coverage.unregistered_size = unregistered_size; } /// Read-only per-store size / free-page-ratio / unregistered-directory report @@ -455,11 +460,12 @@ async fn handle_storage_report( let root_state = store .root_state .map_or("root not probed", |state| state.label()); + let size_coverage = effective_store_size_coverage(store); println!( - " {} ({}, {root_state}): {} total, {free}", + " {} ({}, {root_state}): {}, {free}", store.project_id, store.canonical_root, - format_bytes(store.total_bytes), + storage_size_label(store.total_bytes, size_coverage), ); let kinds = &store.kinds; for (family, bytes) in [ @@ -480,6 +486,17 @@ async fn handle_storage_report( store.unavailable_entry_count ); } + if size_coverage.state + != tracedecay_maintenance::retention::storage_report::StorageReportSizeStateV1::Complete + { + println!( + " sizing: {} ({})", + storage_coverage_state_label(size_coverage.state), + size_coverage + .reason + .map_or("unspecified", storage_coverage_reason_label) + ); + } } for retention in &report.code_generation_retention { println!( @@ -536,11 +553,31 @@ async fn handle_storage_report( println!( " unregistered directories: {} ({})", report.unregistered_dir_count, - format_bytes(report.unregistered_bytes) + storage_size_label(report.unregistered_bytes, report.coverage.unregistered_size) ); + if report.coverage.unregistered_size.state + != tracedecay_maintenance::retention::storage_report::StorageReportSizeStateV1::Complete + { + println!( + " sizing: {} ({})", + storage_coverage_state_label(report.coverage.unregistered_size.state), + report + .coverage + .unregistered_size + .reason + .map_or("unspecified", storage_coverage_reason_label) + ); + } + if report.coverage.unregistered_size.unavailable_entry_count > 0 { + println!( + " not sized: {} unreadable or non-regular entries", + report.coverage.unregistered_size.unavailable_entry_count + ); + } if report.unregistered_dir_count > 0 { println!(" run the daemon's automatic sweep to reclaim unregistered directories"); } + if let Some(footprint) = &report.footprint { for line in footprint_lines(footprint) { println!("{line}"); @@ -561,6 +598,69 @@ async fn handle_storage_report( Ok(()) } +fn storage_size_label( + bytes: u64, + coverage: tracedecay_maintenance::retention::storage_report::StorageReportSizeCoverageV1, +) -> String { + use tracedecay_maintenance::retention::storage_report::StorageReportSizeStateV1 as State; + + match coverage.state { + State::Complete => format!("{} total", format_bytes(bytes)), + State::Partial => format!("at least {} (partial)", format_bytes(bytes)), + State::Unavailable => format!("size unavailable ({} measured)", format_bytes(bytes)), + } +} + +fn effective_store_size_coverage( + store: &tracedecay_maintenance::retention::storage_report::StoreSizeReportEntry, +) -> tracedecay_maintenance::retention::storage_report::StorageReportSizeCoverageV1 { + use tracedecay_maintenance::retention::storage_report::{ + StorageReportSizeCoverageV1, StorageReportSizeReasonV1, StorageReportSizeStateV1 as State, + }; + + let mut coverage = store.size_coverage; + coverage.unavailable_entry_count = coverage + .unavailable_entry_count + .max(store.unavailable_entry_count); + if coverage.state == State::Complete && store.unavailable_entry_count > 0 { + coverage.state = if store.total_bytes == 0 { + State::Unavailable + } else { + State::Partial + }; + coverage.reason = Some(StorageReportSizeReasonV1::UnreadableEntries); + } + if coverage.state == State::Complete && coverage.unavailable_entry_count == 0 { + return StorageReportSizeCoverageV1::default(); + } + coverage +} + +fn storage_coverage_state_label( + state: tracedecay_maintenance::retention::storage_report::StorageReportSizeStateV1, +) -> &'static str { + use tracedecay_maintenance::retention::storage_report::StorageReportSizeStateV1 as State; + + match state { + State::Complete => "complete", + State::Partial => "partial lower bound", + State::Unavailable => "unavailable", + } +} + +fn storage_coverage_reason_label( + reason: tracedecay_maintenance::retention::storage_report::StorageReportSizeReasonV1, +) -> &'static str { + use tracedecay_maintenance::retention::storage_report::StorageReportSizeReasonV1 as Reason; + + match reason { + Reason::UnreadableEntries => "unreadable entries", + Reason::EntryBudgetExceeded => "entry budget exceeded", + Reason::Cancelled => "cancelled", + Reason::DeadlineExceeded => "deadline exceeded", + } +} + /// The footprint section: every entry against its bound, largest first, then /// the profile total. fn footprint_lines( @@ -641,6 +741,51 @@ mod footprint_output_tests { } } +#[cfg(test)] +mod storage_coverage_output_tests { + use tracedecay_maintenance::retention::storage_report::{ + StorageReportSizeCoverageV1, StorageReportSizeReasonV1, StorageReportSizeStateV1, + }; + + use super::storage_size_label; + + #[test] + fn partial_sizes_render_as_lower_bounds_not_exact_totals() { + let label = storage_size_label( + 0, + StorageReportSizeCoverageV1 { + state: StorageReportSizeStateV1::Partial, + reason: Some(StorageReportSizeReasonV1::EntryBudgetExceeded), + unavailable_entry_count: 0, + }, + ); + + assert!(label.starts_with("at least ")); + assert!(label.ends_with("(partial)")); + assert!(!label.contains("total")); + } + + #[test] + fn page_aggregation_preserves_a_budget_reason_and_unavailable_count() { + let merged = StorageReportSizeCoverageV1 { + state: StorageReportSizeStateV1::Partial, + reason: Some(StorageReportSizeReasonV1::UnreadableEntries), + unavailable_entry_count: 1, + } + .merge(StorageReportSizeCoverageV1 { + state: StorageReportSizeStateV1::Partial, + reason: Some(StorageReportSizeReasonV1::EntryBudgetExceeded), + unavailable_entry_count: 0, + }); + + assert_eq!( + merged.reason, + Some(StorageReportSizeReasonV1::EntryBudgetExceeded) + ); + assert_eq!(merged.unavailable_entry_count, 1); + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod reset_project_store_tests { diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations.rs b/crates/tracedecay-code-index-retention/src/code_index_generations.rs index 0d7d67fb0e..8f2c18cd80 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -1005,8 +1005,10 @@ pub fn prepare_next_code_generation_retention_cancellable( ) } +/// Build a read-only retention plan while observing the caller's cancellation +/// authority between directory entries and bounded digest reads. #[tracing::instrument(name = "usecases.retention.plan", level = "trace", skip_all)] -fn plan_code_generation_retention_with_verification_cancellable( +pub fn plan_code_generation_retention_with_verification_cancellable( store_root: &Path, vector_readable_sources: &BTreeSet, verification: GenerationDigestVerificationV1, diff --git a/crates/tracedecay-maintenance/src/retention/footprint.rs b/crates/tracedecay-maintenance/src/retention/footprint.rs index 457bda12f1..eab28da351 100644 --- a/crates/tracedecay-maintenance/src/retention/footprint.rs +++ b/crates/tracedecay-maintenance/src/retention/footprint.rs @@ -185,6 +185,20 @@ pub enum WalkedEntryV1 { Unavailable, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalkInterruptionV1 { + Cancelled, + DeadlineExceeded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalkTerminationV1 { + Complete, + EntryBudgetExceeded, + Cancelled, + DeadlineExceeded, +} + /// Visit every regular file under `root` with its byte length, without /// following symlinks or reading contents. Stops after `entry_ceiling` /// directory entries and returns whether it did, which makes any total built @@ -194,18 +208,44 @@ pub fn walk_regular_files( entry_ceiling: usize, visit: &mut dyn FnMut(&Path, WalkedEntryV1), ) -> bool { - let mut visited = 0usize; + let mut remaining_entries = entry_ceiling; + walk_regular_files_with_budget(root, &mut remaining_entries, &|| None, visit) + == WalkTerminationV1::EntryBudgetExceeded +} + +/// Walk one subtree using a caller-owned entry budget. Sharing +/// `remaining_entries` across roots bounds the whole request rather than +/// restarting the limit for each project. The interruption callback is checked +/// on the blocking worker before every directory entry is inspected. +pub fn walk_regular_files_with_budget( + root: &Path, + remaining_entries: &mut usize, + interruption: &dyn Fn() -> Option, + visit: &mut dyn FnMut(&Path, WalkedEntryV1), +) -> WalkTerminationV1 { let mut pending = vec![root.to_path_buf()]; while let Some(directory) = pending.pop() { + if let Some(interruption) = interruption() { + return match interruption { + WalkInterruptionV1::Cancelled => WalkTerminationV1::Cancelled, + WalkInterruptionV1::DeadlineExceeded => WalkTerminationV1::DeadlineExceeded, + }; + } let Ok(entries) = std::fs::read_dir(&directory) else { visit(&directory, WalkedEntryV1::Unavailable); continue; }; for entry in entries { - if visited >= entry_ceiling { - return true; + if let Some(interruption) = interruption() { + return match interruption { + WalkInterruptionV1::Cancelled => WalkTerminationV1::Cancelled, + WalkInterruptionV1::DeadlineExceeded => WalkTerminationV1::DeadlineExceeded, + }; } - visited += 1; + if *remaining_entries == 0 { + return WalkTerminationV1::EntryBudgetExceeded; + } + *remaining_entries -= 1; let Ok(entry) = entry else { visit(&directory, WalkedEntryV1::Unavailable); continue; @@ -223,21 +263,21 @@ pub fn walk_regular_files( if file_type.is_dir() { pending.push(path); } else if file_type.is_file() { - match entry.metadata() { - Ok(metadata) => visit( + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.is_file() => visit( &path, WalkedEntryV1::File { bytes: metadata.len(), }, ), - Err(_) => visit(&path, WalkedEntryV1::Unavailable), + _ => visit(&path, WalkedEntryV1::Unavailable), } } else { visit(&path, WalkedEntryV1::Unavailable); } } } - false + WalkTerminationV1::Complete } /// Census of every regular file under `profile_root`, bucketed by footprint @@ -902,4 +942,110 @@ mod tests { "a within-bound floor cannot claim health" ); } + + #[cfg(unix)] + #[test] + fn controlled_walk_counts_zero_byte_entries_and_never_follows_symlink_trees() { + let temporary = tempfile::tempdir().expect("walk fixture"); + let root = temporary.path().join("store"); + let outside = temporary.path().join("outside"); + std::fs::create_dir_all(&root).expect("store root"); + std::fs::create_dir_all(&outside).expect("outside root"); + std::fs::write(root.join("zero"), []).expect("zero-byte entry"); + sized(&outside.join("private"), 900); + std::os::unix::fs::symlink(&outside, root.join("other-store")).expect("store symlink"); + let mut deep = root.clone(); + for _ in 0..64 { + deep.push("d"); + std::fs::create_dir(&deep).expect("deep directory"); + } + sized(&deep.join("payload"), 12); + + let mut remaining = 100; + let mut bytes = 0u64; + let mut unavailable = 0usize; + let termination = + walk_regular_files_with_budget(&root, &mut remaining, &|| None, &mut |_, entry| { + match entry { + WalkedEntryV1::File { bytes: size } => bytes += size, + WalkedEntryV1::Unavailable => unavailable += 1, + } + }); + + assert_eq!(termination, WalkTerminationV1::Complete); + assert_eq!(bytes, 12, "the outside store is not followed"); + assert_eq!(unavailable, 1, "the symlink is explicit unavailable data"); + assert_eq!(remaining, 100 - 1 - 64 - 1 - 1); + } + + #[test] + fn one_entry_budget_is_shared_across_walk_roots() { + let temporary = tempfile::tempdir().expect("walk fixture"); + let first = temporary.path().join("first"); + let second = temporary.path().join("second"); + std::fs::create_dir_all(&first).expect("first root"); + std::fs::create_dir_all(&second).expect("second root"); + std::fs::write(first.join("payload"), [1]).expect("first payload"); + std::fs::write(second.join("payload"), [2]).expect("second payload"); + let mut remaining = 1; + let mut visits = 0usize; + let mut visit = |_, _| visits += 1; + + assert_eq!( + walk_regular_files_with_budget(&first, &mut remaining, &|| None, &mut visit), + WalkTerminationV1::Complete + ); + assert_eq!( + walk_regular_files_with_budget(&second, &mut remaining, &|| None, &mut visit), + WalkTerminationV1::EntryBudgetExceeded + ); + assert_eq!(visits, 1); + assert_eq!(remaining, 0); + } + + #[test] + fn cancellation_stops_a_blocking_walk_at_the_next_checkpoint() { + use std::cell::Cell; + + let temporary = tempfile::tempdir().expect("walk fixture"); + for name in ["one", "two", "three"] { + std::fs::write(temporary.path().join(name), [1]).expect("payload"); + } + let visits = Cell::new(0usize); + let mut remaining = 10; + let termination = walk_regular_files_with_budget( + temporary.path(), + &mut remaining, + &|| (visits.get() == 1).then_some(WalkInterruptionV1::Cancelled), + &mut |_, _| visits.set(visits.get() + 1), + ); + + assert_eq!(termination, WalkTerminationV1::Cancelled); + assert_eq!(visits.get(), 1); + assert_eq!(remaining, 9); + } + + #[test] + fn an_unreadable_walk_root_is_accounted_as_unavailable() { + let temporary = tempfile::tempdir().expect("walk fixture"); + let file_root = temporary.path().join("not-a-directory"); + std::fs::write(&file_root, [1u8]).expect("fixture file"); + let mut remaining = 10; + let mut unavailable = 0usize; + + let termination = walk_regular_files_with_budget( + &file_root, + &mut remaining, + &|| None, + &mut |_, entry| { + if entry == WalkedEntryV1::Unavailable { + unavailable += 1; + } + }, + ); + + assert_eq!(termination, WalkTerminationV1::Complete); + assert_eq!(unavailable, 1); + assert_eq!(remaining, 10); + } } diff --git a/crates/tracedecay-maintenance/src/retention/orphan_stores/pages.rs b/crates/tracedecay-maintenance/src/retention/orphan_stores/pages.rs index 941580b912..61d86bd2b1 100644 --- a/crates/tracedecay-maintenance/src/retention/orphan_stores/pages.rs +++ b/crates/tracedecay-maintenance/src/retention/orphan_stores/pages.rs @@ -133,19 +133,9 @@ pub(crate) fn newest_mtime_secs_controlled( walk_store_stats_controlled(dir, control).map(|stats| stats.newest_mtime_secs) } -/// Total size in bytes of every file under `dir`. Best-effort: unreadable -/// entries are skipped. -/// -/// Symlinks are never followed: one pointing at an ancestor would recurse -/// until the stack ran out, and one pointing outside the store would bill -/// another directory's bytes to this one. -pub fn dir_size_bytes(dir: &Path) -> u64 { - walk_store_stats(dir).size_bytes -} - -/// Controlled counterpart of [`dir_size_bytes`]. It preserves the original -/// best-effort accounting policy for unreadable entries while making the -/// recursive work bounded by the caller's admission control. +/// Size-only controlled walk for collection callers. It preserves the +/// best-effort accounting policy for unreadable entries while bounding +/// traversal by the caller's admission control. pub(crate) fn dir_size_bytes_controlled( dir: &Path, control: CollectionControl<'_>, diff --git a/crates/tracedecay-maintenance/src/retention/storage_report.rs b/crates/tracedecay-maintenance/src/retention/storage_report.rs index bea43461b9..85838954f5 100644 --- a/crates/tracedecay-maintenance/src/retention/storage_report.rs +++ b/crates/tracedecay-maintenance/src/retention/storage_report.rs @@ -16,8 +16,9 @@ //! read three pragmas off it. The owner's profile was 91GB, on a command //! whose entire promise is being cheap enough to run on a full profile. //! -//! So sizes come from filesystem metadata (exact, no locks, no I/O beyond -//! `stat`) and free-page counts from a short read-only connection. The one +//! So sizes come from a bounded filesystem-metadata walk and free-page counts +//! from a short read-only connection. Complete samples are exact; interrupted, +//! unreadable, or budget-limited samples remain explicit lower bounds. The one //! registry snapshot uses an OS-temporary scratch directory, never a child of //! the profile it inspects. If a free-page connection fails because the store //! is busy, corrupt, or a WAL database with no `-shm` to map read-only, its @@ -36,20 +37,22 @@ use tracedecay_domain::{ use tracedecay_code_index_retention::code_index_generations::{ CODE_TEXT_ARTIFACT_STAGING_DIRECTORY_V1, CODE_TEXT_ARTIFACTS_DIRECTORY_V1, CodeGenerationRetentionGenerationV1, GenerationDigestVerificationV1, - plan_code_generation_retention_with_verification, scoped_code_index_store_root, + plan_code_generation_retention_with_verification_cancellable, scoped_code_index_store_root, }; use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1; +use tracedecay_contracts::{CancellationSignal, Deadline, now_micros}; use tracedecay_runtime_core::config::GLOBAL_DB_FILENAME; use tracedecay_runtime_core::native_runtime; use tracedecay_runtime_core::storage::{SESSIONS_DB_FILENAME, USER_SESSIONS_DB_FILENAME}; use super::footprint::{ - ProfileFootprintV1, ProjectRootStateV1, WalkedEntryV1, census_profile_footprint, - walk_regular_files, + MAX_FOOTPRINT_ENTRIES, ProfileFootprintV1, ProjectRootStateV1, WalkInterruptionV1, + WalkTerminationV1, WalkedEntryV1, census_profile_footprint, walk_regular_files_with_budget, }; use crate::store_maintenance::{CodeGenerationProtectionUnavailableV1, code_generation_protection}; pub const MAX_STORAGE_REPORT_PAGE_LIMIT: usize = 64; +const MAX_STORAGE_REPORT_WALK_ENTRIES: usize = MAX_FOOTPRINT_ENTRIES; const CODE_GENERATION_RETENTION_DIGEST_SCAN_MAX_BYTES: u64 = 32 * 1024 * 1024; const CODE_GENERATIONS_DIRECTORY: &str = "code-generations-v1"; const CODE_INDEX_DIRECTORY: &str = "code-index-v1"; @@ -71,14 +74,18 @@ type RetentionProtection = Result, &'static str>; pub struct StoreSizeReportEntry { pub project_id: String, pub canonical_root: String, - /// Every regular file under the store's `projects//` directory, from - /// filesystem metadata: the sum of `kinds`. + /// Measured bytes under the store's `projects//` directory, from + /// filesystem metadata: the sum of `kinds`. A lower bound unless + /// `size_coverage` is `complete`. pub total_bytes: u64, /// `total_bytes` by the store family each file belongs to. pub kinds: StoreKindBytesV1, /// Entries under the store that could not be read or are neither regular /// files nor directories. Non-zero makes `total_bytes` a floor. pub unavailable_entry_count: usize, + /// Whether the store walk was complete, partial, or unavailable. + #[serde(default)] + pub size_coverage: StorageReportSizeCoverageV1, /// Reclaimable free-page bytes of the graph database, or `None` when it /// could not be sampled without waiting on a live writer. pub free_bytes: Option, @@ -89,6 +96,67 @@ pub struct StoreSizeReportEntry { pub root_state: Option, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StorageReportSizeStateV1 { + #[default] + Complete, + Partial, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StorageReportSizeReasonV1 { + UnreadableEntries, + EntryBudgetExceeded, + Cancelled, + DeadlineExceeded, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct StorageReportSizeCoverageV1 { + pub state: StorageReportSizeStateV1, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default)] + pub unavailable_entry_count: usize, +} + +impl StorageReportSizeCoverageV1 { + /// Combine coverage from separate directory pages without hiding an + /// unavailable page or its unreadable-entry count. + pub fn merge(self, other: Self) -> Self { + use StorageReportSizeStateV1 as State; + + let state = match (self.state, other.state) { + (State::Complete, State::Complete) => State::Complete, + (State::Unavailable, State::Unavailable) => State::Unavailable, + (State::Partial, _) | (_, State::Partial) => State::Partial, + (State::Unavailable, State::Complete) | (State::Complete, State::Unavailable) => { + State::Partial + } + }; + Self { + state, + reason: match (self.reason, other.reason) { + (Some(left), Some(right)) => Some( + if size_reason_priority(left) >= size_reason_priority(right) { + left + } else { + right + }, + ), + (Some(reason), None) | (None, Some(reason)) => Some(reason), + (None, None) => None, + }, + unavailable_entry_count: self + .unavailable_entry_count + .saturating_add(other.unavailable_entry_count), + } + } +} + /// On-disk bytes of one project store, by family. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub struct StoreKindBytesV1 { @@ -216,6 +284,11 @@ pub enum StorageReportCoverageState { pub struct StorageReportCoverage { pub state: StorageReportCoverageState, pub next_cursor: Option, + /// Sizing coverage for unregistered directories on this directory page. + /// Kept separate from the outer cursor: a cursor never implies that a + /// partially sampled subtree was fully sized. + #[serde(default)] + pub unregistered_size: StorageReportSizeCoverageV1, } impl StorageReportCoverage { @@ -223,10 +296,80 @@ impl StorageReportCoverage { Self { state: StorageReportCoverageState::Partial, next_cursor: Some(next_cursor), + unregistered_size: StorageReportSizeCoverageV1::default(), } } } +#[derive(Clone, Default)] +struct StorageReportWalkControl { + cancellation: Option, + deadline: Option, +} + +impl StorageReportWalkControl { + fn interruption(&self) -> Option { + if self + .cancellation + .as_ref() + .is_some_and(CancellationSignal::is_cancelled) + { + Some(WalkInterruptionV1::Cancelled) + } else if self + .deadline + .as_ref() + .is_some_and(|deadline| deadline.is_elapsed_at(now_micros())) + { + Some(WalkInterruptionV1::DeadlineExceeded) + } else { + None + } + } +} + +fn size_coverage( + termination: WalkTerminationV1, + measured_bytes: u64, + unavailable_entry_count: usize, +) -> StorageReportSizeCoverageV1 { + let (reason, state) = match termination { + WalkTerminationV1::Complete if unavailable_entry_count == 0 => return Default::default(), + WalkTerminationV1::Complete => ( + StorageReportSizeReasonV1::UnreadableEntries, + if measured_bytes == 0 { + StorageReportSizeStateV1::Unavailable + } else { + StorageReportSizeStateV1::Partial + }, + ), + WalkTerminationV1::EntryBudgetExceeded => ( + StorageReportSizeReasonV1::EntryBudgetExceeded, + StorageReportSizeStateV1::Partial, + ), + WalkTerminationV1::Cancelled => ( + StorageReportSizeReasonV1::Cancelled, + if measured_bytes == 0 && unavailable_entry_count == 0 { + StorageReportSizeStateV1::Unavailable + } else { + StorageReportSizeStateV1::Partial + }, + ), + WalkTerminationV1::DeadlineExceeded => ( + StorageReportSizeReasonV1::DeadlineExceeded, + if measured_bytes == 0 && unavailable_entry_count == 0 { + StorageReportSizeStateV1::Unavailable + } else { + StorageReportSizeStateV1::Partial + }, + ), + }; + StorageReportSizeCoverageV1 { + state, + reason: Some(reason), + unavailable_entry_count, + } +} + /// Whether a profile total accounts for every byte under the profile root. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "snake_case")] @@ -280,12 +423,20 @@ impl StorageReport { .saturating_add(self.unregistered_bytes); if let Some(full_profile_size) = self.full_profile_size { - let excluded_families = (full_profile_size.unavailable_entry_count > 0).then(|| { - format!( + let mut excluded_families = Vec::new(); + if full_profile_size.unavailable_entry_count > 0 { + excluded_families.push(format!( "{} unreadable or non-regular profile entries", full_profile_size.unavailable_entry_count - ) - }); + )); + } + if self + .footprint + .as_ref() + .is_some_and(|footprint| footprint.truncated) + { + excluded_families.push("profile census stopped at its entry budget".to_owned()); + } return ProfileTotalSizeV1 { state: full_profile_size.state, accounted_bytes: full_profile_size.total_bytes, @@ -293,7 +444,7 @@ impl StorageReport { global_db_bytes: self.global_db_bytes, user_sessions_db_bytes: self.user_sessions_db_bytes, unregistered_bytes: self.unregistered_bytes, - excluded_families: excluded_families.into_iter().collect(), + excluded_families, }; } @@ -314,6 +465,28 @@ impl StorageReport { "{unreadable_store_entries} unreadable entries under registered stores" )); } + for store in &self.stores { + if store.size_coverage.state != StorageReportSizeStateV1::Complete { + excluded_families.push(format!( + "registered store {} was {} ({}, {} unavailable entries)", + store.project_id, + size_state_label(store.size_coverage.state), + size_reason_label(store.size_coverage.reason), + store + .size_coverage + .unavailable_entry_count + .max(store.unavailable_entry_count), + )); + } + } + if self.coverage.unregistered_size.state != StorageReportSizeStateV1::Complete { + excluded_families.push(format!( + "unregistered directories were {} ({}, {} unavailable entries)", + size_state_label(self.coverage.unregistered_size.state), + size_reason_label(self.coverage.unregistered_size.reason), + self.coverage.unregistered_size.unavailable_entry_count, + )); + } ProfileTotalSizeV1 { state: ProfileTotalCoverageStateV1::Partial, accounted_bytes, @@ -326,6 +499,33 @@ impl StorageReport { } } +fn size_state_label(state: StorageReportSizeStateV1) -> &'static str { + match state { + StorageReportSizeStateV1::Complete => "completely sized", + StorageReportSizeStateV1::Partial => "partially sized", + StorageReportSizeStateV1::Unavailable => "unavailable", + } +} + +fn size_reason_label(reason: Option) -> &'static str { + match reason { + None => "unspecified coverage", + Some(StorageReportSizeReasonV1::UnreadableEntries) => "unreadable entries", + Some(StorageReportSizeReasonV1::EntryBudgetExceeded) => "entry budget exceeded", + Some(StorageReportSizeReasonV1::Cancelled) => "cancelled", + Some(StorageReportSizeReasonV1::DeadlineExceeded) => "deadline exceeded", + } +} + +const fn size_reason_priority(reason: StorageReportSizeReasonV1) -> u8 { + match reason { + StorageReportSizeReasonV1::UnreadableEntries => 0, + StorageReportSizeReasonV1::Cancelled => 1, + StorageReportSizeReasonV1::DeadlineExceeded => 2, + StorageReportSizeReasonV1::EntryBudgetExceeded => 3, + } +} + /// Read-only mark-and-sweep preview for one code-index scope. #[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)] pub struct CodeGenerationRetentionDryRunEntry { @@ -413,8 +613,16 @@ pub async fn build_storage_report( .await .map_err(|error| report_error("join profile footprint census", error))?; let profile_root_buf = profile_root.to_path_buf(); + let walk_control = StorageReportWalkControl::default(); let sampled = native_runtime::spawn_blocking(move || { - sample_registered_storage(&profile_root_buf, &global_db_path, registered_projects) + let mut remaining_entries = MAX_STORAGE_REPORT_WALK_ENTRIES; + sample_registered_storage( + &profile_root_buf, + &global_db_path, + registered_projects, + &mut remaining_entries, + &walk_control, + ) }) .await .map_err(|error| report_error("join storage report sample", error))??; @@ -430,7 +638,10 @@ pub async fn build_storage_report( user_sessions_db_bytes: sampled.user_sessions_db_bytes, full_profile_size: Some(footprint.full_profile_size()), footprint: Some(footprint), - coverage: StorageReportCoverage::default(), + coverage: StorageReportCoverage { + unregistered_size: sampled.unregistered_size, + ..StorageReportCoverage::default() + }, }) } @@ -440,6 +651,7 @@ struct SampledRegisteredStorage { code_generation_retention_availability: Vec, unregistered_dir_count: usize, unregistered_bytes: u64, + unregistered_size: StorageReportSizeCoverageV1, global_db_bytes: u64, user_sessions_db_bytes: u64, } @@ -516,6 +728,8 @@ fn sample_registered_storage( profile_root: &Path, global_db_path: &Path, registered_projects: Vec<(String, String)>, + remaining_entries: &mut usize, + walk_control: &StorageReportWalkControl, ) -> tracedecay_domain::errors::Result { let mut registered_ids = HashSet::new(); let mut stores = Vec::new(); @@ -534,16 +748,23 @@ fn sample_registered_storage( &mut stores, &mut code_generation_retention, &mut code_generation_retention_availability, + remaining_entries, + walk_control, )?; } - let (unregistered_dir_count, unregistered_bytes) = - scan_unregistered_dirs(profile_root, ®istered_ids); + let unregistered = scan_unregistered_dirs( + profile_root, + ®istered_ids, + remaining_entries, + walk_control, + ); Ok(SampledRegisteredStorage { stores, code_generation_retention, code_generation_retention_availability, - unregistered_dir_count, - unregistered_bytes, + unregistered_dir_count: unregistered.directory_count, + unregistered_bytes: unregistered.bytes, + unregistered_size: unregistered.coverage, global_db_bytes: database_family_bytes(global_db_path), user_sessions_db_bytes: user_sessions_family_bytes(profile_root), }) @@ -596,8 +817,56 @@ pub async fn build_storage_report_page_from_registered_global_db( code_index_schedulers: Option<&CodeIndexSchedulerRegistryV1>, cursor: Option<&str>, limit: usize, +) -> tracedecay_domain::errors::Result { + build_storage_report_page_from_registered_global_db_controlled( + profile_root, + global_db, + code_index_schedulers, + cursor, + limit, + None, + None, + ) + .await +} + +pub async fn build_storage_report_page_from_registered_global_db_controlled( + profile_root: &Path, + global_db: &tracedecay_global_db::RegisteredGlobalDb, + code_index_schedulers: Option<&CodeIndexSchedulerRegistryV1>, + cursor: Option<&str>, + limit: usize, + cancellation: Option, + deadline: Option, +) -> tracedecay_domain::errors::Result { + build_storage_report_page_with_walk_budget( + profile_root, + global_db, + code_index_schedulers, + cursor, + limit, + MAX_STORAGE_REPORT_WALK_ENTRIES, + cancellation, + deadline, + ) + .await +} + +async fn build_storage_report_page_with_walk_budget( + profile_root: &Path, + global_db: &tracedecay_global_db::RegisteredGlobalDb, + code_index_schedulers: Option<&CodeIndexSchedulerRegistryV1>, + cursor: Option<&str>, + limit: usize, + walk_entry_budget: usize, + cancellation: Option, + deadline: Option, ) -> tracedecay_domain::errors::Result { let limit = limit.clamp(1, MAX_STORAGE_REPORT_PAGE_LIMIT); + let walk_control = StorageReportWalkControl { + cancellation, + deadline, + }; let global_db_bytes = database_family_bytes(&profile_root.join(GLOBAL_DB_FILENAME)); let user_sessions_db_bytes = user_sessions_family_bytes(profile_root); let binding = storage_report_binding(limit)?; @@ -620,6 +889,8 @@ pub async fn build_storage_report_page_from_registered_global_db( limit, global_db_bytes, user_sessions_db_bytes, + walk_entry_budget, + walk_control, ) .await; } @@ -646,14 +917,15 @@ pub async fn build_storage_report_page_from_registered_global_db( .filter(|(name, _)| !registered.contains(name)) .map(|(_, path)| path.clone()) .collect::>(); - let (unregistered_dir_count, unregistered_bytes) = native_runtime::spawn_blocking(move || { - let bytes = unregistered.iter().fold(0u64, |total, path| { - total.saturating_add(super::orphan_stores::dir_size_bytes(path)) - }); - (unregistered.len(), bytes) - }) - .await - .map_err(|error| report_error("join unregistered storage page", error))?; + let (unregistered_dir_count, unregistered_bytes, unregistered_size) = + native_runtime::spawn_blocking(move || { + let mut remaining_entries = walk_entry_budget; + let sampled = + sample_unregistered_paths(unregistered, &mut remaining_entries, &walk_control); + (sampled.directory_count, sampled.bytes, sampled.coverage) + }) + .await + .map_err(|error| report_error("join unregistered storage page", error))?; let next_cursor = directory_page .next_cursor .map(|after| { @@ -669,10 +941,14 @@ pub async fn build_storage_report_page_from_registered_global_db( unregistered_bytes, global_db_bytes, user_sessions_db_bytes, - coverage: next_cursor.map_or_else( - StorageReportCoverage::default, - StorageReportCoverage::partial, - ), + coverage: { + let mut coverage = next_cursor.map_or_else( + StorageReportCoverage::default, + StorageReportCoverage::partial, + ); + coverage.unregistered_size = unregistered_size; + coverage + }, ..StorageReport::default() }) } @@ -686,6 +962,8 @@ async fn project_storage_report_page( limit: usize, global_db_bytes: u64, user_sessions_db_bytes: u64, + walk_entry_budget: usize, + walk_control: StorageReportWalkControl, ) -> tracedecay_domain::errors::Result { let mut projects = global_db .list_code_projects_after(after_project_id, limit.saturating_add(1)) @@ -718,6 +996,7 @@ async fn project_storage_report_page( } let profile_root = profile_root.to_path_buf(); native_runtime::spawn_blocking(move || { + let mut remaining_entries = walk_entry_budget; let mut report = StorageReport { profile_root: profile_root.display().to_string(), global_db_bytes, @@ -734,6 +1013,8 @@ async fn project_storage_report_page( &mut report.stores, &mut report.code_generation_retention, &mut report.code_generation_retention_availability, + &mut remaining_entries, + &walk_control, )?; } Ok(report) @@ -815,14 +1096,26 @@ pub async fn build_project_storage_report_from_daemon( canonical_root: &Path, global_db: &tracedecay_global_db::RegisteredGlobalDb, code_index_schedulers: Option<&CodeIndexSchedulerRegistryV1>, + cancellation: Option, + deadline: Option, ) -> tracedecay_domain::errors::Result { let protection = resolve_retention_protection(code_index_schedulers, global_db, canonical_root).await; let profile_root = profile_root.to_path_buf(); let project_id = project_id.to_owned(); let canonical_root = canonical_root.to_path_buf(); + let walk_control = StorageReportWalkControl { + cancellation, + deadline, + }; native_runtime::spawn_blocking(move || { - build_project_storage_report(&profile_root, &project_id, &canonical_root, protection) + build_project_storage_report_with_control( + &profile_root, + &project_id, + &canonical_root, + protection, + &walk_control, + ) }) .await .map_err(|error| report_error("join daemon-backed project storage report", error))? @@ -856,6 +1149,22 @@ pub fn build_project_storage_report( project_id: &str, canonical_root: &Path, vector_readable_sources: Result, &'static str>, +) -> tracedecay_domain::errors::Result { + build_project_storage_report_with_control( + profile_root, + project_id, + canonical_root, + vector_readable_sources, + &StorageReportWalkControl::default(), + ) +} + +fn build_project_storage_report_with_control( + profile_root: &Path, + project_id: &str, + canonical_root: &Path, + vector_readable_sources: RetentionProtection, + walk_control: &StorageReportWalkControl, ) -> tracedecay_domain::errors::Result { tracedecay_runtime_core::storage::validate_project_id(project_id).map_err(|message| { tracedecay_domain::errors::TraceDecayError::Config { @@ -865,6 +1174,7 @@ pub fn build_project_storage_report( let mut stores = Vec::new(); let mut code_generation_retention = Vec::new(); let mut code_generation_retention_availability = Vec::new(); + let mut remaining_entries = MAX_STORAGE_REPORT_WALK_ENTRIES; append_project_report( profile_root, project_id, @@ -873,6 +1183,8 @@ pub fn build_project_storage_report( &mut stores, &mut code_generation_retention, &mut code_generation_retention_availability, + &mut remaining_entries, + walk_control, )?; let global_db_path = profile_root.join(GLOBAL_DB_FILENAME); Ok(StorageReport { @@ -898,64 +1210,120 @@ fn append_project_report( stores: &mut Vec, code_generation_retention: &mut Vec, code_generation_retention_availability: &mut Vec, + remaining_entries: &mut usize, + walk_control: &StorageReportWalkControl, ) -> tracedecay_domain::errors::Result<()> { let data_root = profile_root.join("projects").join(project_id); - if data_root.is_dir() { - let mut kinds = StoreKindBytesV1::default(); - let mut unavailable_entry_count = 0usize; - walk_regular_files(&data_root, usize::MAX, &mut |path, walked| match walked { - WalkedEntryV1::File { bytes } => { - if let Ok(relative) = path.strip_prefix(&data_root) { - kinds.add(relative, bytes); - } - } - WalkedEntryV1::Unavailable => unavailable_entry_count += 1, - }); - let (free_bytes, free_page_ratio) = - sample_free_pages(&data_root.join(tracedecay_runtime_core::config::DB_FILENAME)) - .map_or((None, None), |(free_bytes, ratio)| { - (Some(free_bytes), Some(ratio)) - }); - stores.push(StoreSizeReportEntry { - project_id: project_id.to_owned(), - canonical_root: canonical_root.to_owned(), - total_bytes: kinds.total(), - kinds, - unavailable_entry_count, - free_bytes, - free_page_ratio, - root_state: Some(ProjectRootStateV1::probe(Some(canonical_root))), - }); - } let code_index_store_root = scoped_code_index_store_root(&data_root.join("code-index-v1"), Path::new(canonical_root)); - if !code_index_store_root + let generation_directory = code_index_store_root.join(CODE_GENERATIONS_DIRECTORY); + let active_pointer_present = code_index_store_root .join("active-code-generation-v1.json") - .is_file() - { + .is_file(); + let measure_generation_bytes = active_pointer_present && vector_readable_sources.is_ok(); + let mut generation_bytes = 0u64; + let mut generation_unavailable_entry_count = 0usize; + let mut termination = WalkTerminationV1::Complete; + let mut kinds = StoreKindBytesV1::default(); + let mut unavailable_entry_count = 0usize; + match std::fs::symlink_metadata(&data_root) { + Ok(metadata) if metadata.is_dir() => { + termination = walk_regular_files_with_budget( + &data_root, + remaining_entries, + &|| walk_control.interruption(), + &mut |path, walked| match walked { + WalkedEntryV1::File { bytes } => { + if let Ok(relative) = path.strip_prefix(&data_root) { + kinds.add(relative, bytes); + } + if measure_generation_bytes && path.starts_with(&generation_directory) { + generation_bytes = generation_bytes.saturating_add(bytes); + } + } + WalkedEntryV1::Unavailable => { + unavailable_entry_count = unavailable_entry_count.saturating_add(1); + if measure_generation_bytes && path.starts_with(&generation_directory) { + generation_unavailable_entry_count = + generation_unavailable_entry_count.saturating_add(1); + } + } + }, + ); + } + Ok(_) => unavailable_entry_count = 1, + Err(error) if error.kind() != std::io::ErrorKind::NotFound => { + unavailable_entry_count = 1; + } + Err(_) => {} + } + let total_bytes = kinds.total(); + let (free_bytes, free_page_ratio) = + sample_free_pages(&data_root.join(tracedecay_runtime_core::config::DB_FILENAME)) + .map_or((None, None), |(free_bytes, ratio)| { + (Some(free_bytes), Some(ratio)) + }); + stores.push(StoreSizeReportEntry { + project_id: project_id.to_owned(), + canonical_root: canonical_root.to_owned(), + total_bytes, + kinds, + unavailable_entry_count, + size_coverage: size_coverage(termination, total_bytes, unavailable_entry_count), + free_bytes, + free_page_ratio, + root_state: Some(ProjectRootStateV1::probe(Some(canonical_root))), + }); + if !active_pointer_present { return Ok(()); } - let digest_scan_exceeds_budget = match generation_digest_scan_exceeds_budget( - &code_index_store_root, - CODE_GENERATION_RETENTION_DIGEST_SCAN_MAX_BYTES, - ) { - Ok(exceeds_budget) => exceeds_budget, - Err(_) => { + // Inventory-less surfaces cannot plan retention. Resolve this before + // computing the digest-size preflight: an empty guessed protection set is + // unsafe and the report's ordinary store sizing is sufficient here. + let readable_sources = match vector_readable_sources { + Ok(sources) => sources, + Err(reason) => { code_generation_retention_availability.push(CodeGenerationRetentionAvailabilityEntry { project_id: project_id.to_owned(), store_root: code_index_store_root.display().to_string(), state: StorageReportAvailabilityState::Unavailable, - reason: Some("generation_retention_scan_unavailable".to_owned()), + reason: Some(reason.to_owned()), }); return Ok(()); } }; + let scan_reason = match termination { + WalkTerminationV1::Complete if generation_unavailable_entry_count > 0 => { + Some("generation_retention_scan_unavailable") + } + WalkTerminationV1::Complete => match walk_control.interruption() { + None => None, + Some(WalkInterruptionV1::Cancelled) => Some("generation_retention_scan_cancelled"), + Some(WalkInterruptionV1::DeadlineExceeded) => { + Some("generation_retention_scan_deadline_exceeded") + } + }, + WalkTerminationV1::EntryBudgetExceeded => { + Some("generation_retention_scan_entry_budget_exceeded") + } + WalkTerminationV1::Cancelled => Some("generation_retention_scan_cancelled"), + WalkTerminationV1::DeadlineExceeded => Some("generation_retention_scan_deadline_exceeded"), + }; + if let Some(reason) = scan_reason { + code_generation_retention_availability.push(CodeGenerationRetentionAvailabilityEntry { + project_id: project_id.to_owned(), + store_root: code_index_store_root.display().to_string(), + state: StorageReportAvailabilityState::Unavailable, + reason: Some(reason.to_owned()), + }); + return Ok(()); + } // Exceeding the digest budget bounds how hard the census may *verify*, not // whether it may run. A single sealed generation is routinely larger than // any budget cheap enough to be worth having, so treating this as // "unavailable" reported nothing at all on exactly the profiles that had // something to report. - let verification = if digest_scan_exceeds_budget { + let verification = if generation_bytes > CODE_GENERATION_RETENTION_DIGEST_SCAN_MAX_BYTES { GenerationDigestVerificationV1::MetadataOnly } else { GenerationDigestVerificationV1::Full @@ -963,30 +1331,27 @@ fn append_project_report( // The serving generation and live native-preview bindings are only // provable from daemon authorities. Without them the dry run is reported // unavailable rather than planned against an empty protection set. - let readable_sources = match vector_readable_sources { - Ok(sources) => sources, - Err(reason) => { - code_generation_retention_availability.push(CodeGenerationRetentionAvailabilityEntry { - project_id: project_id.to_owned(), - store_root: code_index_store_root.display().to_string(), - state: StorageReportAvailabilityState::Unavailable, - reason: Some(reason.to_owned()), - }); - return Ok(()); - } - }; - let plan = match plan_code_generation_retention_with_verification( + let plan = match plan_code_generation_retention_with_verification_cancellable( &code_index_store_root, &readable_sources, verification, + None, + &|| walk_control.interruption().is_some(), ) { Ok(plan) => plan, Err(_) => { + let reason = match walk_control.interruption() { + Some(WalkInterruptionV1::Cancelled) => "generation_retention_scan_cancelled", + Some(WalkInterruptionV1::DeadlineExceeded) => { + "generation_retention_scan_deadline_exceeded" + } + None => "generation_retention_plan_unavailable", + }; code_generation_retention_availability.push(CodeGenerationRetentionAvailabilityEntry { project_id: project_id.to_owned(), store_root: code_index_store_root.display().to_string(), state: StorageReportAvailabilityState::Unavailable, - reason: Some("generation_retention_plan_unavailable".to_owned()), + reason: Some(reason.to_owned()), }); return Ok(()); } @@ -1029,36 +1394,6 @@ fn append_project_report( Ok(()) } -fn generation_digest_scan_exceeds_budget( - store_root: &Path, - maximum_bytes: u64, -) -> tracedecay_domain::errors::Result { - let entries = std::fs::read_dir(store_root.join(CODE_GENERATIONS_DIRECTORY)) - .map_err(|error| report_error("list code-generation retention files", error))?; - let mut bytes = 0u64; - for entry in entries { - let entry = - entry.map_err(|error| report_error("read code-generation retention entry", error))?; - if !entry - .file_type() - .map_err(|error| report_error("read code-generation retention file type", error))? - .is_file() - { - continue; - } - bytes = bytes.saturating_add( - entry - .metadata() - .map_err(|error| report_error("size code-generation retention file", error))? - .len(), - ); - if bytes > maximum_bytes { - return Ok(true); - } - } - Ok(false) -} - fn user_sessions_family_bytes(profile_root: &Path) -> u64 { database_family_bytes(&profile_root.join(USER_SESSIONS_DB_FILENAME)) } @@ -1152,21 +1487,112 @@ fn sample_free_pages(graph_db_path: &Path) -> Option<(u64, f64)> { Some((free_bytes, free_page_ratio)) } -fn scan_unregistered_dirs(profile_root: &Path, registered_ids: &HashSet) -> (usize, u64) { +#[derive(Debug)] +struct UnregisteredDirectorySample { + directory_count: usize, + bytes: u64, + unavailable_entry_count: usize, + coverage: StorageReportSizeCoverageV1, +} + +fn sample_unregistered_paths( + paths: Vec, + remaining_entries: &mut usize, + walk_control: &StorageReportWalkControl, +) -> UnregisteredDirectorySample { + let directory_count = paths.len(); + let mut bytes = 0u64; + let mut unavailable_entry_count = 0usize; + let mut termination = WalkTerminationV1::Complete; + for path in paths { + if let Some(interruption) = walk_control.interruption() { + termination = match interruption { + WalkInterruptionV1::Cancelled => WalkTerminationV1::Cancelled, + WalkInterruptionV1::DeadlineExceeded => WalkTerminationV1::DeadlineExceeded, + }; + break; + } + let current = walk_regular_files_with_budget( + &path, + remaining_entries, + &|| walk_control.interruption(), + &mut |_, walked| match walked { + WalkedEntryV1::File { bytes: file_bytes } => { + bytes = bytes.saturating_add(file_bytes); + } + WalkedEntryV1::Unavailable => { + unavailable_entry_count = unavailable_entry_count.saturating_add(1); + } + }, + ); + if current != WalkTerminationV1::Complete { + termination = current; + break; + } + } + UnregisteredDirectorySample { + directory_count, + bytes, + unavailable_entry_count, + coverage: size_coverage(termination, bytes, unavailable_entry_count), + } +} + +fn scan_unregistered_dirs( + profile_root: &Path, + registered_ids: &HashSet, + remaining_entries: &mut usize, + walk_control: &StorageReportWalkControl, +) -> UnregisteredDirectorySample { let projects_dir = profile_root.join("projects"); - let Ok(entries) = std::fs::read_dir(&projects_dir) else { - return (0, 0); + let entries = match std::fs::read_dir(&projects_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return UnregisteredDirectorySample { + directory_count: 0, + bytes: 0, + unavailable_entry_count: 0, + coverage: StorageReportSizeCoverageV1::default(), + }; + } + Err(_) => { + return UnregisteredDirectorySample { + directory_count: 0, + bytes: 0, + unavailable_entry_count: 1, + coverage: size_coverage(WalkTerminationV1::Complete, 0, 1), + }; + } }; - let mut count = 0usize; - let mut bytes = 0u64; - for entry in entries.flatten() { + let mut paths = Vec::new(); + let mut unavailable_entry_count = 0usize; + let mut termination = WalkTerminationV1::Complete; + for entry in entries { + if let Some(interruption) = walk_control.interruption() { + termination = match interruption { + WalkInterruptionV1::Cancelled => WalkTerminationV1::Cancelled, + WalkInterruptionV1::DeadlineExceeded => WalkTerminationV1::DeadlineExceeded, + }; + break; + } + if *remaining_entries == 0 { + termination = WalkTerminationV1::EntryBudgetExceeded; + break; + } + *remaining_entries -= 1; + let Ok(entry) = entry else { + unavailable_entry_count = unavailable_entry_count.saturating_add(1); + continue; + }; let Ok(file_type) = entry.file_type() else { + unavailable_entry_count = unavailable_entry_count.saturating_add(1); continue; }; if !file_type.is_dir() { continue; } let Ok(name) = entry.file_name().into_string() else { + unavailable_entry_count = unavailable_entry_count.saturating_add(1); continue; }; if tracedecay_runtime_core::storage::validate_project_id(&name).is_err() @@ -1174,12 +1600,37 @@ fn scan_unregistered_dirs(profile_root: &Path, registered_ids: &HashSet) { continue; } - count += 1; - // Shares the orphan sweep's walker so the bytes this report shows for - // a backlog directory are the same bytes that sweep would reclaim. - bytes = bytes.saturating_add(super::orphan_stores::dir_size_bytes(&entry.path())); + paths.push(entry.path()); + } + let directory_count = paths.len(); + let mut bytes = 0u64; + for path in paths { + if termination != WalkTerminationV1::Complete { + break; + } + let current = walk_regular_files_with_budget( + &path, + remaining_entries, + &|| walk_control.interruption(), + &mut |_, walked| match walked { + WalkedEntryV1::File { bytes: file_bytes } => { + bytes = bytes.saturating_add(file_bytes); + } + WalkedEntryV1::Unavailable => { + unavailable_entry_count = unavailable_entry_count.saturating_add(1); + } + }, + ); + if current != WalkTerminationV1::Complete { + termination = current; + } + } + UnregisteredDirectorySample { + directory_count, + bytes, + unavailable_entry_count, + coverage: size_coverage(termination, bytes, unavailable_entry_count), } - (count, bytes) } fn report_error( @@ -1208,12 +1659,26 @@ mod tests { total_bytes, kinds: StoreKindBytesV1::default(), unavailable_entry_count: 0, + size_coverage: StorageReportSizeCoverageV1::default(), free_bytes: None, free_page_ratio: None, root_state: None, } } + async fn registered_project( + db: &tracedecay_global_db::RegisteredGlobalDb, + checkout: &Path, + id: &str, + ) -> String { + std::fs::create_dir_all(checkout).unwrap(); + let canonical = tracedecay_runtime_core::path_safety::canonical_root_identity(checkout); + db.upsert_code_project(id, &canonical, None, None, None) + .await + .unwrap() + .project_id + } + #[test] fn paginated_report_totals_a_floor_and_never_claims_completeness() { let report = StorageReport { @@ -1964,6 +2429,11 @@ mod tests { report.unregistered_bytes, 2048, "only the store's own bytes count" ); + assert_eq!(report.coverage.unregistered_size.unavailable_entry_count, 2); + assert_eq!( + report.coverage.unregistered_size.state, + StorageReportSizeStateV1::Partial + ); } /// Each store row sizes every family under its project directory, and the @@ -2278,4 +2748,352 @@ mod tests { "a corrupt active pointer is still unavailable: {payload}" ); } + + #[asupersync::test] + async fn one_project_is_partially_sized_when_its_nested_walk_exceeds_the_call_budget() { + let tmp = tempfile::TempDir::new().unwrap(); + let profile_root = tmp.path().join("profile"); + let runtime = tracedecay_global_db::tests::harness::RegisteredGlobalDbTestRuntime::profile( + &profile_root, + ) + .await + .unwrap(); + let db = runtime.profile_database_arc(); + let project_id = + registered_project(&db, &tmp.path().join("checkout"), "proj_bounded_single").await; + let data_root = profile_root.join("projects").join(project_id); + std::fs::create_dir_all(&data_root).unwrap(); + for name in ["one", "two", "three"] { + std::fs::write(data_root.join(name), [1u8]).unwrap(); + } + + let report = build_storage_report_page_with_walk_budget( + &profile_root, + &db, + None, + None, + 1, + 1, + None, + None, + ) + .await + .unwrap(); + + assert_eq!(report.stores.len(), 1); + assert_eq!(report.stores[0].total_bytes, 1); + assert_eq!( + report.stores[0].size_coverage, + StorageReportSizeCoverageV1 { + state: StorageReportSizeStateV1::Partial, + reason: Some(StorageReportSizeReasonV1::EntryBudgetExceeded), + unavailable_entry_count: 0, + } + ); + assert!(report.coverage.next_cursor.is_some()); + assert!( + report + .profile_total_size() + .excluded_families + .iter() + .any(|family| family.contains("entry budget exceeded")) + ); + } + + #[asupersync::test] + async fn registered_projects_share_one_page_entry_budget() { + let tmp = tempfile::TempDir::new().unwrap(); + let profile_root = tmp.path().join("profile"); + let runtime = tracedecay_global_db::tests::harness::RegisteredGlobalDbTestRuntime::profile( + &profile_root, + ) + .await + .unwrap(); + let db = runtime.profile_database_arc(); + let project_ids = [ + registered_project(&db, &tmp.path().join("checkout-a"), "proj_bounded_a").await, + registered_project(&db, &tmp.path().join("checkout-b"), "proj_bounded_b").await, + ]; + for project_id in &project_ids { + let data_root = profile_root.join("projects").join(project_id); + std::fs::create_dir_all(&data_root).unwrap(); + for name in ["first", "second"] { + std::fs::write(data_root.join(name), [1u8]).unwrap(); + } + } + + let report = build_storage_report_page_with_walk_budget( + &profile_root, + &db, + None, + None, + 2, + 3, + None, + None, + ) + .await + .unwrap(); + + assert_eq!(report.stores.len(), 2); + assert_eq!( + report + .stores + .iter() + .map(|store| store.total_bytes) + .sum::(), + 3, + "the later project cannot restart the page's entry budget" + ); + assert_eq!( + report + .stores + .iter() + .filter(|store| { + store.size_coverage.reason + == Some(StorageReportSizeReasonV1::EntryBudgetExceeded) + }) + .count(), + 1 + ); + } + + #[test] + fn unregistered_subtrees_share_the_same_sizing_accounting_and_budget() { + let tmp = tempfile::TempDir::new().unwrap(); + let first = tmp.path().join("first"); + let second = tmp.path().join("second"); + std::fs::create_dir_all(&first).unwrap(); + std::fs::create_dir_all(&second).unwrap(); + std::fs::write(first.join("payload"), [1u8]).unwrap(); + std::fs::write(second.join("payload"), [2u8]).unwrap(); + let mut remaining = 1; + + let sample = sample_unregistered_paths( + vec![first, second], + &mut remaining, + &StorageReportWalkControl::default(), + ); + + assert_eq!(sample.directory_count, 2); + assert_eq!(sample.bytes, 1); + assert_eq!(sample.unavailable_entry_count, 0); + assert_eq!(remaining, 0); + assert_eq!( + sample.coverage.reason, + Some(StorageReportSizeReasonV1::EntryBudgetExceeded) + ); + assert_eq!(sample.coverage.state, StorageReportSizeStateV1::Partial); + } + + #[asupersync::test] + async fn cancellation_and_deadline_reach_the_blocking_project_walk() { + let tmp = tempfile::TempDir::new().unwrap(); + let profile_root = tmp.path().join("profile"); + let runtime = tracedecay_global_db::tests::harness::RegisteredGlobalDbTestRuntime::profile( + &profile_root, + ) + .await + .unwrap(); + let db = runtime.profile_database_arc(); + let project_id = + registered_project(&db, &tmp.path().join("checkout"), "proj_bounded_control").await; + let data_root = profile_root.join("projects").join(project_id); + std::fs::create_dir_all(&data_root).unwrap(); + std::fs::write(data_root.join("payload"), [1u8]).unwrap(); + + let cancellation = CancellationSignal::active("storage-report-test").unwrap(); + cancellation.cancel(now_micros()); + let cancelled = build_storage_report_page_from_registered_global_db_controlled( + &profile_root, + &db, + None, + None, + 1, + Some(cancellation), + None, + ) + .await + .unwrap(); + assert_eq!( + cancelled.stores[0].size_coverage.state, + StorageReportSizeStateV1::Unavailable + ); + assert_eq!( + cancelled.stores[0].size_coverage.reason, + Some(StorageReportSizeReasonV1::Cancelled) + ); + assert_eq!(cancelled.stores[0].total_bytes, 0); + + let expired = Deadline::new(tracedecay_domain::UtcMicros(0)).unwrap(); + let timed_out = build_storage_report_page_from_registered_global_db_controlled( + &profile_root, + &db, + None, + None, + 1, + None, + Some(expired), + ) + .await + .unwrap(); + assert_eq!( + timed_out.stores[0].size_coverage.reason, + Some(StorageReportSizeReasonV1::DeadlineExceeded) + ); + assert_eq!(timed_out.stores[0].total_bytes, 0); + } + + #[test] + fn unavailable_protection_skips_generation_preflight() { + let tmp = tempfile::TempDir::new().unwrap(); + let profile_root = tmp.path().join("profile"); + let data_root = profile_root.join("projects/proj_a"); + let store_root = + scoped_code_index_store_root(&data_root.join("code-index-v1"), Path::new("/repos/a")); + let generations = store_root.join(CODE_GENERATIONS_DIRECTORY); + std::fs::create_dir_all(&generations).unwrap(); + std::fs::write(store_root.join("active-code-generation-v1.json"), b"active").unwrap(); + std::fs::write(generations.join("generation-empty.json"), []).unwrap(); + + let mut remaining = usize::MAX; + assert_eq!( + walk_regular_files_with_budget(&data_root, &mut remaining, &|| None, &mut |_, _| {},), + WalkTerminationV1::Complete + ); + let mut exact_budget = usize::MAX - remaining; + let mut stores = Vec::new(); + let mut plans = Vec::new(); + let mut availability = Vec::new(); + + append_project_report( + &profile_root, + "proj_a", + "/repos/a", + Err(RETENTION_PROTECTION_UNRESOLVED), + &mut stores, + &mut plans, + &mut availability, + &mut exact_budget, + &StorageReportWalkControl::default(), + ) + .unwrap(); + + assert_eq!(exact_budget, 0); + assert!(plans.is_empty()); + assert_eq!( + availability[0].reason.as_deref(), + Some(RETENTION_PROTECTION_UNRESOLVED) + ); + } + + #[test] + fn zero_byte_generation_files_cannot_evade_the_shared_entry_budget() { + let tmp = tempfile::TempDir::new().unwrap(); + let profile_root = tmp.path().join("profile"); + let data_root = profile_root.join("projects/proj_a"); + let store_root = + scoped_code_index_store_root(&data_root.join("code-index-v1"), Path::new("/repos/a")); + let generations = store_root.join(CODE_GENERATIONS_DIRECTORY); + std::fs::create_dir_all(&generations).unwrap(); + std::fs::write(store_root.join("active-code-generation-v1.json"), b"active").unwrap(); + for index in 0..5 { + std::fs::write(generations.join(format!("empty-{index}")), []).unwrap(); + } + let mut remaining = 3; + let mut stores = Vec::new(); + let mut availability = Vec::new(); + append_project_report( + &profile_root, + "proj_a", + "/repos/a", + Ok(BTreeSet::new()), + &mut stores, + &mut Vec::new(), + &mut availability, + &mut remaining, + &StorageReportWalkControl::default(), + ) + .unwrap(); + + assert_eq!(remaining, 0); + assert_eq!(stores[0].total_bytes, 0); + assert_eq!( + stores[0].size_coverage.state, + StorageReportSizeStateV1::Partial + ); + assert_eq!( + stores[0].size_coverage.reason, + Some(StorageReportSizeReasonV1::EntryBudgetExceeded) + ); + assert_eq!( + availability[0].reason.as_deref(), + Some("generation_retention_scan_entry_budget_exceeded") + ); + } + + #[test] + fn partial_sizing_and_full_census_coverage_survive_report_serialization() { + let mut partial_store = store("alpha", 4); + partial_store.size_coverage = StorageReportSizeCoverageV1 { + state: StorageReportSizeStateV1::Partial, + reason: Some(StorageReportSizeReasonV1::EntryBudgetExceeded), + unavailable_entry_count: 1, + }; + let report = StorageReport { + stores: vec![partial_store], + unregistered_bytes: 3, + coverage: StorageReportCoverage { + unregistered_size: StorageReportSizeCoverageV1 { + state: StorageReportSizeStateV1::Partial, + reason: Some(StorageReportSizeReasonV1::EntryBudgetExceeded), + unavailable_entry_count: 2, + }, + ..StorageReportCoverage::default() + }, + ..StorageReport::default() + }; + let payload = serde_json::to_value(&report).unwrap(); + let decoded: StorageReport = serde_json::from_value(payload).unwrap(); + + assert_eq!( + decoded.stores[0].size_coverage.reason, + Some(StorageReportSizeReasonV1::EntryBudgetExceeded) + ); + assert_eq!( + decoded.coverage.unregistered_size.unavailable_entry_count, + 2 + ); + let total = decoded.profile_total_size(); + assert_eq!(total.accounted_bytes, 7); + assert_eq!(total.state, ProfileTotalCoverageStateV1::Partial); + assert!( + total + .excluded_families + .iter() + .any(|family| family.contains("entry budget exceeded")) + ); + + let full = StorageReport { + full_profile_size: Some(FullProfileSizeV1 { + state: ProfileTotalCoverageStateV1::Partial, + total_bytes: 7, + unavailable_entry_count: 0, + }), + footprint: Some(ProfileFootprintV1 { + entries: Vec::new(), + total_bytes: 7, + unavailable_entry_count: 0, + truncated: true, + total_bound_bytes: 100, + }), + ..StorageReport::default() + }; + assert!( + full.profile_total_size() + .excluded_families + .iter() + .any(|family| family.contains("stopped at its entry budget")) + ); + } } diff --git a/crates/tracedecay-mcp/src/handlers/admin_cli.rs b/crates/tracedecay-mcp/src/handlers/admin_cli.rs index c94b198f1a..8c36297e95 100644 --- a/crates/tracedecay-mcp/src/handlers/admin_cli.rs +++ b/crates/tracedecay-mcp/src/handlers/admin_cli.rs @@ -432,16 +432,20 @@ async fn dispatch_admin_cli( &project_root, global_db, context.code_index_schedulers, + context.cancellation.clone(), + context.deadline.clone(), ) .await? } (None, None) => { - tracedecay_maintenance::retention::storage_report::build_storage_report_page_from_registered_global_db( + tracedecay_maintenance::retention::storage_report::build_storage_report_page_from_registered_global_db_controlled( profile_root, global_db, context.code_index_schedulers, cursor.as_deref(), limit, + context.cancellation.clone(), + context.deadline.clone(), ) .await? } @@ -979,7 +983,11 @@ mod tests { assert_eq!(typed.unregistered_bytes, 4096); assert_eq!( typed.coverage, - serde_json::json!({"state": "complete", "next_cursor": null}) + serde_json::json!({ + "state": "complete", + "next_cursor": null, + "unregistered_size": {"state": "complete", "unavailable_entry_count": 0} + }) ); } } From c73e9ef0ba39cd473544fb0aca6d06ad28211261 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:48:26 +0000 Subject: [PATCH 03/11] fix(storage-report): budget retention inventory scans Co-authored-by: ScriptedAlchemy <25274700+ScriptedAlchemy@users.noreply.github.com> --- .../src/code_index_generations.rs | 150 ++++++++-- .../code_index_generations/text_artifacts.rs | 32 ++- .../src/retention/storage_report.rs | 259 ++++++++++++++++-- 3 files changed, 395 insertions(+), 46 deletions(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations.rs b/crates/tracedecay-code-index-retention/src/code_index_generations.rs index 8f2c18cd80..96123504c5 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -93,14 +93,14 @@ use scope_roots::{ }; use text_artifacts::{ TEXT_ARTIFACT_RECEIPT_STORE, execute_text_artifact_retention_under_store_lock, - plan_collectable_text_artifacts_cancellable, + plan_collectable_text_artifacts_budgeted_cancellable, recover_pending_text_artifact_transaction_unlocked, text_artifact_transaction_path, total_text_artifact_bytes, }; #[cfg(test)] use text_artifacts::{ TEXT_ARTIFACT_TRANSACTION_JOURNAL, build_text_artifact_receipt, - stage_collectable_text_artifacts, + plan_collectable_text_artifacts_cancellable, stage_collectable_text_artifacts, }; use generation_scan::{read_generation_format_revision, read_generation_metadata}; @@ -460,6 +460,8 @@ pub enum CodeGenerationRetentionErrorV1 { Conflict(String), #[error("code-generation retention cancelled")] Cancelled, + #[error("code-generation retention entry budget exceeded")] + EntryBudgetExceeded, #[error("code-generation retention deferred: graph replay pool is busy")] GraphReplayPoolBusy, #[error("code-generation retention deferred: generation store is busy")] @@ -1007,13 +1009,34 @@ pub fn prepare_next_code_generation_retention_cancellable( /// Build a read-only retention plan while observing the caller's cancellation /// authority between directory entries and bounded digest reads. -#[tracing::instrument(name = "usecases.retention.plan", level = "trace", skip_all)] pub fn plan_code_generation_retention_with_verification_cancellable( store_root: &Path, vector_readable_sources: &BTreeSet, verification: GenerationDigestVerificationV1, graph_replay_pool_root: Option<&Path>, is_cancelled: &dyn Fn() -> bool, +) -> Result { + let mut remaining_entries = usize::MAX; + plan_code_generation_retention_with_verification_budgeted_cancellable( + store_root, + vector_readable_sources, + verification, + graph_replay_pool_root, + &mut remaining_entries, + is_cancelled, + ) +} + +/// Build a read-only retention plan while sharing an entry budget with the +/// caller's surrounding filesystem census. +#[tracing::instrument(name = "usecases.retention.plan", level = "trace", skip_all)] +pub fn plan_code_generation_retention_with_verification_budgeted_cancellable( + store_root: &Path, + vector_readable_sources: &BTreeSet, + verification: GenerationDigestVerificationV1, + graph_replay_pool_root: Option<&Path>, + remaining_entries: &mut usize, + is_cancelled: &dyn Fn() -> bool, ) -> Result { if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); @@ -1057,6 +1080,10 @@ pub fn plan_code_generation_retention_with_verification_cancellable( if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); } + if *remaining_entries == 0 { + return Err(CodeGenerationRetentionErrorV1::EntryBudgetExceeded); + } + *remaining_entries -= 1; let entry = entry.map_err(storage)?; let path = entry.path(); let Some(file_name) = generation_file_name(&path) else { @@ -1213,10 +1240,11 @@ pub fn plan_code_generation_retention_with_verification_cancellable( .take(MAX_CODE_GENERATION_RETENTION_BATCH_V1) .cloned() .collect::>(); - let text_artifact_inventory = plan_collectable_text_artifacts_cancellable( + let text_artifact_inventory = plan_collectable_text_artifacts_budgeted_cancellable( store_root, active_pointer.as_ref(), verification, + remaining_entries, is_cancelled, )?; // The segment mark phase streams every retained manifest end to end and @@ -1230,10 +1258,11 @@ pub fn plan_code_generation_retention_with_verification_cancellable( // without a second full-digest pass. let collectable_generation_segments = match verification { GenerationDigestVerificationV1::Full => { - if has_unreferenced_generation_segments( + if has_unreferenced_generation_segments_budgeted( store_root, graph_replay_pool_root, is_cancelled, + remaining_entries, )? { GenerationSegmentCensusV1::Present } else { @@ -1241,7 +1270,7 @@ pub fn plan_code_generation_retention_with_verification_cancellable( } } GenerationDigestVerificationV1::MetadataOnly => { - if store_may_hold_generation_segments(store_root, is_cancelled)? { + if store_may_hold_generation_segments(store_root, is_cancelled, remaining_entries)? { GenerationSegmentCensusV1::Unknown } else { GenerationSegmentCensusV1::NoneFound @@ -1317,13 +1346,17 @@ fn sweep_unreferenced_generation_segments( graph_replay_pool_root: Option<&Path>, apply: bool, is_cancelled: &dyn Fn() -> bool, + remaining_entries: Option<&mut usize>, ) -> Result<(bool, bool), CodeGenerationRetentionErrorV1> { + let mut remaining_entries = remaining_entries; let segments_root = code_generation_segments_root(store_root); let entries = match std::fs::read_dir(&segments_root) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((false, false)), Err(error) => return Err(storage(error)), }; + let manifest_directories = + segment_manifest_directories(store_root, &mut remaining_entries, is_cancelled)?; let mut live_segments = BTreeSet::new(); let mut retired_read_bundle_manifests = Vec::new(); let mut mark_root = |root: &Path, @@ -1338,6 +1371,7 @@ fn sweep_unreferenced_generation_segments( if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); } + charge_entry_budget(&mut remaining_entries)?; let manifest = manifest.map_err(storage)?; let path = manifest.path(); let expected_digest = if replay_pool { @@ -1421,7 +1455,7 @@ fn sweep_unreferenced_generation_segments( } Ok(()) }; - for directory in segment_manifest_directories(store_root)? { + for directory in manifest_directories { mark_root(&directory, false)?; } if let Some(pool_root) = graph_replay_pool_root { @@ -1447,6 +1481,7 @@ fn sweep_unreferenced_generation_segments( if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); } + charge_entry_budget(&mut remaining_entries)?; let entry = entry.map_err(storage)?; let path = entry.path(); let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { @@ -1491,7 +1526,23 @@ fn sweep_unreferenced_generation_segments( )) } -fn subdirectories(root: &Path) -> Result, CodeGenerationRetentionErrorV1> { +fn charge_entry_budget( + remaining_entries: &mut Option<&mut usize>, +) -> Result<(), CodeGenerationRetentionErrorV1> { + if let Some(remaining) = remaining_entries.as_deref_mut() { + if *remaining == 0 { + return Err(CodeGenerationRetentionErrorV1::EntryBudgetExceeded); + } + *remaining -= 1; + } + Ok(()) +} + +fn subdirectories( + root: &Path, + remaining_entries: &mut Option<&mut usize>, + is_cancelled: &dyn Fn() -> bool, +) -> Result, CodeGenerationRetentionErrorV1> { let entries = match std::fs::read_dir(root) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), @@ -1499,6 +1550,10 @@ fn subdirectories(root: &Path) -> Result, CodeGenerationRetentionEr }; let mut directories = Vec::new(); for entry in entries { + if is_cancelled() { + return Err(CodeGenerationRetentionErrorV1::Cancelled); + } + charge_entry_budget(remaining_entries)?; let entry = entry.map_err(storage)?; if entry.file_type().map_err(storage)?.is_dir() { directories.push(entry.path()); @@ -1510,7 +1565,11 @@ fn subdirectories(root: &Path) -> Result, CodeGenerationRetentionEr /// The scopes whose manifests can name the store's segments: the store alone, /// or in a shared project every worktree scope, including scopes quarantined /// by scope collection, which a recovery may still restore. -fn segment_scope_roots(store_root: &Path) -> Result, CodeGenerationRetentionErrorV1> { +fn segment_scope_roots( + store_root: &Path, + remaining_entries: &mut Option<&mut usize>, + is_cancelled: &dyn Fn() -> bool, +) -> Result, CodeGenerationRetentionErrorV1> { if !shares_project_generation_segments(store_root) { return Ok(vec![store_root.to_path_buf()]); } @@ -1520,12 +1579,20 @@ fn segment_scope_roots(store_root: &Path) -> Result, CodeGeneration .and_then(|name| name.to_str()) .is_some_and(is_code_index_scope_hash) }; - let mut scopes = subdirectories(project_root)? + let mut scopes = subdirectories(project_root, remaining_entries, is_cancelled)? .into_iter() .filter(is_scope) .collect::>(); - for stage in subdirectories(&project_root.join(SCOPE_RETENTION_QUARANTINE_DIRECTORY))? { - scopes.extend(subdirectories(&stage)?.into_iter().filter(is_scope)); + for stage in subdirectories( + &project_root.join(SCOPE_RETENTION_QUARANTINE_DIRECTORY), + remaining_entries, + is_cancelled, + )? { + scopes.extend( + subdirectories(&stage, remaining_entries, is_cancelled)? + .into_iter() + .filter(is_scope), + ); } Ok(scopes) } @@ -1534,11 +1601,17 @@ fn segment_scope_roots(store_root: &Path) -> Result, CodeGeneration /// scope's generations and the stages its retention quarantine may restore. fn segment_manifest_directories( store_root: &Path, + remaining_entries: &mut Option<&mut usize>, + is_cancelled: &dyn Fn() -> bool, ) -> Result, CodeGenerationRetentionErrorV1> { let mut directories = Vec::new(); - for scope in segment_scope_roots(store_root)? { + for scope in segment_scope_roots(store_root, remaining_entries, is_cancelled)? { directories.push(scope.join(GENERATIONS_DIRECTORY)); - directories.extend(subdirectories(&scope.join(QUARANTINE_DIRECTORY))?); + directories.extend(subdirectories( + &scope.join(QUARANTINE_DIRECTORY), + remaining_entries, + is_cancelled, + )?); } Ok(directories) } @@ -1563,6 +1636,7 @@ fn replay_generation_file_digest(file_name: &str) -> Option<&str> { fn store_may_hold_generation_segments( store_root: &Path, is_cancelled: &dyn Fn() -> bool, + remaining_entries: &mut usize, ) -> Result { let mut entries = match std::fs::read_dir(code_generation_segments_root(store_root)) { Ok(entries) => entries, @@ -1572,11 +1646,14 @@ fn store_may_hold_generation_segments( if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); } - entries - .next() - .transpose() - .map(|entry| entry.is_some()) - .map_err(storage) + let Some(_entry) = entries.next().transpose().map_err(storage)? else { + return Ok(false); + }; + if *remaining_entries == 0 { + return Err(CodeGenerationRetentionErrorV1::EntryBudgetExceeded); + } + *remaining_entries -= 1; + Ok(true) } fn has_unreferenced_generation_segments( @@ -1584,8 +1661,29 @@ fn has_unreferenced_generation_segments( graph_replay_pool_root: Option<&Path>, is_cancelled: &dyn Fn() -> bool, ) -> Result { - sweep_unreferenced_generation_segments(store_root, graph_replay_pool_root, false, is_cancelled) - .map(|(found, _)| found) + let mut remaining_entries = usize::MAX; + has_unreferenced_generation_segments_budgeted( + store_root, + graph_replay_pool_root, + is_cancelled, + &mut remaining_entries, + ) +} + +fn has_unreferenced_generation_segments_budgeted( + store_root: &Path, + graph_replay_pool_root: Option<&Path>, + is_cancelled: &dyn Fn() -> bool, + remaining_entries: &mut usize, +) -> Result { + sweep_unreferenced_generation_segments( + store_root, + graph_replay_pool_root, + false, + is_cancelled, + Some(remaining_entries), + ) + .map(|(found, _)| found) } fn collect_unreferenced_generation_segments( @@ -1593,8 +1691,14 @@ fn collect_unreferenced_generation_segments( graph_replay_pool_root: Option<&Path>, is_cancelled: &dyn Fn() -> bool, ) -> Result { - sweep_unreferenced_generation_segments(store_root, graph_replay_pool_root, true, is_cancelled) - .map(|(_, batch_exhausted)| batch_exhausted) + sweep_unreferenced_generation_segments( + store_root, + graph_replay_pool_root, + true, + is_cancelled, + None, + ) + .map(|(_, batch_exhausted)| batch_exhausted) } /// `graph_replay_pool_root` is the project graph's replay pool. When present, diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs index c5061766b0..4bcf2b46ef 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs @@ -224,11 +224,29 @@ pub fn withdraw_verified_text_artifact_under_lock( /// builder names its staging database, in this scope's staging directory, /// with the sealed generation digest, so the active generation's staging is /// preserved rather than guessed dead by wall-clock age. +#[cfg(test)] pub(super) fn plan_collectable_text_artifacts_cancellable( store_root: &Path, active_pointer: Option<&DurablePublicationPointerV1>, verification: GenerationDigestVerificationV1, is_cancelled: &dyn Fn() -> bool, +) -> Result { + let mut remaining_entries = usize::MAX; + plan_collectable_text_artifacts_budgeted_cancellable( + store_root, + active_pointer, + verification, + &mut remaining_entries, + is_cancelled, + ) +} + +pub(super) fn plan_collectable_text_artifacts_budgeted_cancellable( + store_root: &Path, + active_pointer: Option<&DurablePublicationPointerV1>, + verification: GenerationDigestVerificationV1, + remaining_entries: &mut usize, + is_cancelled: &dyn Fn() -> bool, ) -> Result { if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); @@ -285,6 +303,10 @@ pub(super) fn plan_collectable_text_artifacts_cancellable( if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); } + if *remaining_entries == 0 { + return Err(CodeGenerationRetentionErrorV1::EntryBudgetExceeded); + } + *remaining_entries -= 1; verify_completed_text_artifact( &root.join(&descriptor.artifact_file), descriptor, @@ -303,6 +325,10 @@ pub(super) fn plan_collectable_text_artifacts_cancellable( }); let mut active_text_replacement_in_flight = false; if let Some(active_staging_source) = active_staging_source { + if *remaining_entries == 0 { + return Err(CodeGenerationRetentionErrorV1::EntryBudgetExceeded); + } + *remaining_entries -= 1; let active_staging_file = format!(".text-artifact-{active_staging_source}.staging"); let active_staging_path = staging_root.join(&active_staging_file); match std::fs::symlink_metadata(&active_staging_path) { @@ -329,13 +355,17 @@ pub(super) fn plan_collectable_text_artifacts_cancellable( } let mut entries = std::fs::read_dir(directory).map_err(storage)?; while remaining > 0 && candidates.len() < MAX_CODE_TEXT_ARTIFACT_RETENTION_BATCH_V1 { - remaining -= 1; if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); } let Some(entry) = entries.next() else { break; }; + if *remaining_entries == 0 { + return Err(CodeGenerationRetentionErrorV1::EntryBudgetExceeded); + } + *remaining_entries -= 1; + remaining -= 1; let entry = entry.map_err(storage)?; let file_name = entry.file_name().into_string().map_err(|_| { CodeGenerationRetentionErrorV1::UnsafeState( diff --git a/crates/tracedecay-maintenance/src/retention/storage_report.rs b/crates/tracedecay-maintenance/src/retention/storage_report.rs index 85838954f5..abfc13a834 100644 --- a/crates/tracedecay-maintenance/src/retention/storage_report.rs +++ b/crates/tracedecay-maintenance/src/retention/storage_report.rs @@ -37,7 +37,8 @@ use tracedecay_domain::{ use tracedecay_code_index_retention::code_index_generations::{ CODE_TEXT_ARTIFACT_STAGING_DIRECTORY_V1, CODE_TEXT_ARTIFACTS_DIRECTORY_V1, CodeGenerationRetentionGenerationV1, GenerationDigestVerificationV1, - plan_code_generation_retention_with_verification_cancellable, scoped_code_index_store_root, + plan_code_generation_retention_with_verification_budgeted_cancellable, + scoped_code_index_store_root, }; use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1; use tracedecay_contracts::{CancellationSignal, Deadline, now_micros}; @@ -896,11 +897,13 @@ async fn build_storage_report_page_with_walk_budget( } }; let profile_root_buf = profile_root.to_path_buf(); + let directory_walk_control = walk_control.clone(); let directory_page = native_runtime::spawn_blocking(move || { - list_project_directories_page( + list_project_directories_page_controlled( &profile_root_buf, after_directory.as_deref().unwrap_or_default(), limit, + &directory_walk_control, ) }) .await @@ -1039,10 +1042,24 @@ fn list_project_directories_page( profile_root: &Path, cursor: &str, limit: usize, +) -> tracedecay_domain::errors::Result { + list_project_directories_page_controlled( + profile_root, + cursor, + limit, + &StorageReportWalkControl::default(), + ) +} + +fn list_project_directories_page_controlled( + profile_root: &Path, + cursor: &str, + limit: usize, + walk_control: &StorageReportWalkControl, ) -> tracedecay_domain::errors::Result { let projects_dir = profile_root.join("projects"); match std::fs::symlink_metadata(&projects_dir) { - Ok(_) => {} + Ok(metadata) if metadata.is_dir() => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(ProjectDirectoryPage { directories: Vec::new(), @@ -1055,16 +1072,26 @@ fn list_project_directories_page( message: format!("inspect storage report projects directory: {error}"), }); } + Ok(_) => { + return Err(tracedecay_domain::errors::TraceDecayError::Config { + message: "storage report projects path is not a directory".to_owned(), + }); + } } let page = super::orphan_stores::read_project_directory_page( profile_root, (!cursor.is_empty()).then_some(cursor), limit, - &|| false, + &|| walk_control.interruption().is_some(), )?; let Some(page) = page else { + let interruption = match walk_control.interruption() { + Some(WalkInterruptionV1::Cancelled) => "cancelled", + Some(WalkInterruptionV1::DeadlineExceeded) => "deadline exceeded", + None => "interrupted", + }; return Err(tracedecay_domain::errors::TraceDecayError::Config { - message: "storage report directory page was unexpectedly interrupted".to_owned(), + message: format!("storage report directory page {interruption}"), }); }; let directories = page @@ -1202,6 +1229,23 @@ fn build_project_storage_report_with_control( }) } +fn directory_chain_is_nofollow(root: &Path, directory: &Path) -> bool { + if !std::fs::symlink_metadata(root).is_ok_and(|metadata| metadata.is_dir()) { + return false; + } + let Ok(relative) = directory.strip_prefix(root) else { + return false; + }; + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component); + if !std::fs::symlink_metadata(¤t).is_ok_and(|metadata| metadata.is_dir()) { + return false; + } + } + true +} + fn append_project_report( profile_root: &Path, project_id: &str, @@ -1214,20 +1258,28 @@ fn append_project_report( walk_control: &StorageReportWalkControl, ) -> tracedecay_domain::errors::Result<()> { let data_root = profile_root.join("projects").join(project_id); + let code_index_root = data_root.join("code-index-v1"); let code_index_store_root = - scoped_code_index_store_root(&data_root.join("code-index-v1"), Path::new(canonical_root)); + scoped_code_index_store_root(&code_index_root, Path::new(canonical_root)); let generation_directory = code_index_store_root.join(CODE_GENERATIONS_DIRECTORY); - let active_pointer_present = code_index_store_root - .join("active-code-generation-v1.json") - .is_file(); + let active_pointer = code_index_store_root.join("active-code-generation-v1.json"); + let project_chain_is_nofollow = directory_chain_is_nofollow(profile_root, &data_root); + let active_pointer_present = project_chain_is_nofollow + && directory_chain_is_nofollow(profile_root, &code_index_store_root) + && std::fs::symlink_metadata(&active_pointer).is_ok_and(|metadata| metadata.is_file()); let measure_generation_bytes = active_pointer_present && vector_readable_sources.is_ok(); let mut generation_bytes = 0u64; let mut generation_unavailable_entry_count = 0usize; + let mut code_index_unavailable_entry_count = 0usize; let mut termination = WalkTerminationV1::Complete; let mut kinds = StoreKindBytesV1::default(); let mut unavailable_entry_count = 0usize; - match std::fs::symlink_metadata(&data_root) { - Ok(metadata) if metadata.is_dir() => { + match ( + project_chain_is_nofollow, + std::fs::symlink_metadata(&data_root), + ) { + (false, _) => unavailable_entry_count = 1, + (true, Ok(metadata)) if metadata.is_dir() => { termination = walk_regular_files_with_budget( &data_root, remaining_entries, @@ -1243,6 +1295,10 @@ fn append_project_report( } WalkedEntryV1::Unavailable => { unavailable_entry_count = unavailable_entry_count.saturating_add(1); + if path.starts_with(&code_index_root) { + code_index_unavailable_entry_count = + code_index_unavailable_entry_count.saturating_add(1); + } if measure_generation_bytes && path.starts_with(&generation_directory) { generation_unavailable_entry_count = generation_unavailable_entry_count.saturating_add(1); @@ -1251,18 +1307,21 @@ fn append_project_report( }, ); } - Ok(_) => unavailable_entry_count = 1, - Err(error) if error.kind() != std::io::ErrorKind::NotFound => { + (true, Ok(_)) => unavailable_entry_count = 1, + (true, Err(error)) if error.kind() != std::io::ErrorKind::NotFound => { unavailable_entry_count = 1; } - Err(_) => {} + (true, Err(_)) => {} } let total_bytes = kinds.total(); - let (free_bytes, free_page_ratio) = + let (free_bytes, free_page_ratio) = if project_chain_is_nofollow { sample_free_pages(&data_root.join(tracedecay_runtime_core::config::DB_FILENAME)) .map_or((None, None), |(free_bytes, ratio)| { (Some(free_bytes), Some(ratio)) - }); + }) + } else { + (None, None) + }; stores.push(StoreSizeReportEntry { project_id: project_id.to_owned(), canonical_root: canonical_root.to_owned(), @@ -1293,7 +1352,9 @@ fn append_project_report( } }; let scan_reason = match termination { - WalkTerminationV1::Complete if generation_unavailable_entry_count > 0 => { + WalkTerminationV1::Complete + if code_index_unavailable_entry_count > 0 || generation_unavailable_entry_count > 0 => + { Some("generation_retention_scan_unavailable") } WalkTerminationV1::Complete => match walk_control.interruption() { @@ -1328,17 +1389,25 @@ fn append_project_report( } else { GenerationDigestVerificationV1::Full }; - // The serving generation and live native-preview bindings are only - // provable from daemon authorities. Without them the dry run is reported - // unavailable rather than planned against an empty protection set. - let plan = match plan_code_generation_retention_with_verification_cancellable( + let plan = plan_code_generation_retention_with_verification_budgeted_cancellable( &code_index_store_root, &readable_sources, verification, None, + remaining_entries, &|| walk_control.interruption().is_some(), - ) { + ); + let plan = match plan { Ok(plan) => plan, + Err(tracedecay_code_index_retention::code_index_generations::CodeGenerationRetentionErrorV1::EntryBudgetExceeded) => { + code_generation_retention_availability.push(CodeGenerationRetentionAvailabilityEntry { + project_id: project_id.to_owned(), + store_root: code_index_store_root.display().to_string(), + state: StorageReportAvailabilityState::Unavailable, + reason: Some("generation_retention_scan_entry_budget_exceeded".to_owned()), + }); + return Ok(()); + } Err(_) => { let reason = match walk_control.interruption() { Some(WalkInterruptionV1::Cancelled) => "generation_retention_scan_cancelled", @@ -1454,6 +1523,11 @@ fn sqlite_family_member(database_path: &Path, suffix: &str) -> PathBuf { fn sample_free_pages(graph_db_path: &Path) -> Option<(u64, f64)> { use std::io::Read as _; + if !std::fs::symlink_metadata(graph_db_path) + .is_ok_and(|metadata| metadata.file_type().is_file()) + { + return None; + } let mut header = [0_u8; 100]; std::fs::File::open(graph_db_path) .ok()? @@ -2019,6 +2093,24 @@ mod tests { assert!(exhausted.coverage.next_cursor.is_none()); } + #[test] + fn cancelled_directory_inventory_stops_on_the_blocking_worker() { + let tmp = tempfile::TempDir::new().unwrap(); + let profile_root = tmp.path().join("profile"); + std::fs::create_dir_all(profile_root.join("projects/proj_a")).unwrap(); + let cancellation = CancellationSignal::active("storage-directory-test").unwrap(); + cancellation.cancel(now_micros()); + let control = StorageReportWalkControl { + cancellation: Some(cancellation), + deadline: None, + }; + + let error = + list_project_directories_page_controlled(&profile_root, "", 1, &control).unwrap_err(); + + assert!(error.to_string().contains("cancelled")); + } + #[test] fn project_directory_report_rejects_invalidated_inventory_positions() { let tmp = tempfile::TempDir::new().unwrap(); @@ -2987,6 +3079,83 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn a_symlinked_code_index_store_is_sized_as_unavailable_not_planned() { + let tmp = tempfile::TempDir::new().unwrap(); + let profile_root = tmp.path().join("profile"); + let data_root = profile_root.join("projects/proj_a"); + std::fs::create_dir_all(&data_root).unwrap(); + let outside_index = tmp.path().join("outside-code-index"); + let canonical_root = Path::new("/repos/a"); + let outside_store = scoped_code_index_store_root(&outside_index, canonical_root); + tracedecay_code_index_retention::code_index_generations::fixture::write_generation_store_fixture( + &outside_store, + 1, + ); + std::os::unix::fs::symlink(&outside_index, data_root.join("code-index-v1")).unwrap(); + let mut stores = Vec::new(); + let mut plans = Vec::new(); + let mut availability = Vec::new(); + let mut remaining = MAX_STORAGE_REPORT_WALK_ENTRIES; + + append_project_report( + &profile_root, + "proj_a", + "/repos/a", + Ok(BTreeSet::new()), + &mut stores, + &mut plans, + &mut availability, + &mut remaining, + &StorageReportWalkControl::default(), + ) + .unwrap(); + + assert_eq!(stores[0].total_bytes, 0); + assert_eq!( + stores[0].size_coverage.state, + StorageReportSizeStateV1::Unavailable + ); + assert!(plans.is_empty()); + assert!(availability.is_empty()); + } + + #[cfg(unix)] + #[test] + fn a_symlinked_projects_ancestor_is_not_traversed() { + let tmp = tempfile::TempDir::new().unwrap(); + let profile_root = tmp.path().join("profile"); + std::fs::create_dir_all(&profile_root).unwrap(); + let outside_projects = tmp.path().join("outside-projects"); + std::fs::create_dir_all(outside_projects.join("proj_a")).unwrap(); + std::fs::write(outside_projects.join("proj_a/graph.db"), [7; 16]).unwrap(); + std::os::unix::fs::symlink(&outside_projects, profile_root.join("projects")).unwrap(); + let mut stores = Vec::new(); + let mut remaining = MAX_STORAGE_REPORT_WALK_ENTRIES; + + append_project_report( + &profile_root, + "proj_a", + "/repos/a", + Ok(BTreeSet::new()), + &mut stores, + &mut Vec::new(), + &mut Vec::new(), + &mut remaining, + &StorageReportWalkControl::default(), + ) + .unwrap(); + + assert_eq!(stores[0].total_bytes, 0); + assert_eq!( + stores[0].size_coverage.state, + StorageReportSizeStateV1::Unavailable + ); + assert_eq!(stores[0].unavailable_entry_count, 1); + assert_eq!(stores[0].free_bytes, None); + } + #[test] fn zero_byte_generation_files_cannot_evade_the_shared_entry_budget() { let tmp = tempfile::TempDir::new().unwrap(); @@ -3032,6 +3201,52 @@ mod tests { ); } + #[test] + fn retention_inventory_uses_the_remaining_call_budget() { + let tmp = tempfile::TempDir::new().unwrap(); + let profile_root = tmp.path().join("profile"); + let data_root = profile_root.join("projects/proj_a"); + let canonical_root = Path::new("/repos/a"); + let store_root = + scoped_code_index_store_root(&data_root.join("code-index-v1"), canonical_root); + tracedecay_code_index_retention::code_index_generations::fixture::write_generation_store_fixture( + &store_root, + 1, + ); + let mut census_remaining = usize::MAX; + assert_eq!( + walk_regular_files_with_budget( + &data_root, + &mut census_remaining, + &|| None, + &mut |_, _| {}, + ), + WalkTerminationV1::Complete + ); + let size_walk_entries = usize::MAX - census_remaining; + let mut remaining = size_walk_entries + 1; + let mut availability = Vec::new(); + + append_project_report( + &profile_root, + "proj_a", + "/repos/a", + Ok(BTreeSet::new()), + &mut Vec::new(), + &mut Vec::new(), + &mut availability, + &mut remaining, + &StorageReportWalkControl::default(), + ) + .unwrap(); + + assert_eq!(remaining, 0); + assert_eq!( + availability[0].reason.as_deref(), + Some("generation_retention_scan_entry_budget_exceeded") + ); + } + #[test] fn partial_sizing_and_full_census_coverage_survive_report_serialization() { let mut partial_store = store("alpha", 4); From f829e7321daa763db9d1f79544a9a886692af67a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:05:44 -0700 Subject: [PATCH 04/11] fix(storage): share report budgets and preserve coverage --- .../src/commands/profile_storage.rs | 193 +++++++++++++----- .../src/code_index_generations/tests.rs | 69 +++++++ .../code_index_generations/text_artifacts.rs | 26 ++- .../src/retention/footprint.rs | 81 +++++--- .../src/retention/storage_report.rs | 140 +++++++++++-- .../tracedecay-mcp/src/handlers/admin_cli.rs | 3 +- 6 files changed, 403 insertions(+), 109 deletions(-) diff --git a/crates/tracedecay-cli/src/commands/profile_storage.rs b/crates/tracedecay-cli/src/commands/profile_storage.rs index 15b4eba671..1d4cc12d1f 100644 --- a/crates/tracedecay-cli/src/commands/profile_storage.rs +++ b/crates/tracedecay-cli/src/commands/profile_storage.rs @@ -6,6 +6,10 @@ use tracedecay_runtime_core::native_runtime; use crate::cli::ProfileStorageAction; use tracedecay_contracts::retrieval::{AdminCliResultV1, AdminCliSurfaceRequestV1}; use tracedecay_global_db::profile_registry_maintenance::remove_store_directory; +use tracedecay_maintenance::retention::storage_report::{ + StorageReport, StorageReportCoverageState, StorageReportSizeCoverageV1, + StorageReportSizeReasonV1, StorageReportSizeStateV1, StoreSizeReportEntry, +}; use tracedecay_runtime_core::lifecycle_lease::{ ExclusiveLeaseAttempt, try_acquire_exclusive_for_profile, }; @@ -310,14 +314,21 @@ async fn brokered_storage_report( Ok(report) } -fn merge_storage_report_page( - report: &mut tracedecay_maintenance::retention::storage_report::StorageReport, - page: tracedecay_maintenance::retention::storage_report::StorageReport, -) { - let unregistered_size = report - .coverage - .unregistered_size - .merge(page.coverage.unregistered_size); +fn merge_storage_report_page(report: &mut StorageReport, page: StorageReport) { + // Project pages and empty directory pages carry no backlog sample. Their + // default Complete coverage is neutral, not evidence of measured bytes. + let unregistered_size = if !has_unregistered_size_sample(report) { + page.coverage.unregistered_size + } else if !has_unregistered_size_sample(&page) { + report.coverage.unregistered_size + } else { + report + .coverage + .unregistered_size + .merge(page.coverage.unregistered_size) + }; + let unregistered_count_is_lower_bound = report.coverage.unregistered_count_is_lower_bound + || page.coverage.unregistered_count_is_lower_bound; if report.profile_root.is_empty() { report.profile_root = page.profile_root; } @@ -340,6 +351,13 @@ fn merge_storage_report_page( .max(page.user_sessions_db_bytes); report.coverage = page.coverage; report.coverage.unregistered_size = unregistered_size; + report.coverage.unregistered_count_is_lower_bound = unregistered_count_is_lower_bound; +} + +fn has_unregistered_size_sample(report: &StorageReport) -> bool { + report.unregistered_dir_count > 0 + || report.unregistered_bytes > 0 + || report.coverage.unregistered_size != StorageReportSizeCoverageV1::default() } /// Read-only per-store size / free-page-ratio / unregistered-directory report @@ -552,7 +570,11 @@ async fn handle_storage_report( } println!( " unregistered directories: {} ({})", - report.unregistered_dir_count, + storage_directory_count_label( + report.unregistered_dir_count, + report.coverage.unregistered_count_is_lower_bound + || report.coverage.state == StorageReportCoverageState::Partial, + ), storage_size_label(report.unregistered_bytes, report.coverage.unregistered_size) ); if report.coverage.unregistered_size.state @@ -598,66 +620,58 @@ async fn handle_storage_report( Ok(()) } -fn storage_size_label( - bytes: u64, - coverage: tracedecay_maintenance::retention::storage_report::StorageReportSizeCoverageV1, -) -> String { - use tracedecay_maintenance::retention::storage_report::StorageReportSizeStateV1 as State; - +fn storage_size_label(bytes: u64, coverage: StorageReportSizeCoverageV1) -> String { match coverage.state { - State::Complete => format!("{} total", format_bytes(bytes)), - State::Partial => format!("at least {} (partial)", format_bytes(bytes)), - State::Unavailable => format!("size unavailable ({} measured)", format_bytes(bytes)), + StorageReportSizeStateV1::Complete => format!("{} total", format_bytes(bytes)), + StorageReportSizeStateV1::Partial => format!("at least {} (partial)", format_bytes(bytes)), + StorageReportSizeStateV1::Unavailable => { + format!("size unavailable ({} measured)", format_bytes(bytes)) + } } } -fn effective_store_size_coverage( - store: &tracedecay_maintenance::retention::storage_report::StoreSizeReportEntry, -) -> tracedecay_maintenance::retention::storage_report::StorageReportSizeCoverageV1 { - use tracedecay_maintenance::retention::storage_report::{ - StorageReportSizeCoverageV1, StorageReportSizeReasonV1, StorageReportSizeStateV1 as State, - }; +fn storage_directory_count_label(count: usize, is_lower_bound: bool) -> String { + if is_lower_bound { + format!("at least {count}") + } else { + count.to_string() + } +} +fn effective_store_size_coverage(store: &StoreSizeReportEntry) -> StorageReportSizeCoverageV1 { let mut coverage = store.size_coverage; coverage.unavailable_entry_count = coverage .unavailable_entry_count .max(store.unavailable_entry_count); - if coverage.state == State::Complete && store.unavailable_entry_count > 0 { + if coverage.state == StorageReportSizeStateV1::Complete && store.unavailable_entry_count > 0 { coverage.state = if store.total_bytes == 0 { - State::Unavailable + StorageReportSizeStateV1::Unavailable } else { - State::Partial + StorageReportSizeStateV1::Partial }; coverage.reason = Some(StorageReportSizeReasonV1::UnreadableEntries); } - if coverage.state == State::Complete && coverage.unavailable_entry_count == 0 { + if coverage.state == StorageReportSizeStateV1::Complete && coverage.unavailable_entry_count == 0 + { return StorageReportSizeCoverageV1::default(); } coverage } -fn storage_coverage_state_label( - state: tracedecay_maintenance::retention::storage_report::StorageReportSizeStateV1, -) -> &'static str { - use tracedecay_maintenance::retention::storage_report::StorageReportSizeStateV1 as State; - +fn storage_coverage_state_label(state: StorageReportSizeStateV1) -> &'static str { match state { - State::Complete => "complete", - State::Partial => "partial lower bound", - State::Unavailable => "unavailable", + StorageReportSizeStateV1::Complete => "complete", + StorageReportSizeStateV1::Partial => "partial lower bound", + StorageReportSizeStateV1::Unavailable => "unavailable", } } -fn storage_coverage_reason_label( - reason: tracedecay_maintenance::retention::storage_report::StorageReportSizeReasonV1, -) -> &'static str { - use tracedecay_maintenance::retention::storage_report::StorageReportSizeReasonV1 as Reason; - +fn storage_coverage_reason_label(reason: StorageReportSizeReasonV1) -> &'static str { match reason { - Reason::UnreadableEntries => "unreadable entries", - Reason::EntryBudgetExceeded => "entry budget exceeded", - Reason::Cancelled => "cancelled", - Reason::DeadlineExceeded => "deadline exceeded", + StorageReportSizeReasonV1::UnreadableEntries => "unreadable entries", + StorageReportSizeReasonV1::EntryBudgetExceeded => "entry budget exceeded", + StorageReportSizeReasonV1::Cancelled => "cancelled", + StorageReportSizeReasonV1::DeadlineExceeded => "deadline exceeded", } } @@ -744,10 +758,95 @@ mod footprint_output_tests { #[cfg(test)] mod storage_coverage_output_tests { use tracedecay_maintenance::retention::storage_report::{ - StorageReportSizeCoverageV1, StorageReportSizeReasonV1, StorageReportSizeStateV1, + StorageReport, StorageReportCoverage, StorageReportSizeCoverageV1, + StorageReportSizeReasonV1, StorageReportSizeStateV1, }; - use super::storage_size_label; + use super::{merge_storage_report_page, storage_directory_count_label, storage_size_label}; + + #[test] + fn unavailable_backlog_stays_unavailable_across_empty_and_unavailable_pages() { + let mut report = StorageReport::default(); + let empty_page = StorageReport { + profile_root: "/profile".to_owned(), + ..StorageReport::default() + }; + merge_storage_report_page(&mut report, empty_page.clone()); + let unavailable_page = StorageReport { + profile_root: "/profile".to_owned(), + unregistered_dir_count: 1, + coverage: StorageReportCoverage { + unregistered_size: StorageReportSizeCoverageV1 { + state: StorageReportSizeStateV1::Unavailable, + reason: Some(StorageReportSizeReasonV1::UnreadableEntries), + unavailable_entry_count: 1, + }, + ..StorageReportCoverage::default() + }, + ..StorageReport::default() + }; + merge_storage_report_page(&mut report, unavailable_page.clone()); + assert_eq!( + report.coverage.unregistered_size.state, + StorageReportSizeStateV1::Unavailable + ); + merge_storage_report_page(&mut report, unavailable_page); + merge_storage_report_page(&mut report, empty_page); + assert_eq!(report.unregistered_dir_count, 2); + assert_eq!(report.unregistered_bytes, 0); + assert_eq!( + report.coverage.unregistered_size.state, + StorageReportSizeStateV1::Unavailable + ); + assert_eq!(report.coverage.unregistered_size.unavailable_entry_count, 2); + + merge_storage_report_page( + &mut report, + StorageReport { + profile_root: "/profile".to_owned(), + unregistered_dir_count: 1, + unregistered_bytes: 7, + ..StorageReport::default() + }, + ); + assert_eq!(report.unregistered_bytes, 7); + assert_eq!( + report.coverage.unregistered_size.state, + StorageReportSizeStateV1::Partial + ); + } + + #[test] + fn truncated_directory_counts_remain_lower_bounds_after_page_aggregation() { + let mut report = StorageReport::default(); + merge_storage_report_page( + &mut report, + StorageReport { + coverage: StorageReportCoverage { + unregistered_count_is_lower_bound: true, + ..StorageReportCoverage::default() + }, + ..StorageReport::default() + }, + ); + merge_storage_report_page( + &mut report, + StorageReport { + unregistered_dir_count: 2, + ..StorageReport::default() + }, + ); + assert!(report.coverage.unregistered_count_is_lower_bound); + assert_eq!(storage_directory_count_label(0, true), "at least 0"); + assert_eq!( + storage_directory_count_label( + report.unregistered_dir_count, + report.coverage.unregistered_count_is_lower_bound + ), + "at least 2" + ); + assert_eq!(storage_directory_count_label(2, false), "2"); + } #[test] fn partial_sizes_render_as_lower_bounds_not_exact_totals() { diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 5efa84d3c2..76c7c5e091 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -1081,6 +1081,75 @@ fn text_artifact_retention_uses_bounded_restartable_batches() { ); } +#[test] +fn text_artifact_marks_charge_scope_enumeration_and_pointer_observations() { + let project = tempfile::tempdir().unwrap(); + let scopes = (0..4) + .map(|index| { + let scope = scoped_code_index_store_root( + project.path(), + Path::new(&format!("/worktree/{index}")), + ); + std::fs::create_dir_all(&scope).unwrap(); + scope + }) + .collect::>(); + + // Four directory entries plus four pointer observations are required to + // prove that this shared project has no published text-artifact marks. + // Neither an incomplete enumeration nor an incomplete mark is a plan. + for budget in [2, 7] { + let mut remaining = budget; + let error = plan_collectable_text_artifacts_budgeted_cancellable( + &scopes[0], + None, + GenerationDigestVerificationV1::MetadataOnly, + &mut remaining, + &|| false, + ) + .expect_err("a partial liveness mark cannot authorize collection"); + assert!(matches!( + error, + CodeGenerationRetentionErrorV1::EntryBudgetExceeded + )); + assert_eq!(remaining, 0); + } + let mut remaining = 8; + let inventory = plan_collectable_text_artifacts_budgeted_cancellable( + &scopes[0], + None, + GenerationDigestVerificationV1::MetadataOnly, + &mut remaining, + &|| false, + ) + .unwrap(); + assert!(inventory.candidates.is_empty()); + assert_eq!(remaining, 0); +} + +#[test] +fn text_artifact_marks_stop_before_observing_a_pointer_after_cancellation() { + let (store, _) = fixture_store(1); + let pointer = read_active_pointer(store.path()).unwrap(); + std::fs::write( + store.path().join(ACTIVE_POINTER_FILE), + b"unreadable pointer", + ) + .unwrap(); + let checks = std::sync::atomic::AtomicUsize::new(0); + let mut remaining = 1; + let error = plan_collectable_text_artifacts_budgeted_cancellable( + store.path(), + Some(&pointer), + GenerationDigestVerificationV1::MetadataOnly, + &mut remaining, + &|| checks.fetch_add(1, std::sync::atomic::Ordering::SeqCst) > 0, + ) + .expect_err("cancellation must win before reading the damaged pointer"); + assert!(matches!(error, CodeGenerationRetentionErrorV1::Cancelled)); + assert_eq!(remaining, 1); +} + #[test] fn text_artifact_inventory_honors_cancellation_before_marking_or_mutation() { let (store, _generations) = fixture_store(1); diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs index 4bcf2b46ef..9cb4e98dd1 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs @@ -28,10 +28,11 @@ use super::{ MAX_CODE_TEXT_ARTIFACT_RETENTION_BATCH_V1, MAX_DURABLE_PUBLICATION_POINTER_BYTES_V1, MAX_TRANSACTION_BYTES, TEXT_ARTIFACT_QUARANTINE_DIRECTORY, TEXT_ARTIFACT_RECEIPT_SCHEMA, TEXT_ARTIFACT_RECEIPTS_DIRECTORY, TEXT_ARTIFACT_TRANSACTION_FILE, - TEXT_ARTIFACT_TRANSACTION_SCHEMA, code_text_artifact_staging_root, code_text_artifacts_root, - durable_generation_index_digest, generation_file_digest, open_file_sha256_hex_cancellable, - path_still_names_open_file, read_active_pointer, read_optional_active_pointer, - regular_file_exists, retain_bounded_generation_index_with_text_head, sha256_file_component, + TEXT_ARTIFACT_TRANSACTION_SCHEMA, charge_entry_budget, code_text_artifact_staging_root, + code_text_artifacts_root, durable_generation_index_digest, generation_file_digest, + open_file_sha256_hex_cancellable, path_still_names_open_file, read_active_pointer, + read_optional_active_pointer, regular_file_exists, + retain_bounded_generation_index_with_text_head, segment_scope_roots, sha256_file_component, storage, validate_durable_generation_index, validate_sealed_generation_identity, validate_text_artifact_descriptor, }; @@ -272,7 +273,8 @@ pub(super) fn plan_collectable_text_artifacts_budgeted_cancellable( } } } - let marked = scope_text_artifact_marks(store_root)?; + let marked = + scope_text_artifact_marks(store_root, &mut Some(&mut *remaining_entries), is_cancelled)?; let active_staging_source = active_pointer .map(|pointer| { generation_file_digest(&pointer.generation_file).ok_or_else(|| { @@ -478,7 +480,7 @@ pub fn find_shared_text_artifact( content_key: &tracedecay_domain::ManifestDigest, ) -> Result, CodeGenerationRetentionErrorV1> { let root = code_text_artifacts_root(store_root); - for scope in super::segment_scope_roots(store_root)? { + for scope in segment_scope_roots(store_root, &mut None, &|| false)? { let Some(pointer) = read_optional_active_pointer(&scope)? else { continue; }; @@ -502,9 +504,17 @@ pub fn find_shared_text_artifact( /// restore. A store outside a shared project marks only itself. fn scope_text_artifact_marks( store_root: &Path, + remaining_entries: &mut Option<&mut usize>, + is_cancelled: &dyn Fn() -> bool, ) -> Result, CodeGenerationRetentionErrorV1> { let mut marked = BTreeSet::new(); - for scope in super::segment_scope_roots(store_root)? { + for scope in segment_scope_roots(store_root, remaining_entries, is_cancelled)? { + if is_cancelled() { + return Err(CodeGenerationRetentionErrorV1::Cancelled); + } + // Enumerating a scope and observing its publication pointer are + // separate filesystem work; neither may restart the caller's budget. + charge_entry_budget(remaining_entries)?; let Some(pointer) = read_optional_active_pointer(&scope)? else { continue; }; @@ -954,7 +964,7 @@ pub(super) fn ensure_text_artifact_transaction_liveness( // may have landed since the transaction was staged, in this scope or in // a sibling that shares the completed artifact, and no durable // descriptor target any of them names may be removed. - let marked = scope_text_artifact_marks(store_root)?; + let marked = scope_text_artifact_marks(store_root, &mut None, &|| false)?; if transaction .receipt .deleted_artifacts diff --git a/crates/tracedecay-maintenance/src/retention/footprint.rs b/crates/tracedecay-maintenance/src/retention/footprint.rs index eab28da351..3260550631 100644 --- a/crates/tracedecay-maintenance/src/retention/footprint.rs +++ b/crates/tracedecay-maintenance/src/retention/footprint.rs @@ -291,11 +291,12 @@ pub fn census_profile_footprint( registered_roots: &BTreeMap, bound_overrides: &BTreeMap, ) -> ProfileFootprintV1 { - census_with_entry_ceiling( + let mut remaining_entries = MAX_FOOTPRINT_ENTRIES; + census_profile_footprint_with_budget( profile_root, registered_roots, bound_overrides, - MAX_FOOTPRINT_ENTRIES, + &mut remaining_entries, ) } @@ -313,41 +314,49 @@ struct FootprintKeys { project: Option<(String, PathBuf)>, } -fn census_with_entry_ceiling( +/// Charge the profile census to the same entry budget as its caller's other +/// filesystem observations. +pub(crate) fn census_profile_footprint_with_budget( profile_root: &Path, registered_roots: &BTreeMap, bound_overrides: &BTreeMap, - entry_ceiling: usize, + remaining_entries: &mut usize, ) -> ProfileFootprintV1 { let mut tallies: BTreeMap<(String, ProfileFamilyV1), EntryTally> = BTreeMap::new(); let mut total_bytes = 0u64; let mut unavailable_entry_count = 0usize; - let truncated = walk_regular_files(profile_root, entry_ceiling, &mut |path, walked| { - if let WalkedEntryV1::File { bytes } = walked { - total_bytes = total_bytes.saturating_add(bytes); - } else { - unavailable_entry_count = unavailable_entry_count.saturating_add(1); - } - let Some(keys) = path - .strip_prefix(profile_root) - .ok() - .and_then(footprint_keys) - else { - return; - }; - let primary = tallies.entry(keys.primary).or_default(); - tally(primary, walked); - if let (Some((_, store_relative)), WalkedEntryV1::File { bytes }) = (&keys.project, walked) - { - primary - .kinds - .get_or_insert_with(StoreKindBytesV1::default) - .add(store_relative, bytes); - } - if let Some(overlay) = keys.overlay { - tally(tallies.entry(overlay).or_default(), walked); - } - }); + let termination = walk_regular_files_with_budget( + profile_root, + remaining_entries, + &|| None, + &mut |path, walked| { + if let WalkedEntryV1::File { bytes } = walked { + total_bytes = total_bytes.saturating_add(bytes); + } else { + unavailable_entry_count = unavailable_entry_count.saturating_add(1); + } + let Some(keys) = path + .strip_prefix(profile_root) + .ok() + .and_then(footprint_keys) + else { + return; + }; + let primary = tallies.entry(keys.primary).or_default(); + tally(primary, walked); + if let (Some((_, store_relative)), WalkedEntryV1::File { bytes }) = + (&keys.project, walked) + { + primary + .kinds + .get_or_insert_with(StoreKindBytesV1::default) + .add(store_relative, bytes); + } + if let Some(overlay) = keys.overlay { + tally(tallies.entry(overlay).or_default(), walked); + } + }, + ); let entries = tallies .into_iter() .map(|((key, family), tally)| { @@ -377,7 +386,7 @@ fn census_with_entry_ceiling( entries, total_bytes, unavailable_entry_count, - truncated, + truncated: termination == WalkTerminationV1::EntryBudgetExceeded, total_bound_bytes: bound_overrides .get(PROFILE_TOTAL_KEY) .copied() @@ -927,10 +936,16 @@ mod tests { #[test] fn a_walk_stopped_at_its_entry_ceiling_reports_a_floor() { let fixture = profile_fixture(); - let footprint = - census_with_entry_ceiling(&fixture.profile, &fixture.registered, &BTreeMap::new(), 3); + let mut remaining_entries = 3; + let footprint = census_profile_footprint_with_budget( + &fixture.profile, + &fixture.registered, + &BTreeMap::new(), + &mut remaining_entries, + ); assert!(footprint.truncated); + assert_eq!(remaining_entries, 0); assert_eq!( footprint.full_profile_size().state, ProfileTotalCoverageStateV1::Partial diff --git a/crates/tracedecay-maintenance/src/retention/storage_report.rs b/crates/tracedecay-maintenance/src/retention/storage_report.rs index abfc13a834..9e4956e919 100644 --- a/crates/tracedecay-maintenance/src/retention/storage_report.rs +++ b/crates/tracedecay-maintenance/src/retention/storage_report.rs @@ -26,6 +26,7 @@ //! size. use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::io::Read; use std::path::{Path, PathBuf}; use tracedecay_domain::errors::TraceDecayError; @@ -48,7 +49,8 @@ use tracedecay_runtime_core::storage::{SESSIONS_DB_FILENAME, USER_SESSIONS_DB_FI use super::footprint::{ MAX_FOOTPRINT_ENTRIES, ProfileFootprintV1, ProjectRootStateV1, WalkInterruptionV1, - WalkTerminationV1, WalkedEntryV1, census_profile_footprint, walk_regular_files_with_budget, + WalkTerminationV1, WalkedEntryV1, census_profile_footprint_with_budget, + walk_regular_files_with_budget, }; use crate::store_maintenance::{CodeGenerationProtectionUnavailableV1, code_generation_protection}; @@ -128,14 +130,19 @@ impl StorageReportSizeCoverageV1 { /// Combine coverage from separate directory pages without hiding an /// unavailable page or its unreadable-entry count. pub fn merge(self, other: Self) -> Self { - use StorageReportSizeStateV1 as State; - let state = match (self.state, other.state) { - (State::Complete, State::Complete) => State::Complete, - (State::Unavailable, State::Unavailable) => State::Unavailable, - (State::Partial, _) | (_, State::Partial) => State::Partial, - (State::Unavailable, State::Complete) | (State::Complete, State::Unavailable) => { - State::Partial + (StorageReportSizeStateV1::Complete, StorageReportSizeStateV1::Complete) => { + StorageReportSizeStateV1::Complete + } + (StorageReportSizeStateV1::Unavailable, StorageReportSizeStateV1::Unavailable) => { + StorageReportSizeStateV1::Unavailable + } + (StorageReportSizeStateV1::Partial, _) | (_, StorageReportSizeStateV1::Partial) => { + StorageReportSizeStateV1::Partial + } + (StorageReportSizeStateV1::Unavailable, StorageReportSizeStateV1::Complete) + | (StorageReportSizeStateV1::Complete, StorageReportSizeStateV1::Unavailable) => { + StorageReportSizeStateV1::Partial } }; Self { @@ -290,6 +297,12 @@ pub struct StorageReportCoverage { /// partially sampled subtree was fully sized. #[serde(default)] pub unregistered_size: StorageReportSizeCoverageV1, + /// Enumeration stopped before all unregistered directories were counted. + /// Independent of size coverage: all names can be counted even when a + /// subtree cannot be measured. Counts across unfinished pages also remain + /// lower bounds through the outer `state` and continuation cursor. + #[serde(default)] + pub unregistered_count_is_lower_bound: bool, } impl StorageReportCoverage { @@ -298,6 +311,7 @@ impl StorageReportCoverage { state: StorageReportCoverageState::Partial, next_cursor: Some(next_cursor), unregistered_size: StorageReportSizeCoverageV1::default(), + unregistered_count_is_lower_bound: false, } } } @@ -593,6 +607,13 @@ pub struct CodeGenerationRetentionAvailabilityEntry { #[tracing::instrument(name = "maintenance.storage_report.build", level = "trace", skip_all)] pub async fn build_storage_report( profile_root: &Path, +) -> tracedecay_domain::errors::Result { + build_storage_report_with_walk_budget(profile_root, MAX_STORAGE_REPORT_WALK_ENTRIES).await +} + +async fn build_storage_report_with_walk_budget( + profile_root: &Path, + walk_entry_budget: usize, ) -> tracedecay_domain::errors::Result { let global_db_path = profile_root.join(GLOBAL_DB_FILENAME); // The registry rows come from an off-profile copy, so reading them first @@ -606,24 +627,25 @@ pub async fn build_storage_report( // readers. A report must describe the profile supplied by its caller, not // implementation-dependent SQLite sidecar churn caused while collecting // the remaining diagnostic fields. - let full_profile_root = profile_root.to_path_buf(); let registered_roots: BTreeMap = registered_projects.iter().cloned().collect(); - let footprint = native_runtime::spawn_blocking(move || { - census_profile_footprint(&full_profile_root, ®istered_roots, &BTreeMap::new()) - }) - .await - .map_err(|error| report_error("join profile footprint census", error))?; let profile_root_buf = profile_root.to_path_buf(); let walk_control = StorageReportWalkControl::default(); - let sampled = native_runtime::spawn_blocking(move || { - let mut remaining_entries = MAX_STORAGE_REPORT_WALK_ENTRIES; - sample_registered_storage( + let (footprint, sampled) = native_runtime::spawn_blocking(move || { + let mut remaining_entries = walk_entry_budget; + let footprint = census_profile_footprint_with_budget( + &profile_root_buf, + ®istered_roots, + &BTreeMap::new(), + &mut remaining_entries, + ); + let sampled = sample_registered_storage( &profile_root_buf, &global_db_path, registered_projects, &mut remaining_entries, &walk_control, - ) + )?; + Ok::<_, TraceDecayError>((footprint, sampled)) }) .await .map_err(|error| report_error("join storage report sample", error))??; @@ -641,6 +663,7 @@ pub async fn build_storage_report( footprint: Some(footprint), coverage: StorageReportCoverage { unregistered_size: sampled.unregistered_size, + unregistered_count_is_lower_bound: sampled.unregistered_count_is_lower_bound, ..StorageReportCoverage::default() }, }) @@ -653,6 +676,7 @@ struct SampledRegisteredStorage { unregistered_dir_count: usize, unregistered_bytes: u64, unregistered_size: StorageReportSizeCoverageV1, + unregistered_count_is_lower_bound: bool, global_db_bytes: u64, user_sessions_db_bytes: u64, } @@ -766,6 +790,7 @@ fn sample_registered_storage( unregistered_dir_count: unregistered.directory_count, unregistered_bytes: unregistered.bytes, unregistered_size: unregistered.coverage, + unregistered_count_is_lower_bound: unregistered.count_is_lower_bound, global_db_bytes: database_family_bytes(global_db_path), user_sessions_db_bytes: user_sessions_family_bytes(profile_root), }) @@ -1521,8 +1546,6 @@ fn sqlite_family_member(database_path: &Path, suffix: &str) -> PathBuf { /// enough for a size report. `None` for a file that is not a SQLite database /// or whose header counts are stale. fn sample_free_pages(graph_db_path: &Path) -> Option<(u64, f64)> { - use std::io::Read as _; - if !std::fs::symlink_metadata(graph_db_path) .is_ok_and(|metadata| metadata.file_type().is_file()) { @@ -1564,6 +1587,7 @@ fn sample_free_pages(graph_db_path: &Path) -> Option<(u64, f64)> { #[derive(Debug)] struct UnregisteredDirectorySample { directory_count: usize, + count_is_lower_bound: bool, bytes: u64, unavailable_entry_count: usize, coverage: StorageReportSizeCoverageV1, @@ -1606,6 +1630,7 @@ fn sample_unregistered_paths( } UnregisteredDirectorySample { directory_count, + count_is_lower_bound: false, bytes, unavailable_entry_count, coverage: size_coverage(termination, bytes, unavailable_entry_count), @@ -1624,6 +1649,7 @@ fn scan_unregistered_dirs( Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return UnregisteredDirectorySample { directory_count: 0, + count_is_lower_bound: false, bytes: 0, unavailable_entry_count: 0, coverage: StorageReportSizeCoverageV1::default(), @@ -1632,6 +1658,7 @@ fn scan_unregistered_dirs( Err(_) => { return UnregisteredDirectorySample { directory_count: 0, + count_is_lower_bound: true, bytes: 0, unavailable_entry_count: 1, coverage: size_coverage(WalkTerminationV1::Complete, 0, 1), @@ -1677,6 +1704,8 @@ fn scan_unregistered_dirs( paths.push(entry.path()); } let directory_count = paths.len(); + let count_is_lower_bound = + termination != WalkTerminationV1::Complete || unavailable_entry_count > 0; let mut bytes = 0u64; for path in paths { if termination != WalkTerminationV1::Complete { @@ -1701,6 +1730,7 @@ fn scan_unregistered_dirs( } UnregisteredDirectorySample { directory_count, + count_is_lower_bound, bytes, unavailable_entry_count, coverage: size_coverage(termination, bytes, unavailable_entry_count), @@ -1724,6 +1754,7 @@ mod tests { use std::path::{Path, PathBuf}; use super::*; + use crate::retention::footprint::census_profile_footprint; use tracedecay_runtime_core::db::engine::TestConnection; fn store(project_id: &str, total_bytes: u64) -> StoreSizeReportEntry { @@ -2219,6 +2250,40 @@ mod tests { .collect() } + #[asupersync::test] + async fn offline_footprint_and_store_sizing_share_one_request_budget() { + let tmp = tempfile::tempdir().unwrap(); + let profile_root = tmp.path().join("profile"); + let store_root = profile_root.join("projects/proj_a"); + std::fs::create_dir_all(&store_root).unwrap(); + seed_global_db(&profile_root, &[("proj_a", "/repos/a")]).await; + for index in 0..16 { + std::fs::write(store_root.join(format!("payload-{index}")), [1u8]).unwrap(); + } + std::fs::create_dir_all(profile_root.join("projects/proj_orphan")).unwrap(); + + let report = build_storage_report_with_walk_budget(&profile_root, 8) + .await + .unwrap(); + + assert!(report.footprint.as_ref().unwrap().truncated); + assert_eq!( + report.full_profile_size.as_ref().unwrap().state, + ProfileTotalCoverageStateV1::Partial + ); + assert_eq!(report.stores.len(), 1); + assert_eq!( + report.stores[0].total_bytes, 0, + "the store walk cannot reset the exhausted census budget" + ); + assert_eq!( + report.stores[0].size_coverage.reason, + Some(StorageReportSizeReasonV1::EntryBudgetExceeded) + ); + assert_eq!(report.unregistered_dir_count, 0); + assert!(report.coverage.unregistered_count_is_lower_bound); + } + #[asupersync::test] async fn report_sizes_every_registered_store_and_counts_unregistered_dirs() { let tmp = tempfile::TempDir::new().unwrap(); @@ -2950,6 +3015,39 @@ mod tests { ); } + #[test] + fn directory_count_coverage_distinguishes_enumeration_from_sizing() { + let tmp = tempfile::tempdir().unwrap(); + for name in ["proj_first", "proj_second"] { + let path = tmp.path().join("projects").join(name); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(path.join("payload"), [1u8]).unwrap(); + } + let control = StorageReportWalkControl::default(); + let mut remaining = 1; + let partial_inventory = + scan_unregistered_dirs(tmp.path(), &HashSet::new(), &mut remaining, &control); + assert_eq!(partial_inventory.directory_count, 1); + assert!(partial_inventory.count_is_lower_bound); + assert_eq!( + partial_inventory.coverage.reason, + Some(StorageReportSizeReasonV1::EntryBudgetExceeded) + ); + + let mut remaining = 2; + let complete_inventory = + scan_unregistered_dirs(tmp.path(), &HashSet::new(), &mut remaining, &control); + assert_eq!(complete_inventory.directory_count, 2); + assert!( + !complete_inventory.count_is_lower_bound, + "exhausting the byte walk does not erase a complete directory count" + ); + assert_eq!( + complete_inventory.coverage.reason, + Some(StorageReportSizeReasonV1::EntryBudgetExceeded) + ); + } + #[test] fn unregistered_subtrees_share_the_same_sizing_accounting_and_budget() { let tmp = tempfile::TempDir::new().unwrap(); @@ -3259,6 +3357,7 @@ mod tests { stores: vec![partial_store], unregistered_bytes: 3, coverage: StorageReportCoverage { + unregistered_count_is_lower_bound: true, unregistered_size: StorageReportSizeCoverageV1 { state: StorageReportSizeStateV1::Partial, reason: Some(StorageReportSizeReasonV1::EntryBudgetExceeded), @@ -3279,6 +3378,7 @@ mod tests { decoded.coverage.unregistered_size.unavailable_entry_count, 2 ); + assert!(decoded.coverage.unregistered_count_is_lower_bound); let total = decoded.profile_total_size(); assert_eq!(total.accounted_bytes, 7); assert_eq!(total.state, ProfileTotalCoverageStateV1::Partial); diff --git a/crates/tracedecay-mcp/src/handlers/admin_cli.rs b/crates/tracedecay-mcp/src/handlers/admin_cli.rs index 8c36297e95..afd45c22ec 100644 --- a/crates/tracedecay-mcp/src/handlers/admin_cli.rs +++ b/crates/tracedecay-mcp/src/handlers/admin_cli.rs @@ -986,7 +986,8 @@ mod tests { serde_json::json!({ "state": "complete", "next_cursor": null, - "unregistered_size": {"state": "complete", "unavailable_entry_count": 0} + "unregistered_size": {"state": "complete", "unavailable_entry_count": 0}, + "unregistered_count_is_lower_bound": false }) ); } From f162dfb96e3bae715e24c5f4757adc2d7cfc7cb4 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:24:52 -0700 Subject: [PATCH 05/11] fix(storage): finish migrating bounded census callers --- crates/tracedecay-cli/src/commands/storage.rs | 118 ++++++++++++++++-- .../src/code_index_generations/tests.rs | 32 +++-- .../src/retention/footprint.rs | 2 +- .../src/retention/orphan_stores.rs | 2 +- 4 files changed, 132 insertions(+), 22 deletions(-) diff --git a/crates/tracedecay-cli/src/commands/storage.rs b/crates/tracedecay-cli/src/commands/storage.rs index f7ff480337..cc6443b6a8 100644 --- a/crates/tracedecay-cli/src/commands/storage.rs +++ b/crates/tracedecay-cli/src/commands/storage.rs @@ -3,7 +3,9 @@ use std::io::{self, BufRead, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::time::Duration; -use tracedecay_maintenance::retention::orphan_stores::dir_size_bytes; +use tracedecay_maintenance::retention::footprint::{ + MAX_FOOTPRINT_ENTRIES, WalkTerminationV1, WalkedEntryV1, walk_regular_files_with_budget, +}; use tracedecay_runtime_core::config::{GLOBAL_DB_FILENAME, ProfileRoot}; use tracedecay_runtime_core::storage::{USER_MEMORY_DB_FILENAME, USER_SESSIONS_DB_FILENAME}; @@ -658,6 +660,7 @@ fn handle_list_inner( }; let mut rows: Vec = Vec::with_capacity(project_paths.len()); let mut token_errors: Vec = Vec::new(); + let mut remaining_entries = MAX_FOOTPRINT_ENTRIES; for path in &project_paths { let mut location = global::classify_project_storage(profile, path)?; @@ -687,7 +690,7 @@ fn handle_list_inner( } } let has_data = location.data_root.exists(); - let size = list_store_size_bytes(all, has_data, &location.data_root); + let size = list_store_size_bytes(all, &location.data_root, &mut remaining_entries); let project_key = tracedecay_global_db::RegisteredGlobalDb::canonical_project_key(path); let token_row = token_rows.iter().find(|row| { tracedecay_global_db::RegisteredGlobalDb::canonical_project_key(&row.project) @@ -712,7 +715,12 @@ fn handle_list_inner( } if all { - append_orphan_manifest_rows(&mut rows, &project_paths, home_tracedecay.as_deref()); + append_orphan_manifest_rows( + &mut rows, + &project_paths, + home_tracedecay.as_deref(), + &mut remaining_entries, + ); } if rows.is_empty() { @@ -793,14 +801,29 @@ fn handle_list_inner( }) } -fn list_store_size_bytes(all: bool, has_data: bool, data_root: &Path) -> Option { +fn list_store_size_bytes( + all: bool, + data_root: &Path, + remaining_entries: &mut usize, +) -> Option { if !all { return None; } - if !has_data { - return Some(0); + match std::fs::symlink_metadata(data_root) { + Ok(metadata) if metadata.is_dir() => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => return Some(0), + _ => return None, } - Some(dir_size_bytes(data_root)) + let mut size = 0u64; + let mut unavailable = false; + let termination = + walk_regular_files_with_budget(data_root, remaining_entries, &|| None, &mut |_, entry| { + match entry { + WalkedEntryV1::File { bytes } => size = size.saturating_add(bytes), + WalkedEntryV1::Unavailable => unavailable = true, + } + }); + (termination == WalkTerminationV1::Complete && !unavailable).then_some(size) } #[derive(Debug)] @@ -808,8 +831,8 @@ struct ListRow { path: std::path::PathBuf, status_label: &'static str, has_data: bool, - /// `None` when this run did not measure the store. Default `list` - /// skips the recursive size walk. + /// `None` when this run did not completely measure the store. Default + /// `list` skips sizing; `list --all` shares one bounded entry census. size: Option, /// `None` when this run could not read the project's saved-token total. tokens: Option, @@ -819,6 +842,7 @@ fn append_orphan_manifest_rows( rows: &mut Vec, project_paths: &[std::path::PathBuf], profile_root: Option<&Path>, + remaining_entries: &mut usize, ) { let Some(profile_root) = profile_root else { return; @@ -845,7 +869,7 @@ fn append_orphan_manifest_rows( } let data_root = profile_root.join(&plan.store.store_relpath); let has_data = data_root.exists(); - let size = list_store_size_bytes(true, has_data, &data_root); + let size = list_store_size_bytes(true, &data_root, remaining_entries); rows.push(ListRow { path: plan.project.project_root, status_label: "orphan manifest-reconstructable", @@ -909,9 +933,77 @@ mod wipe_safety_tests { #[test] fn default_list_omits_recursive_store_size() { - let missing = Path::new("/definitely/missing-tracedecay-store"); - assert_eq!(list_store_size_bytes(false, true, missing), None); - assert_eq!(list_store_size_bytes(true, false, missing), Some(0)); + let temporary = tempfile::TempDir::new().expect("temporary stores"); + let missing = temporary.path().join("missing-store"); + let mut remaining_entries = 1; + assert_eq!( + list_store_size_bytes(false, &missing, &mut remaining_entries), + None + ); + assert_eq!( + list_store_size_bytes(true, &missing, &mut remaining_entries), + Some(0) + ); + assert_eq!(remaining_entries, 1); + } + + #[test] + fn all_list_shares_its_size_budget_and_omits_partial_measurements() { + let temporary = tempfile::TempDir::new().expect("temporary stores"); + let first = temporary.path().join("first"); + let second = temporary.path().join("second"); + std::fs::create_dir(&first).expect("first store"); + std::fs::create_dir(&second).expect("second store"); + std::fs::write(first.join("payload"), [1u8; 7]).expect("first payload"); + std::fs::write(second.join("one"), [2u8; 5]).expect("second payload"); + std::fs::write(second.join("two"), [3u8; 11]).expect("third payload"); + let mut remaining_entries = 2; + + assert_eq!( + list_store_size_bytes(true, &first, &mut remaining_entries), + Some(7) + ); + assert_eq!(remaining_entries, 1); + assert_eq!( + list_store_size_bytes(true, &second, &mut remaining_entries), + None, + "an incomplete store walk cannot contribute an exact total" + ); + assert_eq!(remaining_entries, 0); + } + + #[test] + fn all_list_omits_a_store_that_cannot_be_enumerated() { + let temporary = tempfile::TempDir::new().expect("temporary stores"); + let file_root = temporary.path().join("not-a-directory"); + std::fs::write(&file_root, [1u8]).expect("non-directory store"); + let mut remaining_entries = 1; + + assert_eq!( + list_store_size_bytes(true, &file_root, &mut remaining_entries), + None, + "unavailable entries cannot be reported as an empty store" + ); + assert_eq!(remaining_entries, 1); + } + + #[cfg(unix)] + #[test] + fn all_list_keeps_failed_root_metadata_unknown() { + let temporary = tempfile::TempDir::new().expect("temporary stores"); + let file_parent = temporary.path().join("not-a-directory"); + std::fs::write(&file_parent, [1u8]).expect("non-directory parent"); + let data_root = file_parent.join("store"); + assert!(!data_root.exists()); + assert!(data_root.try_exists().is_err()); + let mut remaining_entries = 1; + + assert_eq!( + list_store_size_bytes(true, &data_root, &mut remaining_entries), + None, + "a failed root inspection does not prove an absent store" + ); + assert_eq!(remaining_entries, 1); } #[test] diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 76c7c5e091..317e023186 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -1767,13 +1767,19 @@ fn metadata_only_segment_census_observes_at_most_one_directory_entry() { } let cancellation_observations = std::cell::Cell::new(0_usize); - let holds_segments = store_may_hold_generation_segments(store.path(), &|| { - cancellation_observations.set(cancellation_observations.get() + 1); - cancellation_observations.get() > 1 - }) + let mut remaining_entries = 1; + let holds_segments = store_may_hold_generation_segments( + store.path(), + &|| { + cancellation_observations.set(cancellation_observations.get() + 1); + cancellation_observations.get() > 1 + }, + &mut remaining_entries, + ) .expect("one observed entry conservatively proves possible segment work"); assert!(holds_segments); + assert_eq!(remaining_entries, 0); assert_eq!( cancellation_observations.get(), 1, @@ -1793,20 +1799,32 @@ fn metadata_only_segment_census_observes_at_most_one_directory_entry() { } #[test] -fn metadata_only_segment_census_distinguishes_empty_and_cancelled() { +fn metadata_only_segment_census_distinguishes_empty_cancelled_and_exhausted() { let store = tempfile::TempDir::new().expect("create unpublished store"); let segments_root = store.path().join(GENERATION_SEGMENTS_DIRECTORY); std::fs::create_dir_all(&segments_root).expect("create segment root"); + let mut remaining_entries = 1; assert!( - !store_may_hold_generation_segments(store.path(), &|| false) + !store_may_hold_generation_segments(store.path(), &|| false, &mut remaining_entries) .expect("empty segment directory") ); + assert_eq!(remaining_entries, 1); std::fs::write(segments_root.join("crash-debris"), b"debris").expect("write crash debris"); - let error = store_may_hold_generation_segments(store.path(), &|| true) + let error = store_may_hold_generation_segments(store.path(), &|| true, &mut remaining_entries) .expect_err("cancellation wins before an observed entry is accepted"); assert!(matches!(error, CodeGenerationRetentionErrorV1::Cancelled)); + assert_eq!(remaining_entries, 1); + + remaining_entries = 0; + let error = store_may_hold_generation_segments(store.path(), &|| false, &mut remaining_entries) + .expect_err("a nonempty segment directory needs an entry from the shared budget"); + assert!(matches!( + error, + CodeGenerationRetentionErrorV1::EntryBudgetExceeded + )); + assert_eq!(remaining_entries, 0); } #[test] diff --git a/crates/tracedecay-maintenance/src/retention/footprint.rs b/crates/tracedecay-maintenance/src/retention/footprint.rs index 3260550631..417bc91f9b 100644 --- a/crates/tracedecay-maintenance/src/retention/footprint.rs +++ b/crates/tracedecay-maintenance/src/retention/footprint.rs @@ -1004,7 +1004,7 @@ mod tests { std::fs::write(second.join("payload"), [2]).expect("second payload"); let mut remaining = 1; let mut visits = 0usize; - let mut visit = |_, _| visits += 1; + let mut visit = |_: &Path, _: WalkedEntryV1| visits += 1; assert_eq!( walk_regular_files_with_budget(&first, &mut remaining, &|| None, &mut visit), diff --git a/crates/tracedecay-maintenance/src/retention/orphan_stores.rs b/crates/tracedecay-maintenance/src/retention/orphan_stores.rs index e3063bfbc4..eff7cfd945 100644 --- a/crates/tracedecay-maintenance/src/retention/orphan_stores.rs +++ b/crates/tracedecay-maintenance/src/retention/orphan_stores.rs @@ -368,7 +368,7 @@ pub(crate) use collection::{CollectionControl, execute_unregistered_collection_c pub(crate) use collection::{execute_unregistered_collection, unbounded_collection_control}; pub use pages::{ OrphanSweepReport, StoreCensusPageV1, UnregisteredCollectionPlan, UnregisteredStoreFinding, - build_store_census, build_store_census_page, dir_size_bytes, plan_unregistered_collection, + build_store_census, build_store_census_page, plan_unregistered_collection, sweep_unregistered_stores, }; #[cfg(test)] From 91c0d36d7f48d7bb693b63b083765628888dd72b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:41:05 -0700 Subject: [PATCH 06/11] fix(storage): clear bounded report compiler warnings --- .../src/retention/storage_report.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/tracedecay-maintenance/src/retention/storage_report.rs b/crates/tracedecay-maintenance/src/retention/storage_report.rs index 9e4956e919..76730777bd 100644 --- a/crates/tracedecay-maintenance/src/retention/storage_report.rs +++ b/crates/tracedecay-maintenance/src/retention/storage_report.rs @@ -129,6 +129,7 @@ pub struct StorageReportSizeCoverageV1 { impl StorageReportSizeCoverageV1 { /// Combine coverage from separate directory pages without hiding an /// unavailable page or its unreadable-entry count. + #[must_use] pub fn merge(self, other: Self) -> Self { let state = match (self.state, other.state) { (StorageReportSizeStateV1::Complete, StorageReportSizeStateV1::Complete) => { @@ -348,7 +349,9 @@ fn size_coverage( unavailable_entry_count: usize, ) -> StorageReportSizeCoverageV1 { let (reason, state) = match termination { - WalkTerminationV1::Complete if unavailable_entry_count == 0 => return Default::default(), + WalkTerminationV1::Complete if unavailable_entry_count == 0 => { + return StorageReportSizeCoverageV1::default(); + } WalkTerminationV1::Complete => ( StorageReportSizeReasonV1::UnreadableEntries, if measured_bytes == 0 { @@ -1063,6 +1066,7 @@ struct ProjectDirectoryPage { /// /// The cursor is opaque: callers must return it unchanged rather than treating /// it as a directory name or assuming lexical ordering. +#[cfg(test)] fn list_project_directories_page( profile_root: &Path, cursor: &str, @@ -1589,7 +1593,6 @@ struct UnregisteredDirectorySample { directory_count: usize, count_is_lower_bound: bool, bytes: u64, - unavailable_entry_count: usize, coverage: StorageReportSizeCoverageV1, } @@ -1632,7 +1635,6 @@ fn sample_unregistered_paths( directory_count, count_is_lower_bound: false, bytes, - unavailable_entry_count, coverage: size_coverage(termination, bytes, unavailable_entry_count), } } @@ -1651,7 +1653,6 @@ fn scan_unregistered_dirs( directory_count: 0, count_is_lower_bound: false, bytes: 0, - unavailable_entry_count: 0, coverage: StorageReportSizeCoverageV1::default(), }; } @@ -1660,7 +1661,6 @@ fn scan_unregistered_dirs( directory_count: 0, count_is_lower_bound: true, bytes: 0, - unavailable_entry_count: 1, coverage: size_coverage(WalkTerminationV1::Complete, 0, 1), }; } @@ -1732,7 +1732,6 @@ fn scan_unregistered_dirs( directory_count, count_is_lower_bound, bytes, - unavailable_entry_count, coverage: size_coverage(termination, bytes, unavailable_entry_count), } } @@ -3067,7 +3066,7 @@ mod tests { assert_eq!(sample.directory_count, 2); assert_eq!(sample.bytes, 1); - assert_eq!(sample.unavailable_entry_count, 0); + assert_eq!(sample.coverage.unavailable_entry_count, 0); assert_eq!(remaining, 0); assert_eq!( sample.coverage.reason, @@ -3170,7 +3169,7 @@ mod tests { .unwrap(); assert_eq!(exact_budget, 0); - assert!(plans.is_empty()); + assert_eq!(plans, []); assert_eq!( availability[0].reason.as_deref(), Some(RETENTION_PROTECTION_UNRESOLVED) @@ -3215,8 +3214,8 @@ mod tests { stores[0].size_coverage.state, StorageReportSizeStateV1::Unavailable ); - assert!(plans.is_empty()); - assert!(availability.is_empty()); + assert_eq!(plans, []); + assert_eq!(availability, []); } #[cfg(unix)] From b0d45bb85e1b3dbe8b447cb0076ddec3ceddbfd7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 21 Oct 2026 14:01:07 -0700 Subject: [PATCH 07/11] test(storage): keep zero-byte budget fixture consistent --- crates/tracedecay-maintenance/src/retention/storage_report.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay-maintenance/src/retention/storage_report.rs b/crates/tracedecay-maintenance/src/retention/storage_report.rs index 76730777bd..69dbfe04a4 100644 --- a/crates/tracedecay-maintenance/src/retention/storage_report.rs +++ b/crates/tracedecay-maintenance/src/retention/storage_report.rs @@ -3262,7 +3262,7 @@ mod tests { scoped_code_index_store_root(&data_root.join("code-index-v1"), Path::new("/repos/a")); let generations = store_root.join(CODE_GENERATIONS_DIRECTORY); std::fs::create_dir_all(&generations).unwrap(); - std::fs::write(store_root.join("active-code-generation-v1.json"), b"active").unwrap(); + std::fs::write(store_root.join("active-code-generation-v1.json"), []).unwrap(); for index in 0..5 { std::fs::write(generations.join(format!("empty-{index}")), []).unwrap(); } From fe58c29d0397923b1f749bd759431a68a8288f3b Mon Sep 17 00:00:00 2001 From: Zack Jackson Date: Wed, 21 Oct 2026 15:54:58 -0700 Subject: [PATCH 08/11] test(index): settle graph admission before pinning save baseline --- .../src/daemon/tests/rmcp_route/host_index_handoff.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay/src/daemon/tests/rmcp_route/host_index_handoff.rs b/crates/tracedecay/src/daemon/tests/rmcp_route/host_index_handoff.rs index 06cc07fb88..648ade299d 100644 --- a/crates/tracedecay/src/daemon/tests/rmcp_route/host_index_handoff.rs +++ b/crates/tracedecay/src/daemon/tests/rmcp_route/host_index_handoff.rs @@ -30,7 +30,7 @@ async fn committed_save_remains_noncurrent_until_its_broker_handoff() { let project = canonical_existing_identity(fixture.handshake.project_path.as_deref().unwrap()).unwrap(); wait_for_host_ingest_publication(&fixture).await; - let initial = ready_reading(&fixture, &project, "initial").await; + let initial_text = ready_reading(&fixture, &project, "initial text").await; let graph = fixture.server.cg_snapshot().await; let project_id = graph.store_layout().identity.project_id.as_deref().unwrap(); let scope = tracedecay_code_index_runtime::resolved_scope_for_project( @@ -66,11 +66,14 @@ async fn committed_save_remains_noncurrent_until_its_broker_handoff() { std::time::Instant::now() + PHASE_TIMEOUT, ) .await; - assert!(owner.is_some(), "initial ready reading: {initial:?}"); + assert!(owner.is_some(), "initial ready reading: {initial_text:?}"); owner } else { None }; + // Complete-owner demand can queue a freshness pass after text readiness. + // Settle that setup work before pinning the generation the save must retain. + let initial = ready_reading(&fixture, &project, "after initial graph admission").await; let replay = broker.begin_replay().await.unwrap(); assert_eq!(broker.pending_replay_count().await.unwrap(), 0); std::fs::write( From f6393bfc7ad7d2879c167feebbe89ff8fc533fd5 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:35:49 -0700 Subject: [PATCH 09/11] fix(storage): charge directory enumeration to sizing budget --- .../src/retention/storage_report.rs | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-maintenance/src/retention/storage_report.rs b/crates/tracedecay-maintenance/src/retention/storage_report.rs index 69dbfe04a4..dac8372e86 100644 --- a/crates/tracedecay-maintenance/src/retention/storage_report.rs +++ b/crates/tracedecay-maintenance/src/retention/storage_report.rs @@ -948,9 +948,10 @@ async fn build_storage_report_page_with_walk_budget( .filter(|(name, _)| !registered.contains(name)) .map(|(_, path)| path.clone()) .collect::>(); + // Inventory enumeration and subtree sizing belong to the same request. + let mut remaining_entries = walk_entry_budget.saturating_sub(directory_page.entries_scanned); let (unregistered_dir_count, unregistered_bytes, unregistered_size) = native_runtime::spawn_blocking(move || { - let mut remaining_entries = walk_entry_budget; let sampled = sample_unregistered_paths(unregistered, &mut remaining_entries, &walk_control); (sampled.directory_count, sampled.bytes, sampled.coverage) @@ -1058,7 +1059,6 @@ async fn project_storage_report_page( struct ProjectDirectoryPage { directories: Vec<(String, PathBuf)>, next_cursor: Option, - #[cfg_attr(not(test), allow(dead_code))] entries_scanned: usize, } @@ -3014,6 +3014,66 @@ mod tests { ); } + #[asupersync::test] + async fn directory_page_sizing_charges_its_inventory_enumeration() { + let tmp = tempfile::TempDir::new().unwrap(); + let profile_root = tmp.path().join("profile"); + let runtime = tracedecay_global_db::tests::harness::RegisteredGlobalDbTestRuntime::profile( + &profile_root, + ) + .await + .unwrap(); + let db = runtime.profile_database_arc(); + for name in ["proj_budget_a", "proj_budget_b"] { + let data_root = profile_root.join("projects").join(name); + std::fs::create_dir_all(&data_root).unwrap(); + for file in ["one", "two", "three"] { + std::fs::write(data_root.join(file), [1u8]).unwrap(); + } + } + + let first = list_project_directories_page(&profile_root, "", 1).unwrap(); + let after = first.next_cursor.unwrap(); + // Replaying this inventory position performs the same bounded work as + // the report below, without assuming a fixed cost for the pager. + let inventory = list_project_directories_page(&profile_root, &after, 1).unwrap(); + assert_eq!(inventory.directories.len(), 1); + assert!(inventory.entries_scanned > 0); + let cursor = storage_report_cursor( + &storage_report_binding(1).unwrap(), + &StorageReportPosition::Directories { after: Some(after) }, + ) + .unwrap(); + + for (sizing_budget, expected_bytes, expected_state) in [ + (1, 1, StorageReportSizeStateV1::Partial), + (3, 3, StorageReportSizeStateV1::Complete), + ] { + let report = build_storage_report_page_with_walk_budget( + &profile_root, + &db, + None, + Some(&cursor), + 1, + inventory.entries_scanned + sizing_budget, + None, + None, + ) + .await + .unwrap(); + + assert_eq!(report.unregistered_dir_count, 1); + assert_eq!(report.unregistered_bytes, expected_bytes); + assert_eq!(report.coverage.unregistered_size.state, expected_state); + assert_eq!(report.coverage.unregistered_size.unavailable_entry_count, 0); + assert_eq!( + report.coverage.unregistered_size.reason, + (expected_state == StorageReportSizeStateV1::Partial) + .then_some(StorageReportSizeReasonV1::EntryBudgetExceeded), + ); + } + } + #[test] fn directory_count_coverage_distinguishes_enumeration_from_sizing() { let tmp = tempfile::tempdir().unwrap(); From f9c69743d4fdb64884c5519e9bc67b2851b951ef Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:21:32 -0700 Subject: [PATCH 10/11] fix(retention): refuse symlinked offline report roots --- .../src/retention/storage_report.rs | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-maintenance/src/retention/storage_report.rs b/crates/tracedecay-maintenance/src/retention/storage_report.rs index dac8372e86..33352f441e 100644 --- a/crates/tracedecay-maintenance/src/retention/storage_report.rs +++ b/crates/tracedecay-maintenance/src/retention/storage_report.rs @@ -1646,7 +1646,15 @@ fn scan_unregistered_dirs( walk_control: &StorageReportWalkControl, ) -> UnregisteredDirectorySample { let projects_dir = profile_root.join("projects"); - let entries = match std::fs::read_dir(&projects_dir) { + let entries = match std::fs::symlink_metadata(&projects_dir).and_then(|metadata| { + if !metadata.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotADirectory, + "storage report projects path is not a directory", + )); + } + std::fs::read_dir(&projects_dir) + }) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return UnregisteredDirectorySample { @@ -2592,6 +2600,30 @@ mod tests { ); } + #[cfg(unix)] + #[asupersync::test] + async fn offline_unregistered_sizing_refuses_a_symlinked_projects_root() { + let tmp = tempfile::TempDir::new().unwrap(); + let profile_root = tmp.path().join("profile"); + std::fs::create_dir_all(&profile_root).unwrap(); + seed_global_db(&profile_root, &[]).await; + let outside_projects = tmp.path().join("outside-projects"); + std::fs::create_dir_all(outside_projects.join("proj_outside")).unwrap(); + std::fs::write(outside_projects.join("proj_outside/payload"), [7; 2048]).unwrap(); + std::os::unix::fs::symlink(&outside_projects, profile_root.join("projects")).unwrap(); + + let report = build_storage_report(&profile_root).await.unwrap(); + + assert_eq!(report.unregistered_bytes, 0); + assert_eq!(report.unregistered_dir_count, 0); + assert!(report.coverage.unregistered_count_is_lower_bound); + assert_eq!( + report.coverage.unregistered_size.state, + StorageReportSizeStateV1::Unavailable + ); + assert_eq!(report.coverage.unregistered_size.unavailable_entry_count, 1); + } + /// Each store row sizes every family under its project directory, and the /// families add up to the same bytes a plain walk of the directory finds. #[test] From 03212da800b3c415a4ff18bf668603820724acab Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:48:09 -0700 Subject: [PATCH 11/11] test(hosts): avoid Linux native cache copy offload --- crates/tracedecay-agent-hosts/src/agents/claude/tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-agent-hosts/src/agents/claude/tests.rs b/crates/tracedecay-agent-hosts/src/agents/claude/tests.rs index 07725b1ec1..a00f51d9b4 100644 --- a/crates/tracedecay-agent-hosts/src/agents/claude/tests.rs +++ b/crates/tracedecay-agent-hosts/src/agents/claude/tests.rs @@ -22,7 +22,8 @@ fn copy_rendered_bundle_to_native_cache(home: &Path, tracedecay_bin: &str) { for (relative, _) in rendered_plugin_files(tracedecay_bin, &ServeArgs::default()).unwrap() { let target = cache.join(relative); std::fs::create_dir_all(target.parent().unwrap()).unwrap(); - std::fs::copy(source.join(relative), target).unwrap(); + let contents = std::fs::read(source.join(relative)).unwrap(); + std::fs::write(target, contents).unwrap(); } }