From acb4b7f5a9b1837405d38f0ef85ca82d4721bbff Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:52:52 -0600 Subject: [PATCH 01/16] fix: bound scale query memory --- .../graphforge-api/src/belief_projection.rs | 11 +- .../graphforge-api/src/embedding_refresh.rs | 1 + crates/graphforge-api/src/lib.rs | 274 ++++++++- .../src/ontology_composition_lifecycle.rs | 6 +- .../graphforge-api/src/workspace_ontology.rs | 12 +- .../graphforge-api/tests/fixed_hop_limit.rs | 101 +++- .../graphforge-api/tests/m4_entry_baseline.rs | 11 +- .../graphforge-api/tests/scale_g500_ladder.rs | 264 ++++++++- crates/graphforge-exec/Cargo.toml | 2 +- crates/graphforge-exec/src/adjacency.rs | 560 +++++++++++++++--- crates/graphforge-exec/src/demand.rs | 471 +++++++++------ crates/graphforge-exec/src/lib.rs | 37 +- .../tests/explain_snapshots.rs | 4 +- .../tests/persistent_adjacency.rs | 1 + crates/graphforge-storage/src/adjacency.rs | 149 +++-- .../graphforge-storage/src/adjacency_delta.rs | 10 +- crates/graphforge-storage/src/catalog.rs | 131 +++- crates/graphforge-storage/src/lib.rs | 8 +- crates/graphforge-storage/src/schemas.rs | 17 +- docs/book/architecture/execution-model.md | 31 +- docs/book/architecture/storage.md | 34 +- docs/development/perf-g500-ladder.md | 47 +- 22 files changed, 1742 insertions(+), 440 deletions(-) diff --git a/crates/graphforge-api/src/belief_projection.rs b/crates/graphforge-api/src/belief_projection.rs index 06e83b8b..f55c2718 100644 --- a/crates/graphforge-api/src/belief_projection.rs +++ b/crates/graphforge-api/src/belief_projection.rs @@ -434,10 +434,13 @@ impl GraphForge { .expect("runtime catalog poisoned") .clone(), )); - projected.adjacency_provider = Arc::new(graphforge_exec::PersistentAdjacencyProvider::new( - projected.dir.clone(), - projected.ontology_mode, - )); + projected.adjacency_provider = Arc::new( + graphforge_exec::PersistentAdjacencyProvider::new_with_cache( + projected.dir.clone(), + projected.adjacency_cache_guard.path(), + projected.ontology_mode, + ), + ); let current = self.generation_for_read()?; if current.generation_uuid() != source_generation_uuid { return Err(transaction_conflict( diff --git a/crates/graphforge-api/src/embedding_refresh.rs b/crates/graphforge-api/src/embedding_refresh.rs index 76643b78..38029d9c 100644 --- a/crates/graphforge-api/src/embedding_refresh.rs +++ b/crates/graphforge-api/src/embedding_refresh.rs @@ -260,6 +260,7 @@ impl GraphForge { )), dir: self.dir.clone(), workspace_guard: Arc::clone(&self.workspace_guard), + adjacency_cache_guard: Arc::clone(&self.adjacency_cache_guard), graph_open_evidence: self.graph_open_evidence.clone(), project_open_recovery: self.project_open_recovery.clone(), tempdir: self.tempdir.clone(), diff --git a/crates/graphforge-api/src/lib.rs b/crates/graphforge-api/src/lib.rs index 0aa399f0..6d5e562f 100644 --- a/crates/graphforge-api/src/lib.rs +++ b/crates/graphforge-api/src/lib.rs @@ -204,7 +204,9 @@ pub use generation_diff::{ GenerationDiffRequest, GenerationGraphDiff, GraphChangeStream, ReloadRequiredReason, }; pub use graphforge_exec::validate_embedding_options; -pub use graphforge_exec::{ExecutionResult, ExecutionStats, SendableRecordBatchStream}; +pub use graphforge_exec::{ + ExecutionResult, ExecutionStats, ObservedExecution, SendableRecordBatchStream, +}; pub use graphforge_storage::{ GraphDirectedness, WorkspaceConfiguration, WorkspaceOntology, WorkspaceOntologyMode, WorkspaceOntologySourceFormat, @@ -433,6 +435,8 @@ pub struct GraphForge { dir: PathBuf, /// Keeps the private mutable graph workspace alive for the engine's life. workspace_guard: Arc, + /// Keeps query-built derived adjacency artifacts outside graph generations. + adjacency_cache_guard: Arc, /// Structural evidence for how the graph workspace was opened. graph_open_evidence: graphforge_storage::GraphFilesOpenEvidence, /// Safe recovery-on-open summary (cleanup, deferral, or checkpoint skip). @@ -589,6 +593,7 @@ impl GraphForge { load_workspace_ontology(&resolved_generation)?; let (dir, workspace, graph_open_evidence) = hydrate_graph_workspace(&resolved_generation, false)?; + let adjacency_cache = Arc::new(create_adjacency_cache(tmp.path(), &resource_policy)?); Ok(Self { identity: GraphIdentity::new(), path: None, @@ -599,10 +604,13 @@ impl GraphForge { current_generation_uuid: Arc::new(Mutex::new(generation_uuid)), uuid_membership_index: Mutex::new(None), clock: Mutex::new(Arc::new(system_time_micros)), - adjacency_provider: Arc::new(graphforge_exec::PersistentAdjacencyProvider::new( - dir.clone(), - ontology_mode, - )), + adjacency_provider: Arc::new( + graphforge_exec::PersistentAdjacencyProvider::new_with_cache( + dir.clone(), + adjacency_cache.path(), + ontology_mode, + ), + ), adjacency_visibility: Arc::new(std::sync::RwLock::new(())), embedding_refresh_scheduler: Arc::new(Mutex::new( embedding_refresh::initialize_embedding_refresh_scheduler(&dir)?, @@ -622,6 +630,7 @@ impl GraphForge { provider_find_runtimes: Arc::new(Mutex::new(Vec::new())), dir, workspace_guard: workspace, + adjacency_cache_guard: adjacency_cache, graph_open_evidence, project_open_recovery, tempdir: Some(Arc::new(tmp)), @@ -725,6 +734,11 @@ impl GraphForge { load_workspace_ontology(&resolved_generation)?; let (dir, workspace, graph_open_evidence) = hydrate_graph_workspace(&resolved_generation, read_only)?; + // Keep query-derived CSR on the admitted project volume by default, + // or alongside explicitly configured spill. It must not use OS /tmp: + // billion-edge qualification is disk-bound and the container root may + // have a much smaller ephemeral capacity than the data volume. + let adjacency_cache = Arc::new(create_adjacency_cache(&container_dir, &resource_policy)?); let runtime_catalog = load_runtime_catalog(&dir)?; let semantic_storage_bindings = @@ -800,10 +814,13 @@ impl GraphForge { current_generation_uuid: Arc::new(Mutex::new(generation_uuid)), uuid_membership_index: Mutex::new(None), clock: Mutex::new(Arc::new(system_time_micros)), - adjacency_provider: Arc::new(graphforge_exec::PersistentAdjacencyProvider::new( - dir.clone(), - ontology_mode, - )), + adjacency_provider: Arc::new( + graphforge_exec::PersistentAdjacencyProvider::new_with_cache( + dir.clone(), + adjacency_cache.path(), + ontology_mode, + ), + ), adjacency_visibility: Arc::new(std::sync::RwLock::new(())), embedding_refresh_scheduler: Arc::new(Mutex::new( embedding_refresh::initialize_embedding_refresh_scheduler(&dir)?, @@ -820,6 +837,7 @@ impl GraphForge { provider_find_runtimes: Arc::new(Mutex::new(Vec::new())), dir, workspace_guard: workspace, + adjacency_cache_guard: adjacency_cache, graph_open_evidence, project_open_recovery, tempdir: None, @@ -950,6 +968,148 @@ impl GraphForge { self.execute_with_params(cypher, &HashMap::new()) } + /// Execute a read query and return query-isolated operator evidence on success or failure. + pub fn execute_observed(&self, cypher: &str) -> ObservedExecution { + self.execute_with_params_observed(cypher, &HashMap::new()) + } + + /// Parameterized form of [`execute_observed`](Self::execute_observed). + pub fn execute_with_params_observed( + &self, + cypher: &str, + params: &HashMap, + ) -> ObservedExecution { + let result = self.prepare_observed_read(cypher, params); + match result { + Ok(observed) => observed, + Err(error) => ObservedExecution { + result: Err(publicize_query_error(error)), + evidence: graphforge_exec::demand::DemandSnapshot::default(), + }, + } + } + + #[allow(clippy::too_many_lines)] + fn prepare_observed_read( + &self, + cypher: &str, + params: &HashMap, + ) -> Result { + let _admission = self.admit_heavy_query()?; + let composition = self + .default_composition_context + .lock() + .expect("default composition context lock poisoned") + .clone() + .map(|context| self.bind_generation_storage(&context)) + .transpose()?; + if cypher.trim().is_empty() { + return Err(GfError::Validation("empty query".into())); + } + let ast = graphforge_cypher::parse(cypher).map_err(|e| GfError::Parse { + msg: e.message, + span: e.span, + })?; + if ast.clauses.is_empty() { + return Err(GfError::Validation("empty query".into())); + } + validate_typed_parameter_binding( + &ast, + params, + self.ontology.clone(), + &self.runtime_catalog, + self.ontology_mode, + self.procedure_snapshot(), + )?; + let mut binder = Binder::new( + self.ontology.clone(), + self.runtime_catalog.clone(), + self.ontology_mode, + ) + .with_procedures(self.procedure_snapshot()); + if let Some((context, _, _)) = &composition { + binder = binder.with_composition(Arc::clone(context)); + } + let plan = binder + .bind(&ast) + .map_err(|errors| bind_errors_to_gferror(&errors))?; + validate_call_params(&plan, params)?; + if plan.ops.iter().any(|op| { + matches!( + op, + GraphOp::Create { .. } + | GraphOp::Merge { .. } + | GraphOp::Delete { .. } + | GraphOp::Set { .. } + | GraphOp::Remove { .. } + ) + }) { + return Err(GfError::Validation( + "observed execution accepts read queries only".into(), + )); + } + let plan = materialize_row_count_params(&plan, params)?; + let _visibility = self.graph_visibility.read()?; + let catalog = { + let rc = self + .runtime_catalog + .lock() + .expect("runtime catalog poisoned"); + let bindings = self + .semantic_storage_bindings + .lock() + .expect("semantic storage binding lock poisoned"); + GraphCatalog::open_with_semantic_bindings( + &self.dir, + self.ontology.as_ref(), + &rc, + composition + .as_ref() + .map(|(_, candidate, _)| candidate) + .or(bindings.as_ref()), + ) + .map_err(|error| GfError::Storage(error.to_string()))? + }; + let execution_mode = composition + .as_ref() + .map_or(self.ontology_mode, |(context, _, _)| { + match context.composition().profile_default { + graphforge_ontology::ActivationMode::Strict => OntologyMode::Strict, + graphforge_ontology::ActivationMode::Exploratory + | graphforge_ontology::ActivationMode::Advisory => OntologyMode::Advisory, + } + }); + let adjacency_provider = if execution_mode == self.ontology_mode { + Arc::clone(&self.adjacency_provider) + } else { + Arc::new( + graphforge_exec::PersistentAdjacencyProvider::new_with_cache( + self.dir.clone(), + self.adjacency_cache_guard.path(), + execution_mode, + ), + ) + }; + let session = graphforge_exec::ExecutionSession::new_with_target_provider_and_resources( + catalog, + self.ontology.clone(), + self.dir.clone(), + execution_mode, + adjacency_provider, + &self.session_resource_config(), + )?; + let mut observed = self.block_on(async { + Ok(session + .execute_plan_with_params_observed(&plan, params) + .await) + })?; + observed.result = observed + .result + .and_then(|result| shape_result(result, self.ontology_mode, self.ontology.as_ref())) + .map_err(publicize_query_error); + Ok(observed) + } + /// Register or replace a deterministic procedure available to `CALL`. /// /// # Errors @@ -1318,10 +1478,13 @@ impl GraphForge { let adjacency_provider = if execution_mode == self.ontology_mode { Arc::clone(&self.adjacency_provider) } else { - Arc::new(graphforge_exec::PersistentAdjacencyProvider::new( - self.dir.clone(), - execution_mode, - )) + Arc::new( + graphforge_exec::PersistentAdjacencyProvider::new_with_cache( + self.dir.clone(), + self.adjacency_cache_guard.path(), + execution_mode, + ), + ) }; let session = ExecutionSession::new_with_target_provider_and_resources( catalog, @@ -3127,7 +3290,31 @@ impl GraphForge { /// Returns a structured project, execution, or schema error if the committed /// graph generation cannot be inspected. pub fn node_count(&self, label: &str) -> Result { - Ok(self.inspect_graph()?.node_count(label)) + const COUNT_BATCH_ROWS: usize = 65_536; + + let type_id = if label.is_empty() { + None + } else if let Some(type_id) = self + .ontology + .as_ref() + .and_then(|ontology| ontology.entity_type_id(label)) + { + Some(type_id) + } else { + let catalog = self + .runtime_catalog + .lock() + .map_err(|_| GfError::Storage("runtime catalog lock poisoned".into()))?; + let Some((runtime_id, _)) = catalog + .entity_type_names_with_ids() + .find(|(_, name)| *name == label) + else { + return Ok(0); + }; + Some(graphforge_ir::runtime_entity_type_id(runtime_id)) + }; + graphforge_storage::count_nodes_batched(&self.dir, type_id, COUNT_BATCH_ROWS) + .map_err(|error| GfError::Storage(error.to_string())) } /// Return a human-readable explanation of every compiler stage for `cypher`: @@ -3266,10 +3453,13 @@ impl GraphForge { // reads by it (exploratory `_exploratory.parquet` vs typed // `topology/edges/.parquet`); rebuild it so the adjacency path // matches the new mode. - self.adjacency_provider = Arc::new(graphforge_exec::PersistentAdjacencyProvider::new( - self.dir.clone(), - self.ontology_mode, - )); + self.adjacency_provider = Arc::new( + graphforge_exec::PersistentAdjacencyProvider::new_with_cache( + self.dir.clone(), + self.adjacency_cache_guard.path(), + self.ontology_mode, + ), + ); } Ok(()) } @@ -3738,6 +3928,20 @@ fn build_runtime( .map(|rt| Arc::new(OwnedRuntime(Some(rt)))) } +fn create_adjacency_cache( + admitted_data_root: &std::path::Path, + policy: &resource_policy::NormalizedResourcePolicy, +) -> Result { + let parent = policy + .spill_directory + .as_deref() + .unwrap_or(admitted_data_root); + tempfile::Builder::new() + .prefix(".graphforge-adjacency-") + .tempdir_in(parent) + .map_err(|error| GfError::Storage(format!("failed to create adjacency cache: {error}"))) +} + fn hydrate_graph_workspace( generation: &ResolvedProjectGeneration, read_only: bool, @@ -4351,6 +4555,27 @@ mod tests { const ABSENT_TARGET_COOKIE: &str = "graphforge-absent-target-open-v1"; const ABSENT_TARGET_DEADLINE: Duration = Duration::from_secs(10); + #[test] + fn adjacency_cache_uses_admitted_or_configured_spill_volume_and_cleans_up() { + let admitted = tempfile::tempdir().unwrap(); + let spill = tempfile::tempdir().unwrap(); + let default_policy = ExecutionResourcePolicy::default().normalize().unwrap(); + + let default_cache = create_adjacency_cache(admitted.path(), &default_policy).unwrap(); + let default_path = default_cache.path(); + assert_eq!(default_path.parent(), Some(admitted.path())); + drop(default_cache); + assert!(!default_path.exists()); + + let mut spill_policy = default_policy; + spill_policy.spill_directory = Some(spill.path().to_path_buf()); + let spill_cache = create_adjacency_cache(admitted.path(), &spill_policy).unwrap(); + let spill_path = spill_cache.path(); + assert_eq!(spill_path.parent(), Some(spill.path())); + drop(spill_cache); + assert!(!spill_path.exists()); + } + fn spawn_absent_target_child(parent: &Path, child_id: &str) -> Child { Command::new(std::env::current_exe().expect("absent-target current test executable")) .args(["--exact", ABSENT_TARGET_CHILD, "--nocapture"]) @@ -4502,6 +4727,19 @@ mod tests { assert_eq!(reopened.node_count("Person").unwrap(), 1); } + #[test] + fn node_count_does_not_read_relationship_inventory() { + let graph = GraphForge::new(None).unwrap(); + graph.add_node("Person", &HashMap::new()).unwrap(); + let edges = graph.dir.join("topology/edges"); + std::fs::create_dir_all(&edges).unwrap(); + std::fs::write(edges.join("BROKEN.parquet"), b"not parquet").unwrap(); + + assert_eq!(graph.node_count("Person").unwrap(), 1); + assert_eq!(graph.node_count("").unwrap(), 1); + assert_eq!(graph.node_count("Missing").unwrap(), 0); + } + fn degree_options(directed: bool, via: Option<&str>) -> RankOptions { RankOptions { by: RankAlgorithm::Degree, diff --git a/crates/graphforge-api/src/ontology_composition_lifecycle.rs b/crates/graphforge-api/src/ontology_composition_lifecycle.rs index 5faa2566..bfb50826 100644 --- a/crates/graphforge-api/src/ontology_composition_lifecycle.rs +++ b/crates/graphforge-api/src/ontology_composition_lifecycle.rs @@ -486,7 +486,11 @@ impl GraphForge { Some(std::sync::Arc::new(published_binding)); self.ontology_mode = configuration.ontology_mode.execution_mode(); self.adjacency_provider = std::sync::Arc::new( - graphforge_exec::PersistentAdjacencyProvider::new(self.dir.clone(), self.ontology_mode), + graphforge_exec::PersistentAdjacencyProvider::new_with_cache( + self.dir.clone(), + self.adjacency_cache_guard.path(), + self.ontology_mode, + ), ); Ok(CompositionChangeReceipt { project_generation_uuid: *self diff --git a/crates/graphforge-api/src/workspace_ontology.rs b/crates/graphforge-api/src/workspace_ontology.rs index 94c67969..f14f9656 100644 --- a/crates/graphforge-api/src/workspace_ontology.rs +++ b/crates/graphforge-api/src/workspace_ontology.rs @@ -193,7 +193,11 @@ impl GraphForge { )?; } self.adjacency_provider = std::sync::Arc::new( - graphforge_exec::PersistentAdjacencyProvider::new(self.dir.clone(), self.ontology_mode), + graphforge_exec::PersistentAdjacencyProvider::new_with_cache( + self.dir.clone(), + self.adjacency_cache_guard.path(), + self.ontology_mode, + ), ); Ok(()) } @@ -229,7 +233,11 @@ impl GraphForge { self.ontology_document = None; self.ontology_mode = OntologyMode::Exploratory; self.adjacency_provider = std::sync::Arc::new( - graphforge_exec::PersistentAdjacencyProvider::new(self.dir.clone(), self.ontology_mode), + graphforge_exec::PersistentAdjacencyProvider::new_with_cache( + self.dir.clone(), + self.adjacency_cache_guard.path(), + self.ontology_mode, + ), ); Ok(()) } diff --git a/crates/graphforge-api/tests/fixed_hop_limit.rs b/crates/graphforge-api/tests/fixed_hop_limit.rs index 78f1233c..e1af242a 100644 --- a/crates/graphforge-api/tests/fixed_hop_limit.rs +++ b/crates/graphforge-api/tests/fixed_hop_limit.rs @@ -13,7 +13,7 @@ use arrow::array::{FixedSizeBinaryArray, Int64Array, UInt64Array}; use graphforge_api::GraphForge; use graphforge_core::uuid::{Uuid, new_v7}; use graphforge_core::{OntologyMode, TypeId}; -use graphforge_exec::demand::{self, DemandSnapshot}; +use graphforge_exec::demand::DemandSnapshot; use graphforge_ir::IrLiteral; use graphforge_storage::adjacency::build_adjacency_index; use graphforge_storage::{GraphWriter, io_stats}; @@ -165,13 +165,12 @@ fn run_measured( query: &str, ) -> (Duration, io_stats::IoSnapshot, DemandSnapshot) { io_stats::reset(); - demand::reset(); let started = Instant::now(); - let result = forge.execute(query).unwrap(); + let observed = forge.execute_observed(query); + let result = observed.result.unwrap(); let elapsed = started.elapsed(); - demand::disable(); assert_eq!(result.stats.rows_produced, LIMIT as u64, "{query}"); - (elapsed, io_stats::snapshot(), demand::snapshot()) + (elapsed, io_stats::snapshot(), observed.evidence) } #[derive(Debug)] @@ -300,13 +299,12 @@ fn run_scattered_destination_scale( let edges = generate_scattered_destinations(dir.path(), nodes, 4, 1_500); let forge = open_forge(dir.path()); io_stats::reset(); - demand::reset(); - let result = forge.execute(ONE_HOP).unwrap(); - demand::disable(); + let observed = forge.execute_observed(ONE_HOP); + let result = observed.result.unwrap(); assert_eq!(result.stats.rows_produced, LIMIT as u64); let mut values = fixed_binary_values(&result, "id"); values.sort_unstable(); - (values, io_stats::snapshot(), demand::snapshot(), edges) + (values, io_stats::snapshot(), observed.evidence, edges) } #[test] @@ -361,13 +359,12 @@ fn limits_sweep_bounded_multi_hop_work_and_repartition() { assert!(!plan.contains("RoundRobinBatch"), "{plan}"); io_stats::reset(); - demand::reset(); - let result = forge.execute(&query).unwrap(); - demand::disable(); + let observed = forge.execute_observed(&query); + let result = observed.result.unwrap(); assert_eq!(result.stats.rows_produced, limit); let io = io_stats::snapshot(); assert_indexed_limit_io(&io); - assert_bounded_demand(&demand::snapshot(), 2, limit); + assert_bounded_demand(&observed.evidence, 2, limit); } } @@ -380,11 +377,10 @@ fn selective_filter_tops_up_without_crossing_blockers() { let selective = "MATCH (a)-[r1]->(b)-[r2]->(c) \ WHERE c.node_id = 64 RETURN c.node_id AS id LIMIT 10"; - demand::reset(); - let result = forge.execute(selective).unwrap(); - demand::disable(); + let observed = forge.execute_observed(selective); + let result = observed.result.unwrap(); assert_eq!(result.stats.rows_produced, 10); - let snapshot = demand::snapshot(); + let snapshot = observed.evidence; assert_bounded_demand(&snapshot, 2, 10); assert!( snapshot @@ -501,13 +497,34 @@ fn fixed_hop_limit_preserves_skip_parameters_filters_and_blockers() { "post-filter LIMIT returned unexpected IDs: {filtered_ids:?}" ); - let ordered = forge - .execute( - "MATCH ()-[r]->(b) RETURN b.node_id AS id \ - ORDER BY id DESC LIMIT 5", - ) + let ordered_plan = forge + .explain("MATCH ()-[r]->(b) RETURN b.node_id AS id ORDER BY id DESC LIMIT 5") .unwrap(); + assert!( + ordered_plan.contains("SortExec: TopK(fetch=5)"), + "ordered LIMIT must physically select bounded TopK: {ordered_plan}" + ); + let observed = forge.execute_observed( + "MATCH ()-[r]->(b) RETURN b.node_id AS id \ + ORDER BY id DESC LIMIT 5", + ); + let ordered = observed.result.unwrap(); assert_eq!(uint64_values(&ordered, "id"), [64, 64, 64, 64, 63]); + let ordered_metrics = observed.evidence; + assert_eq!(ordered_metrics.sorts.len(), 1, "{ordered_metrics:#?}"); + let sort = &ordered_metrics.sorts[0]; + assert_eq!(sort.fetch, Some(5), "{ordered_metrics:#?}"); + assert_eq!(sort.output_rows, 5, "{ordered_metrics:#?}"); + assert_eq!( + sort.spill_count, 0, + "TopK does not spill: {ordered_metrics:#?}" + ); + assert_eq!(sort.spilled_bytes, 0, "{ordered_metrics:#?}"); + assert_eq!(sort.memory_used_after, 0, "{ordered_metrics:#?}"); + assert_eq!( + ordered_metrics.memory_reserved_after, ordered_metrics.memory_reserved_before, + "query memory reservations must quiesce: {ordered_metrics:#?}" + ); let distinct = forge .execute( @@ -530,6 +547,37 @@ fn fixed_hop_limit_preserves_skip_parameters_filters_and_blockers() { assert_eq!(total, 256, "aggregation must consume the complete hop"); } +#[test] +fn ordered_limit_topk_state_is_cardinality_independent_and_released() { + let _guard = IO_GUARD.lock().unwrap(); + let mut snapshots = Vec::new(); + for nodes in [4_096, 40_960] { + let dir = TempDir::new().unwrap(); + generate_graph(dir.path(), nodes, FAN_OUT); + let forge = open_forge(dir.path()); + let observed = forge.execute_observed( + "MATCH ()-[r]->(b) RETURN b.node_id AS id ORDER BY id DESC LIMIT 100", + ); + let result = observed.result.unwrap(); + assert_eq!(result.stats.rows_produced, 100); + snapshots.push(observed.evidence); + } + + for snapshot in &snapshots { + assert_eq!(snapshot.sorts.len(), 1, "{snapshot:#?}"); + let sort = &snapshot.sorts[0]; + assert_eq!(sort.fetch, Some(100), "{snapshot:#?}"); + assert_eq!(sort.output_rows, 100, "{snapshot:#?}"); + assert_eq!(sort.spill_count, 0, "{snapshot:#?}"); + assert_eq!(sort.spilled_bytes, 0, "{snapshot:#?}"); + assert_eq!(sort.memory_used_after, 0, "{snapshot:#?}"); + assert_eq!( + snapshot.memory_reserved_after, snapshot.memory_reserved_before, + "{snapshot:#?}" + ); + } +} + fn env_usize(key: &str, default: usize) -> usize { match std::env::var(key) { Ok(value) => value @@ -568,18 +616,17 @@ fn physical_plan_only(explain: &str) -> &str { fn livejournal_sample(forge: &GraphForge, query: &str, limit: usize) -> LiveJournalSample { io_stats::reset(); - demand::reset(); let started = Instant::now(); - let result = forge - .execute(query) + let observed = forge.execute_observed(query); + let result = observed + .result .unwrap_or_else(|error| panic!("LiveJournal traversal execution failed: {error}")); let elapsed = started.elapsed(); - demand::disable(); assert_eq!(result.stats.rows_produced, limit as u64); LiveJournalSample { elapsed, io: io_stats::snapshot(), - demand: demand::snapshot(), + demand: observed.evidence, } } diff --git a/crates/graphforge-api/tests/m4_entry_baseline.rs b/crates/graphforge-api/tests/m4_entry_baseline.rs index 84518fa9..104b875c 100644 --- a/crates/graphforge-api/tests/m4_entry_baseline.rs +++ b/crates/graphforge-api/tests/m4_entry_baseline.rs @@ -24,7 +24,6 @@ use graphforge_api::{ use graphforge_core::algorithms::{ AnalyzeAlgorithm, PathAlgorithm, RankAlgorithm, SimilarAlgorithm, }; -use graphforge_exec::demand; use graphforge_storage::io_stats; use sha2::{Digest, Sha256}; @@ -1230,18 +1229,14 @@ fn run_node2vec(gf: &GraphForge) -> WorkloadEvidence { fn assert_fixed_hop_demand(gf: &GraphForge) { let _guard = IO_GUARD.lock().expect("io guard"); io_stats::reset(); - demand::reset(); let plan = gf.explain(FIXED_HOP_LIMIT).expect("explain fixed-hop"); assert!( !plan.contains("RoundRobinBatch"), "entry harness must not introduce eager repartitioning: {plan}" ); - let result = gf.execute(FIXED_HOP_LIMIT).expect("fixed-hop execute"); - let demand_snap = { - let snap = demand::snapshot(); - demand::disable(); - snap - }; + let observed = gf.execute_observed(FIXED_HOP_LIMIT); + let result = observed.result.expect("fixed-hop execute"); + let demand_snap = observed.evidence; let io = io_stats::snapshot(); assert_eq!(result.stats.rows_produced, 3); // Small fixture may not cancel upstream reads; still require demand/plan surface. diff --git a/crates/graphforge-api/tests/scale_g500_ladder.rs b/crates/graphforge-api/tests/scale_g500_ladder.rs index 3f9fec9f..75ba9a82 100644 --- a/crates/graphforge-api/tests/scale_g500_ladder.rs +++ b/crates/graphforge-api/tests/scale_g500_ladder.rs @@ -36,6 +36,7 @@ use graphforge_api::{ bulk_node_input_schema, verify_portable_v2, }; use graphforge_core::uuid::Uuid; +use graphforge_exec::demand; use serde::Deserialize; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -60,6 +61,38 @@ static JOURNAL_WRITE_SEQUENCE: AtomicU64 = AtomicU64::new(0); static INGEST_SUBPHASE: AtomicU64 = AtomicU64::new(0); static INGEST_CHUNK_INDEX: AtomicU64 = AtomicU64::new(0); +fn query_operator_evidence(snapshot: &demand::DemandSnapshot) -> Value { + json!({ + "expands": snapshot.hops.iter().map(|(edge_var, hop)| json!({ + "edge_var": edge_var, + "input_batches": hop.input_batches, + "input_rows": hop.input_rows, + "candidates_generated": hop.candidates_generated, + "rows_emitted": hop.rows_emitted, + "edge_rows_scanned": hop.edge_rows_scanned, + "node_rows_scanned": hop.node_rows_scanned, + "max_in_flight_reads": snapshot.max_in_flight_reads, + })).collect::>(), + "sorts": snapshot.sorts.iter().map(|sort| json!({ + "ordinal": sort.ordinal, + "top_k_rows": sort.fetch, + "output_rows": sort.output_rows, + "output_batches": sort.output_batches, + "spill_count": sort.spill_count, + "spilled_bytes": sort.spilled_bytes, + "memory_used_after": sort.memory_used_after, + })).collect::>(), + "memory_reserved_before": snapshot.memory_reserved_before, + "memory_reserved_after": snapshot.memory_reserved_after, + "operator_rss": { + "expand_peak_bytes": snapshot.operator_rss.expand_peak_bytes, + "expand_current_bytes": snapshot.operator_rss.expand_current_bytes, + "sort_peak_bytes": snapshot.operator_rss.sort_peak_bytes, + "sort_current_bytes": snapshot.operator_rss.sort_current_bytes, + }, + }) +} + // --------------------------------------------------------------------------- // Versioned profile (single source of truth for the ladder). // --------------------------------------------------------------------------- @@ -571,6 +604,11 @@ fn phase_journal_value( "active_steps": steps, "first_failing_phase": failure.map(|(phase, _)| phase), "error_class": failure.map(|(_, class)| class), + "interruption_semantics": { + "last_atomic_boundary": format!("{phase}:{state}"), + "typed_failure_recorded": failure.is_some(), + "running_without_typed_failure": state == "running" && failure.is_none(), + }, }) } @@ -870,7 +908,7 @@ fn run_rung( ); } - // ---- reopen + recount ---- + // ---- reopen + individually journaled recount/query phases ---- let mut node_count = 0u64; let mut edge_count = 0u64; let mut gsi = String::new(); @@ -887,10 +925,7 @@ fn run_rung( let reopen_started = Instant::now(); let graph = GraphForge::new(Some(project.to_str().expect("utf8 project"))) .expect("reopen GraphForge"); - node_count = graph.node_count(NODE_LABEL).expect("node_count"); - edge_count = scalar_count(&graph.execute(COUNT_EDGES).expect("edge count")); let reopen_s = reopen_started.elapsed().as_secs_f64(); - gsi = gsi_undirected(node_count, edge_count); let reopen_violation = envelope_violation(&env, ladder_started, &project, &spill_dir); if let Some(class) = reopen_violation { first_failing_phase = Some("reopen"); @@ -901,7 +936,8 @@ fn run_rung( "pass": reopen_violation.is_none(), "wall_time_s": reopen_s, "rss_peak_bytes": rss_value(), - "detail": { "node_count": node_count, "edge_count": edge_count, "gsi": gsi } + "process_memory": linux_process_memory(), + "detail": {} })); persist_phase_journal( profile, @@ -917,47 +953,187 @@ fn run_rung( first_failing_phase.zip(error_class), ); - // ---- deterministic LIMIT queries ---- + // Each potentially large operation has its own durable before/after + // boundary. If the kernel kills the process, the last atomic journal + // identifies the interrupted operation; a normal return records its + // current and high-water RSS independently from the other operations. + if first_failing_phase.is_none() { + persist_phase_journal( + profile, + rung, + completed_rungs, + "node_count", + "running", + &steps, + None, + ); + let phase_started = Instant::now(); + node_count = graph.node_count(NODE_LABEL).expect("node_count"); + let expected_nodes = 1u64 << rung.scale; + let mut violation = envelope_violation(&env, ladder_started, &project, &spill_dir); + if node_count != expected_nodes { + violation = Some("result_mismatch"); + } + if let Some(class) = violation { + first_failing_phase = Some("node_count"); + error_class = Some(class); + } + steps.push(json!({ + "id": "node_count", + "pass": violation.is_none(), + "wall_time_s": phase_started.elapsed().as_secs_f64(), + "rss_peak_bytes": rss_value(), + "process_memory": linux_process_memory(), + "detail": { "node_count": node_count, "expected": expected_nodes } + })); + persist_phase_journal( + profile, + rung, + completed_rungs, + "node_count", + if violation.is_some() { + "phase_failed" + } else { + "phase_completed" + }, + &steps, + first_failing_phase.zip(error_class), + ); + } + + if first_failing_phase.is_none() { + persist_phase_journal( + profile, + rung, + completed_rungs, + "edge_count", + "running", + &steps, + None, + ); + let phase_started = Instant::now(); + edge_count = scalar_count(&graph.execute(COUNT_EDGES).expect("edge count")); + gsi = gsi_undirected(node_count, edge_count); + let mut violation = envelope_violation(&env, ladder_started, &project, &spill_dir); + if edge_count != live_unique_edges { + violation = Some("result_mismatch"); + } + if let Some(class) = violation { + first_failing_phase = Some("edge_count"); + error_class = Some(class); + } + steps.push(json!({ + "id": "edge_count", + "pass": violation.is_none(), + "wall_time_s": phase_started.elapsed().as_secs_f64(), + "rss_peak_bytes": rss_value(), + "process_memory": linux_process_memory(), + "detail": { "edge_count": edge_count, "expected": live_unique_edges, "gsi": gsi } + })); + persist_phase_journal( + profile, + rung, + completed_rungs, + "edge_count", + if violation.is_some() { + "phase_failed" + } else { + "phase_completed" + }, + &steps, + first_failing_phase.zip(error_class), + ); + } + if first_failing_phase.is_none() { - let hop1_started = Instant::now(); persist_phase_journal( profile, rung, completed_rungs, - "query", + "one_hop", "running", &steps, None, ); - let hop1 = graph.execute(ONE_HOP).expect("one-hop LIMIT"); - let hop1_rows = row_count(&hop1); + let hop1_started = Instant::now(); + let hop1 = graph.execute_observed(ONE_HOP); + let hop1_operators = query_operator_evidence(&hop1.evidence); + let hop1_failure = hop1.result.as_ref().err().map(ToString::to_string); + let hop1_rows = hop1.result.as_ref().map_or(0, row_count); + let mut violation = envelope_violation(&env, ladder_started, &project, &spill_dir); + if hop1_failure.is_some() { + violation = Some("execution_failure"); + } + if hop1_rows != 1_000 { + violation = Some("result_mismatch"); + } + if let Some(class) = violation { + first_failing_phase = Some("one_hop"); + error_class = Some(class); + } steps.push(json!({ "id": "cypher_limit_1hop", - "pass": hop1_rows <= 1_000, + "pass": violation.is_none(), "wall_time_s": hop1_started.elapsed().as_secs_f64(), - "detail": { "rows": hop1_rows } + "rss_peak_bytes": rss_value(), + "process_memory": linux_process_memory(), + "detail": { "rows": hop1_rows, "operators": hop1_operators, "execution_failure": hop1_failure } })); + persist_phase_journal( + profile, + rung, + completed_rungs, + "one_hop", + if violation.is_some() { + "phase_failed" + } else { + "phase_completed" + }, + &steps, + first_failing_phase.zip(error_class), + ); + } + if first_failing_phase.is_none() { + persist_phase_journal( + profile, + rung, + completed_rungs, + "two_hop", + "running", + &steps, + None, + ); let hop2_started = Instant::now(); - let hop2 = graph.execute(TWO_HOP).expect("two-hop LIMIT"); - let hop2_rows = row_count(&hop2); + let hop2 = graph.execute_observed(TWO_HOP); + let hop2_operators = query_operator_evidence(&hop2.evidence); + let hop2_failure = hop2.result.as_ref().err().map(ToString::to_string); + let hop2_rows = hop2.result.as_ref().map_or(0, row_count); + let mut violation = envelope_violation(&env, ladder_started, &project, &spill_dir); + if hop2_failure.is_some() { + violation = Some("execution_failure"); + } + if hop2_rows != 1_000 { + violation = Some("result_mismatch"); + } + if let Some(class) = violation { + first_failing_phase = Some("two_hop"); + error_class = Some(class); + } steps.push(json!({ "id": "cypher_limit_2hop", - "pass": hop2_rows <= 1_000, + "pass": violation.is_none(), "wall_time_s": hop2_started.elapsed().as_secs_f64(), - "detail": { "rows": hop2_rows } + "rss_peak_bytes": rss_value(), + "process_memory": linux_process_memory(), + "detail": { "rows": hop2_rows, "operators": hop2_operators, "execution_failure": hop2_failure } })); - let query_violation = envelope_violation(&env, ladder_started, &project, &spill_dir); - if let Some(class) = query_violation { - first_failing_phase = Some("query"); - error_class = Some(class); - } persist_phase_journal( profile, rung, completed_rungs, - "query", - if query_violation.is_some() { + "two_hop", + if violation.is_some() { "phase_failed" } else { "phase_completed" @@ -1661,6 +1837,31 @@ fn ci_rung_public_facade_engineering_green() { 1u64 << ci_rung.scale ); assert!(live > 0, "CI rung must persist a non-empty graph"); + let step_ids = ev["steps"] + .as_array() + .expect("steps") + .iter() + .map(|step| step["id"].as_str().expect("step id")) + .collect::>(); + assert_eq!( + step_ids, + [ + "generate", + "ingest", + "reopen", + "node_count", + "edge_count", + "cypher_limit_1hop", + "cypher_limit_2hop", + ], + "every S20-relevant operation needs an independent evidence boundary" + ); + for step in &ev["steps"].as_array().expect("steps")[2..] { + assert!( + step.get("rss_peak_bytes").is_some() && step.get("process_memory").is_some(), + "reopen/count/query phases must carry peak and current RSS: {step}" + ); + } } /// Provisioned full ladder (SCALE-20 → SCALE-26). Opt-in via @@ -1754,6 +1955,23 @@ fn phase_journal_atomically_preserves_completed_rungs_and_active_state() { assert_eq!(journal["run_state"], "phase_failed"); assert_eq!(journal["first_failing_phase"], "ingest"); assert_eq!(journal["error_class"], "disk_limit"); + assert_eq!( + journal["interruption_semantics"]["last_atomic_boundary"], + "ingest:phase_failed" + ); + assert_eq!( + journal["interruption_semantics"]["typed_failure_recorded"], + true + ); + let interrupted = phase_journal_value( + &profile, rung, &completed, "two_hop", "running", &steps, None, + ); + assert_eq!(interrupted["active_phase"], "two_hop"); + assert!(interrupted["error_class"].is_null()); + assert_eq!( + interrupted["interruption_semantics"]["running_without_typed_failure"], true, + "an OOM-safe journal identifies the interrupted phase without inventing a cause" + ); let directory = TempDir::new().expect("journal directory"); let path = directory.path().join("journal.json"); diff --git a/crates/graphforge-exec/Cargo.toml b/crates/graphforge-exec/Cargo.toml index a42d3161..b51db097 100644 --- a/crates/graphforge-exec/Cargo.toml +++ b/crates/graphforge-exec/Cargo.toml @@ -23,12 +23,12 @@ thiserror = { workspace = true } anyhow = { workspace = true } sha2 = { workspace = true } rayon = "1.10" +tokio = { workspace = true } [dev-dependencies] graphforge-cypher = { path = "../graphforge-cypher" } insta = { workspace = true, features = ["filters"] } tempfile = "3" -tokio = { workspace = true } parquet = { workspace = true } [lints] diff --git a/crates/graphforge-exec/src/adjacency.rs b/crates/graphforge-exec/src/adjacency.rs index e53abe86..81858dc7 100644 --- a/crates/graphforge-exec/src/adjacency.rs +++ b/crates/graphforge-exec/src/adjacency.rs @@ -9,13 +9,14 @@ //! //! - [`ScanBuildAdjacencyProvider`] — reads the typed edge tables and builds //! the view in memory on every call (the behavior of the retired private -//! `build_adjacency`); the universal fallback. +//! `build_adjacency`); retained as an explicit oracle/foreign-session provider. //! - [`PersistentAdjacencyProvider`] (#761) — serves from the on-disk CSR //! index under `indexes/adjacency/` when it is fresh (manifest //! `topology_generation` matches the project counter), lazily rebuilds a -//! stale index, and falls back to scan-build whenever the index cannot -//! serve a key. A stale, corrupt, or missing index can only cost speed, -//! never correctness. The adjacency-aware lowering rule is #763. +//! stale, corrupt, incomplete, or missing index with the bounded external-sort +//! builder, and fails closed if that reconstruction fails. The project facade +//! never falls back to the O(E)-memory oracle. The adjacency-aware lowering +//! rule is #763. //! //! Surrogate-only (R-ADJ-3): the view holds `node_id` / `edge_id` `u64` //! surrogates exclusively; UUIDs are resolved at the API boundary, never here. @@ -29,7 +30,7 @@ use graphforge_core::{GfError, OntologyMode}; use graphforge_ir::Direction; use graphforge_storage::adjacency::{ self as csr, ALL_RELATIONS_STEM, AdjacencyManifestRow, CsrIndex, CsrRow, ShardedCsrIndex, - build_adjacency_index, + adjacency_relation_key, is_adjacency_relation_key, }; use graphforge_storage::adjacency_delta::{ CsrDeltaOverlay, DeltaSegment, overlay_delta_segments, read_delta_chain, @@ -46,11 +47,11 @@ pub enum AdjacencyStatus { Hit, /// The index capability is present but could not serve this key fresh: /// stale or corrupt manifest/counter, a fresh index with no row for the - /// relation, or a missing CSR file. The request scan-builds (and, when - /// the whole index was stale, lazily rebuilds it). + /// relation, or a missing CSR file. The persistent provider rebuilds it. Miss, - /// No index capability for this request: `indexes/adjacency/` absent, a - /// scan-build-only provider, or the typed-mode `"*"` bypass. + /// No index capability currently exists: `indexes/adjacency/` is absent, + /// or this is an explicit scan-build-only provider. Persistent execution + /// builds the capability on first use. Building, } @@ -460,16 +461,16 @@ fn merge_undirected_row<'a>(out: &NeighborRow<'a>, inbound: &NeighborRow<'a>) -> } /// Single adjacency abstraction (ADR 0005): implementations decide *how* a -/// view is produced (scan-build now; disk-loaded CSR with scan fallback in -/// #761) — consumers only see [`Adjacency`]. +/// view is produced (explicit scan oracle or persistent bounded CSR) — +/// consumers only see [`Adjacency`]. pub trait AdjacencyProvider: Send + Sync { /// The adjacency view for (`rel_type_name`, `direction`). /// /// `"*"` means all relation types (#823): the per-row `rel_type_name` /// filter is skipped and every relation's edges are unioned — served by the - /// `_all` CSR union (a `Hit`) when the index is fresh, otherwise by a union - /// scan-build over `read_edges(dir, "*", mode)` (which itself unions every - /// `topology/edges/*.parquet` in Strict/Advisory). + /// `_all` CSR union when the persistent index is fresh. Persistent execution + /// bounded-builds that union when absent; only an explicitly selected scan + /// provider uses `read_edges(dir, "*", mode)`. /// /// # Errors /// Returns [`GfError::Execution`] on storage or decode failure. @@ -617,20 +618,21 @@ enum IndexState { /// Provider over the on-disk CSR index (#761): serves `Hit`s from /// `indexes/adjacency/` when the manifest generation matches the project's -/// `topology_generation`, lazily rebuilds a stale index, and falls back to -/// scan-build whenever the index cannot serve a key — a stale, corrupt, or -/// missing index only ever costs speed, never correctness. +/// `topology_generation`, and lazily runs the bounded external-sort builder +/// whenever the index cannot serve a key. The persistent provider never uses +/// the O(E)-memory scan-build oracle: if bounded index construction cannot +/// complete, traversal fails with a typed execution error instead of risking +/// process OOM. /// -/// One instance lives per [`ExecutionSession`](crate::ExecutionSession) -/// (= per query); loaded views are cached per `(stem, direction)` so a -/// multi-expand query loads each CSR once. Facade-level cross-query caching -/// is a planned later lift (the `Arc` return type already -/// supports it). +/// The facade shares one instance across execution sessions. Loaded views are +/// cached per `(stem, direction)`, and lazy external-sort publication is +/// single-flight: concurrent waiters re-read and serve the winner's files. pub struct PersistentAdjacencyProvider { dir: PathBuf, - /// Scan-build fallback, fed the ORIGINAL relation name so per-row relation - /// filtering still applies (the union read serves the typed `"*"` wildcard). - scan: ScanBuildAdjacencyProvider, + cache_dir: PathBuf, + artifact_dir: Mutex, + /// Serializes lazy publication. Waiters re-read the published state. + rebuild: Mutex<()>, /// Lazily-read index state; refreshed after a successful lazy rebuild. state: Mutex>, /// Loaded views per `(stem, direction)`. @@ -640,10 +642,35 @@ pub struct PersistentAdjacencyProvider { impl PersistentAdjacencyProvider { /// A provider over the project at `dir` in ontology `mode`. #[must_use] - pub fn new(dir: PathBuf, mode: OntologyMode) -> Self { + pub fn new(dir: PathBuf, _mode: OntologyMode) -> Self { + Self::with_artifact_dir(dir.clone(), dir) + } + + /// A provider whose lazy derived artifacts live outside the source graph tree. + /// + /// Each provider receives a private child directory because the bounded + /// builder uses fixed staging names. Providers for projected graphs or + /// alternate execution modes must therefore never race in a shared cache + /// root. + #[must_use] + pub fn new_with_cache(dir: PathBuf, cache_root: &std::path::Path, _mode: OntologyMode) -> Self { + static NEXT_CACHE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = NEXT_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let artifact_dir = cache_root.join(format!("provider-{id}")); + Self::with_artifact_dir(dir, artifact_dir) + } + + fn with_artifact_dir(dir: PathBuf, artifact_dir: PathBuf) -> Self { + let active_artifact = if csr::adjacency_dir(&dir).exists() { + dir.clone() + } else { + artifact_dir.clone() + }; Self { - scan: ScanBuildAdjacencyProvider::new(dir.clone(), mode), dir, + cache_dir: artifact_dir, + artifact_dir: Mutex::new(active_artifact), + rebuild: Mutex::new(()), state: Mutex::new(None), cache: Mutex::new(HashMap::new()), } @@ -657,26 +684,31 @@ impl PersistentAdjacencyProvider { if rel_type_name == "*" { ALL_RELATIONS_STEM.to_owned() } else { - rel_type_name.to_owned() + adjacency_relation_key(rel_type_name) } } /// The current index state, read once and memoized. fn state(&self) -> IndexState { let mut guard = self.state.lock().expect("adjacency state lock"); + let artifact_dir = self + .artifact_dir + .lock() + .expect("adjacency artifact lock") + .clone(); guard - .get_or_insert_with(|| Self::read_state(&self.dir)) + .get_or_insert_with(|| Self::read_state(&self.dir, &artifact_dir)) .clone() } - fn read_state(dir: &std::path::Path) -> IndexState { - if !csr::adjacency_dir(dir).exists() { + fn read_state(source_dir: &std::path::Path, artifact_dir: &std::path::Path) -> IndexState { + if !csr::adjacency_dir(artifact_dir).exists() { return IndexState::Absent; } - let Ok(generation) = read_topology_generation(dir) else { + let Ok(generation) = read_topology_generation(source_dir) else { return IndexState::Unreadable; }; - let Ok(rows) = csr::read_manifest(dir) else { + let Ok(rows) = csr::read_manifest(artifact_dir) else { return IndexState::Unreadable; }; // The base generation the CSRs were built at — uniform across rows on a @@ -684,15 +716,27 @@ impl PersistentAdjacencyProvider { // stale (rebuild repairs them), never served. let base = rows.first().map(|r| r.topology_generation); let uniform = base.is_some_and(|b| rows.iter().all(|r| r.topology_generation == b)); + // Raw-name manifests predate identity-bound keys and cannot distinguish + // a literal `_all` relation from the wildcard union. Rebuild them; + // never interpret absent encoded coverage as a proven empty relation. + let identity_bound = rows.iter().all(|row| match row.relation_name.as_deref() { + None => row.relation_type == ALL_RELATIONS_STEM, + Some(name) => { + is_adjacency_relation_key(&row.relation_type) + && row.relation_type == adjacency_relation_key(name) + } + }); let (fresh, deltas) = match base { // base == counter: exact match, no overlay (the #761 fast path). - Some(b) if uniform && b == generation => (true, Vec::new()), + Some(b) if uniform && identity_bound && b == generation => (true, Vec::new()), // base < counter: serveable iff an intact, bounded delta chain // (#765) covers (base, counter]; otherwise stale ⇒ rebuild. - Some(b) if uniform && b < generation => match read_delta_chain(dir, b, generation) { - Some(chain) => (true, chain), - None => (false, Vec::new()), - }, + Some(b) if uniform && identity_bound && b < generation => { + match read_delta_chain(source_dir, b, generation) { + Some(chain) => (true, chain), + None => (false, Vec::new()), + } + } // Empty / torn manifest, or an index newer than the counter // (anomalous, e.g. a counter reset): stale. _ => (false, Vec::new()), @@ -718,6 +762,20 @@ impl PersistentAdjacencyProvider { } } + fn rows_cover_name( + rows: &[AdjacencyManifestRow], + stem: &str, + rel_type_name: &str, + direction: Direction, + ) -> bool { + let expected = (rel_type_name != "*").then_some(rel_type_name); + Self::rows_cover(rows, stem, direction) + && rows + .iter() + .filter(|row| row.relation_type == stem) + .all(|row| row.relation_name.as_deref() == expected) + } + /// Load the view for (`stem`, `direction`) from the CSR file(s), overlaying /// the delta chain (#765) when one is present (`deltas` non-empty). /// @@ -731,8 +789,15 @@ impl PersistentAdjacencyProvider { rows: &[AdjacencyManifestRow], deltas: &[DeltaSegment], ) -> Result { + // One immutable publication serves both halves of an undirected view. + // Capture it once so concurrent invalidation cannot mix roots. + let artifact_dir = self + .artifact_dir + .lock() + .expect("adjacency artifact lock") + .clone(); let directed = |d: csr::Direction| -> Result { - let path = csr::csr_path(&self.dir, stem, d); + let path = csr::csr_path(&artifact_dir, stem, d); if csr::sharded_csr_exists(&path) { let base = Arc::new(ShardedCsrIndex::open(&path)?); if let Some(row) = rows @@ -801,47 +866,133 @@ impl PersistentAdjacencyProvider { } /// Lazily rebuild the index, refresh the memoized state, and serve from - /// the fresh files; any failure falls back to scan-build (a build problem - /// must never fail the query). + /// the fresh files. This path deliberately fails closed rather than using + /// the O(E)-memory scan-build oracle. fn rebuild_and_serve( &self, rel_type_name: &str, - stem: &str, direction: Direction, ) -> Result, GfError> { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| i64::try_from(d.as_micros()).unwrap_or(i64::MAX)); - match build_adjacency_index(&self.dir, now) { - Ok(rows) => { - let covered = Self::rows_cover(&rows, stem, direction); - // Index writes don't bump the topology counter, so re-read it - // for the stamp — a missed stamp would make the next - // revalidate() spuriously drop this fresh rebuild. - let generation = rows.first().map_or_else( - || read_topology_generation(&self.dir).unwrap_or(0), - |r| r.topology_generation, - ); - // A fresh rebuild has no overlay: the new base IS `generation` - // and the builder pruned the consumed segments (#765). - let view = if covered { - self.load(stem, direction, &rows, &[]).ok() - } else { - None - }; + self.rebuild_and_serve_with_checkpoint(rel_type_name, direction, || Ok(())) + } + + #[allow(clippy::too_many_lines)] // single-flight recheck, build, generation validation, and publication are one atomic path + fn rebuild_and_serve_with_checkpoint( + &self, + rel_type_name: &str, + direction: Direction, + mut checkpoint: impl FnMut() -> Result<(), GfError>, + ) -> Result, GfError> { + let stem = Self::stem_for(rel_type_name); + let _rebuild = self.rebuild.lock().expect("adjacency rebuild lock"); + + // Another query may have completed the bounded build while this caller + // waited. Re-read from disk under the single-flight lock and serve its + // publication instead of multiplying external-sort memory and racing + // the builder's fixed staging paths. + if let Some(view) = self + .cache + .lock() + .expect("adjacency cache lock") + .get(&(stem.clone(), direction)) + .cloned() + { + return Ok(view); + } + let active_artifact = self + .artifact_dir + .lock() + .expect("adjacency artifact lock") + .clone(); + if let IndexState::Ready { + fresh: true, + generation, + rows, + deltas, + } = Self::read_state(&self.dir, &active_artifact) + { + let covered = Self::rows_cover_name(&rows, &stem, rel_type_name, direction); + let loaded = if covered { + self.load(&stem, direction, &rows, &deltas).map(Some) + } else if deltas.is_empty() { + Ok(None) + } else { + Err(GfError::Execution( + "fresh adjacency delta chain lacks requested relation coverage".into(), + )) + }; + if let Ok(view) = loaded { *self.state.lock().expect("adjacency state lock") = Some(IndexState::Ready { fresh: true, generation, rows, - deltas: Arc::new(Vec::new()), + deltas, }); - if let Some(view) = view { - return Ok(self.cache_view(stem, direction, view)); + return Ok(self.cache_view(&stem, direction, view.unwrap_or_default())); + } + } + self.artifact_dir + .lock() + .expect("adjacency artifact lock") + .clone_from(&self.cache_dir); + for attempt in 0..2 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_micros()).unwrap_or(i64::MAX)); + let rows = graphforge_storage::adjacency::build_adjacency_index_into( + &self.dir, + &self.cache_dir, + now, + &mut checkpoint, + ) + .map_err(|error| { + GfError::Execution(format!("bounded adjacency index build failed: {error}")) + })?; + let base_generation = rows.first().map_or_else( + || read_topology_generation(&self.dir).unwrap_or(0), + |row| row.topology_generation, + ); + let current_generation = read_topology_generation(&self.dir).map_err(|error| { + GfError::Execution(format!( + "cannot validate bounded adjacency build generation: {error}" + )) + })?; + let deltas = if current_generation == base_generation { + Vec::new() + } else if current_generation > base_generation { + match read_delta_chain(&self.dir, base_generation, current_generation) { + Some(chain) => chain, + None if attempt == 0 => continue, + None => { + return Err(GfError::Execution( + "topology changed during bounded adjacency build without a complete delta chain" + .into(), + )); + } } - self.scan.adjacency(rel_type_name, direction) + } else if attempt == 0 { + continue; + } else { + return Err(GfError::Execution( + "topology generation moved backwards during bounded adjacency build".into(), + )); + }; + let covered = Self::rows_cover_name(&rows, &stem, rel_type_name, direction); + let view = covered + .then(|| self.load(&stem, direction, &rows, &deltas)) + .transpose()?; + *self.state.lock().expect("adjacency state lock") = Some(IndexState::Ready { + fresh: true, + generation: current_generation, + rows, + deltas: Arc::new(deltas), + }); + if let Some(view) = view { + return Ok(self.cache_view(&stem, direction, view)); } - Err(_) => self.scan.adjacency(rel_type_name, direction), + return Ok(self.cache_view(&stem, direction, Adjacency::default())); } + unreachable!("bounded adjacency rebuild retry loop returns") } /// Drop the memoized index state and every loaded view, forcing the next @@ -854,6 +1005,12 @@ impl PersistentAdjacencyProvider { pub fn invalidate(&self) { *self.state.lock().expect("adjacency state lock") = None; self.cache.lock().expect("adjacency cache lock").clear(); + *self.artifact_dir.lock().expect("adjacency artifact lock") = + if csr::adjacency_dir(&self.dir).exists() { + self.dir.clone() + } else { + self.cache_dir.clone() + }; } /// Cheap cross-query freshness check (#832): one `generation.json` read @@ -923,7 +1080,7 @@ fn sharded_overlay_rows( let mut max_key = base.node_count().saturating_sub(1); for segment in chain { for edge in &segment.edges { - if take_all || edge.rel_type_name == stem { + if take_all || adjacency_relation_key(&edge.rel_type_name) == stem { let (key, neighbor) = match direction { csr::Direction::Out => (edge.src_id, edge.dst_id), csr::Direction::In => (edge.dst_id, edge.src_id), @@ -966,37 +1123,41 @@ impl AdjacencyProvider for PersistentAdjacencyProvider { return Ok(Arc::clone(view)); } match self.state() { - IndexState::Absent | IndexState::Unreadable => { - // A streaming ExpandExec may request the same view once per - // input batch. Scan-build exactly once per session/query and - // cache it just like a CSR view; writes/revalidation clear the - // cache before a changed topology can be observed (#1248). - let view = self.scan.adjacency(rel_type_name, direction)?; - Ok(self.cache_shared_view(&stem, direction, view)) - } + // The scan fallback materializes every edge twice: first in Arrow + // batches and then in a HashMap. That makes the first ordinary + // fixed-hop query O(E) anonymous memory. The adjacency builder is + // already an external-sort, bounded-memory operation; use it to + // publish sharded CSR and serve requested rows from disk. This is + // also the repair path for an unreadable index. IndexState::Ready { fresh: true, rows, deltas, .. } => { - if !Self::rows_cover(&rows, &stem, direction) { - // A FRESH index with no row for this relation: a relation - // born only in the delta chain has no base CSR to overlay, - // and rebuilding cannot add an unknown/unusable stem either, - // so scan-build without rebuild (the union `_all` still - // carries those rows for exploratory `*`). Correct, slower. - return self.scan.adjacency(rel_type_name, direction); + if !Self::rows_cover_name(&rows, &stem, rel_type_name, direction) { + // With no delta chain, a complete current manifest proves + // that this relation has no source edges. A relation born + // after the base index can exist only when deltas are + // present; rebuild that case so missing coverage never + // diverts into the O(E)-memory scan oracle. + return if deltas.is_empty() { + Ok(self.cache_view(&stem, direction, Adjacency::default())) + } else { + self.rebuild_and_serve(rel_type_name, direction) + }; } match self.load(&stem, direction, &rows, &deltas) { Ok(view) => Ok(self.cache_view(&stem, direction, view)), // CSR missing/corrupt, or the torn-read count guard tripped: // one lazy rebuild repairs the index. - Err(_) => self.rebuild_and_serve(rel_type_name, &stem, direction), + Err(_) => self.rebuild_and_serve(rel_type_name, direction), } } - IndexState::Ready { fresh: false, .. } => { - self.rebuild_and_serve(rel_type_name, &stem, direction) + IndexState::Absent + | IndexState::Unreadable + | IndexState::Ready { fresh: false, .. } => { + self.rebuild_and_serve(rel_type_name, direction) } } } @@ -1015,7 +1176,7 @@ impl AdjacencyProvider for PersistentAdjacencyProvider { let path = csr::csr_path(&self.dir, &stem, d); path.exists() || csr::sharded_csr_exists(&path) }; - let present = Self::rows_cover(&rows, &stem, direction) + let present = Self::rows_cover_name(&rows, &stem, rel_type_name, direction) && match direction { Direction::Out => files_exist(csr::Direction::Out), Direction::In => files_exist(csr::Direction::In), @@ -1065,6 +1226,7 @@ mod tests { /// Diamond a→b, a→c, b→d, c→d plus a parallel edge a→b and a self-loop /// d→d, all `KNOWS`, Strict mode. Returns the surrogate node ids. + #[allow(clippy::many_single_char_names)] fn write_diamond(dir: &Path) -> [u64; 4] { let mut w = GraphWriter::open_at(dir, OntologyMode::Strict, TS).unwrap(); let uuids: Vec = (0..4).map(|_| new_v7()).collect(); @@ -1156,7 +1318,7 @@ mod tests { #[test] fn typed_mode_wildcard_unions_all_rel_types() { let dir = TempDir::new().unwrap(); - let mut w = GraphWriter::open_at(dir.path(), OntologyMode::Strict, TS).unwrap(); + let mut w = GraphWriter::open_at(dir.path(), OntologyMode::Exploratory, TS).unwrap(); let (a, b, c) = (new_v7(), new_v7(), new_v7()); let ids: Vec = [a, b, c] .iter() @@ -1287,6 +1449,228 @@ mod tests { assert_eq!(scanned.backing(), AdjacencyBacking::ScanHashMap); } + /// The first ordinary fixed-hop query on an unindexed project must not + /// construct an O(E) anonymous-memory HashMap. It builds the bounded, + /// spillable persistent representation and immediately serves sharded CSR. + #[test] + fn absent_index_builds_and_serves_disk_backed_csr() { + let dir = TempDir::new().unwrap(); + let [a, b, c, _d] = write_diamond(dir.path()); + let provider = + PersistentAdjacencyProvider::new(dir.path().to_path_buf(), OntologyMode::Strict); + + assert_eq!( + provider.status("KNOWS", Direction::Out), + AdjacencyStatus::Building + ); + let out = provider.adjacency("KNOWS", Direction::Out).unwrap(); + + assert_eq!(out.backing(), AdjacencyBacking::CsrNative); + assert_eq!(out.base_csr_entries_expanded(), 0); + assert_eq!(out.neighbors(a).to_vec(), vec![(1, b), (2, c), (5, b)]); + assert!(csr::adjacency_dir(dir.path()).exists()); + assert_eq!( + provider.status("KNOWS", Direction::Out), + AdjacencyStatus::Hit + ); + } + + #[test] + fn persistent_exact_relation_keys_do_not_collide_with_wildcard_or_paths() { + let dir = TempDir::new().unwrap(); + let mut w = GraphWriter::open_at(dir.path(), OntologyMode::Exploratory, TS).unwrap(); + let (a, b, c) = (new_v7(), new_v7(), new_v7()); + let ids: Vec = [a, b, c] + .iter() + .map(|uuid| w.create_node(*uuid, TypeId(0)).unwrap()) + .collect(); + w.create_edge(new_v7(), "a/b", &a, &b).unwrap(); + w.create_edge(new_v7(), "_all", &a, &c).unwrap(); + w.flush().unwrap(); + + let provider = + PersistentAdjacencyProvider::new(dir.path().to_path_buf(), OntologyMode::Exploratory); + assert_eq!( + provider + .adjacency("a/b", Direction::Out) + .unwrap() + .neighbors(ids[0]) + .to_vec(), + vec![(1, ids[1])] + ); + assert_eq!( + provider + .adjacency("_all", Direction::Out) + .unwrap() + .neighbors(ids[0]) + .to_vec(), + vec![(2, ids[2])] + ); + assert_eq!( + provider + .adjacency("*", Direction::Out) + .unwrap() + .neighbors(ids[0]) + .to_vec(), + vec![(1, ids[1]), (2, ids[2])] + ); + } + + #[test] + fn lazy_build_validates_and_overlays_a_concurrent_topology_commit() { + let dir = TempDir::new().unwrap(); + let [a, _b, _c, _d] = write_diamond(dir.path()); + let provider = + PersistentAdjacencyProvider::new(dir.path().to_path_buf(), OntologyMode::Strict); + let mut checkpoints = 0; + let mut appended = None; + + let view = provider + .rebuild_and_serve_with_checkpoint("KNOWS", Direction::Out, || { + checkpoints += 1; + if checkpoints == 2 { + let mut w = GraphWriter::open_at(dir.path(), OntologyMode::Strict, TS).unwrap(); + let src = new_v7(); + let dst = new_v7(); + let src_id = w.create_node(src, TypeId(0)).unwrap(); + let dst_id = w.create_node(dst, TypeId(0)).unwrap(); + w.create_edge(new_v7(), "KNOWS", &src, &dst).unwrap(); + w.flush().unwrap(); + appended = Some((src_id, dst_id)); + } + Ok(()) + }) + .unwrap(); + + let (src, dst) = appended.expect("checkpoint injected one topology commit"); + assert_eq!(view.neighbors(src).to_vec(), vec![(7, dst)]); + assert_eq!(view.neighbors(a).len(), 3); + assert_eq!( + provider.status("KNOWS", Direction::Out), + AdjacencyStatus::Hit, + "the memoized state is stamped only after generation validation" + ); + } + + #[test] + fn private_lazy_build_never_mutates_the_authoritative_graph_tree() { + let dir = TempDir::new().unwrap(); + let cache = TempDir::new().unwrap(); + let [a, ..] = write_diamond(dir.path()); + assert!(!csr::adjacency_dir(dir.path()).exists()); + + let provider = PersistentAdjacencyProvider::new_with_cache( + dir.path().to_path_buf(), + cache.path(), + OntologyMode::Strict, + ); + let view = provider.adjacency("KNOWS", Direction::Out).unwrap(); + + assert_eq!(view.neighbors(a).len(), 3); + assert!( + !csr::adjacency_dir(dir.path()).exists(), + "query repair wrote derived files into authoritative graph content" + ); + assert!( + std::fs::read_dir(cache.path()) + .unwrap() + .any(|entry| csr::adjacency_dir(&entry.unwrap().path()).exists()), + "bounded CSR publication did not use its private cache namespace" + ); + } + + #[test] + fn concurrent_lazy_rebuild_is_single_flight_and_waiter_serves_publication() { + use std::sync::mpsc; + use std::time::Duration; + + let dir = TempDir::new().unwrap(); + write_diamond(dir.path()); + let provider = Arc::new(PersistentAdjacencyProvider::new( + dir.path().to_path_buf(), + OntologyMode::Strict, + )); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (waiter_started_tx, waiter_started_rx) = mpsc::channel(); + let (waiter_tx, waiter_rx) = mpsc::channel(); + + std::thread::scope(|scope| { + let builder_provider = Arc::clone(&provider); + let builder = scope.spawn(move || { + let mut first = true; + builder_provider.rebuild_and_serve_with_checkpoint("KNOWS", Direction::Out, || { + if first { + first = false; + entered_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + } + Ok(()) + }) + }); + entered_rx.recv().unwrap(); + + let waiter_provider = Arc::clone(&provider); + let waiter = scope.spawn(move || { + waiter_started_tx.send(()).unwrap(); + let result = waiter_provider.adjacency("KNOWS", Direction::Out); + waiter_tx.send(()).unwrap(); + result + }); + waiter_started_rx.recv().unwrap(); + assert!( + waiter_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "waiter must not enter a second external-sort build" + ); + release_tx.send(()).unwrap(); + + let built = builder.join().unwrap().unwrap(); + let waited = waiter.join().unwrap().unwrap(); + assert!(Arc::ptr_eq(&built, &waited)); + }); + } + + #[test] + fn complete_index_missing_relation_returns_empty_without_scan_hash_map() { + let dir = TempDir::new().unwrap(); + write_diamond(dir.path()); + graphforge_storage::adjacency::build_adjacency_index(dir.path(), TS).unwrap(); + let provider = + PersistentAdjacencyProvider::new(dir.path().to_path_buf(), OntologyMode::Strict); + + let missing = provider.adjacency("MISSING", Direction::Out).unwrap(); + + assert!(missing.is_empty()); + assert_eq!(missing.base_csr_entries_expanded(), 0); + assert_eq!( + provider.status("MISSING", Direction::Out), + AdjacencyStatus::Miss, + "the complete current manifest proves bounded empty coverage" + ); + } + + #[test] + fn bounded_build_failure_does_not_fall_back_to_full_scan() { + let dir = TempDir::new().unwrap(); + write_diamond(dir.path()); + let adjacency_path = csr::adjacency_dir(dir.path()); + std::fs::create_dir_all(adjacency_path.parent().unwrap()).unwrap(); + std::fs::write(&adjacency_path, b"blocks adjacency directory creation").unwrap(); + let provider = + PersistentAdjacencyProvider::new(dir.path().to_path_buf(), OntologyMode::Strict); + + let error = provider + .adjacency("KNOWS", Direction::Out) + .expect_err("bounded build failure must fail closed"); + + assert!( + error + .to_string() + .contains("bounded adjacency index build failed"), + "unexpected error: {error}" + ); + } + #[test] fn undirected_csr_merge_preserves_out_before_in_ties() { let out = CsrIndex { diff --git a/crates/graphforge-exec/src/demand.rs b/crates/graphforge-exec/src/demand.rs index 1d1e51d2..0da431d3 100644 --- a/crates/graphforge-exec/src/demand.rs +++ b/crates/graphforge-exec/src/demand.rs @@ -9,8 +9,8 @@ use std::collections::BTreeMap; use std::fmt; use std::pin::Pin; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, LazyLock, Mutex}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; use arrow::datatypes::SchemaRef; @@ -24,6 +24,7 @@ use datafusion::physical_plan::filter::FilterExec; use datafusion::physical_plan::limit::{GlobalLimitExec, LocalLimitExec}; use datafusion::physical_plan::projection::ProjectionExec; use datafusion::physical_plan::repartition::RepartitionExec; +use datafusion::physical_plan::sorts::sort::SortExec; use datafusion::physical_plan::{ DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties, RecordBatchStream, SendableRecordBatchStream, @@ -114,42 +115,255 @@ pub struct DemandSnapshot { pub cancellations: u64, /// Maximum simultaneous filtered-read calls. pub max_in_flight_reads: u64, + /// Blocking ordered operators, in stable top-down plan order. + pub sorts: Vec, + /// Query memory-pool reservation before physical execution. + pub memory_reserved_before: u64, + /// Query memory-pool reservation after every stream/operator was dropped. + pub memory_reserved_after: u64, + /// Process RSS attributed to operator lifetimes by the query sampler. + pub operator_rss: OperatorRssSnapshot, } -static CAPTURE_ENABLED: AtomicBool = AtomicBool::new(false); -static CAPTURE: LazyLock> = - LazyLock::new(|| Mutex::new(DemandSnapshot::default())); +/// Process-memory evidence sampled while blocking operators were alive. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OperatorRssSnapshot { + /// Highest RSS sample while at least one expand stream was alive. + pub expand_peak_bytes: u64, + /// Last RSS sample while an expand stream was alive. + pub expand_current_bytes: u64, + /// Highest RSS sample while a plan containing a sort was collecting. + pub sort_peak_bytes: u64, + /// Last RSS sample while a plan containing a sort was collecting. + pub sort_current_bytes: u64, +} -/// Reset and enable fixed-hop demand capture. -#[doc(hidden)] -pub fn reset() { - *CAPTURE.lock().expect("demand stats lock") = DemandSnapshot::default(); - CAPTURE_ENABLED.store(true, Ordering::SeqCst); +/// Authoritative post-execution DataFusion metrics for one ordered operator. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SortSnapshot { + /// Stable top-down ordinal in the physical plan. + pub ordinal: usize, + /// Hard TopK row bound. `None` means the spillable external sorter path. + pub fetch: Option, + /// Rows emitted by this sort. + pub output_rows: u64, + /// Output record batches emitted by this sort. + pub output_batches: u64, + /// External-sort spill count (zero for TopK). + pub spill_count: u64, + /// External-sort bytes spilled (zero for TopK). + pub spilled_bytes: u64, + /// Memory still reserved when execution completed; this must quiesce to zero. + pub memory_used_after: u64, } -/// Disable demand capture without discarding the last snapshot. -#[doc(hidden)] -pub fn disable() { - CAPTURE_ENABLED.store(false, Ordering::SeqCst); +tokio::task_local! { static ACTIVE_CAPTURE: Arc; } + +struct QueryCapture { + snapshot: Mutex, + expand_active: AtomicUsize, + sort_active: AtomicUsize, + expand_peak: AtomicU64, + expand_current: AtomicU64, + sort_peak: AtomicU64, + sort_current: AtomicU64, + stop: AtomicBool, } -/// Copy the current fixed-hop demand counters. -#[must_use] -#[doc(hidden)] -pub fn snapshot() -> DemandSnapshot { - CAPTURE.lock().expect("demand stats lock").clone() +impl QueryCapture { + fn new() -> Self { + Self { + snapshot: Mutex::new(DemandSnapshot::default()), + expand_active: AtomicUsize::new(0), + sort_active: AtomicUsize::new(0), + expand_peak: AtomicU64::new(0), + expand_current: AtomicU64::new(0), + sort_peak: AtomicU64::new(0), + sort_current: AtomicU64::new(0), + stop: AtomicBool::new(false), + } + } +} + +/// Run one future with isolated, task-scoped query evidence. +pub async fn observe(future: F) -> (F::Output, DemandSnapshot) { + let capture = Arc::new(QueryCapture::new()); + let sampler_capture = Arc::clone(&capture); + let sampler = std::thread::spawn(move || sample_rss(&sampler_capture)); + let guard = SamplerGuard { + capture: Arc::clone(&capture), + sampler: Some(sampler), + }; + let output = ACTIVE_CAPTURE.scope(Arc::clone(&capture), future).await; + drop(guard); + let mut snapshot = capture.snapshot.lock().expect("query capture lock").clone(); + snapshot.operator_rss = OperatorRssSnapshot { + expand_peak_bytes: capture.expand_peak.load(Ordering::Acquire), + expand_current_bytes: capture.expand_current.load(Ordering::Acquire), + sort_peak_bytes: capture.sort_peak.load(Ordering::Acquire), + sort_current_bytes: capture.sort_current.load(Ordering::Acquire), + }; + (output, snapshot) +} + +struct SamplerGuard { + capture: Arc, + sampler: Option>, +} + +impl Drop for SamplerGuard { + fn drop(&mut self) { + self.capture.stop.store(true, Ordering::Release); + if let Some(sampler) = self.sampler.take() { + let _ = sampler.join(); + } + } +} + +#[cfg(test)] +static ACTIVE_SAMPLERS: AtomicUsize = AtomicUsize::new(0); + +fn with_capture(update: impl FnOnce(&QueryCapture)) { + let _ = ACTIVE_CAPTURE.try_with(|capture| update(capture)); } pub(crate) fn capture_enabled() -> bool { - CAPTURE_ENABLED.load(Ordering::Relaxed) + ACTIVE_CAPTURE.try_with(|_| ()).is_ok() } -fn with_hop(edge_var: u32, update: impl FnOnce(&mut HopSnapshot)) { - if !capture_enabled() { - return; +fn current_rss_bytes() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let kib = status + .lines() + .find(|line| line.starts_with("VmRSS:"))? + .split_whitespace() + .nth(1)? + .parse::() + .ok()?; + Some(kib.saturating_mul(1024)) +} + +fn sample_rss(capture: &QueryCapture) { + #[cfg(test)] + ACTIVE_SAMPLERS.fetch_add(1, Ordering::AcqRel); + while !capture.stop.load(Ordering::Acquire) { + if let Some(rss) = current_rss_bytes() { + if capture.expand_active.load(Ordering::Acquire) > 0 { + capture.expand_current.store(rss, Ordering::Release); + capture.expand_peak.fetch_max(rss, Ordering::AcqRel); + } + if capture.sort_active.load(Ordering::Acquire) > 0 { + capture.sort_current.store(rss, Ordering::Release); + capture.sort_peak.fetch_max(rss, Ordering::AcqRel); + } + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + #[cfg(test)] + ACTIVE_SAMPLERS.fetch_sub(1, Ordering::AcqRel); +} + +pub(crate) struct OperatorActivity { + kind: OperatorKind, + capture: Option>, +} +enum OperatorKind { + Expand, + Sort, +} +impl OperatorActivity { + pub(crate) fn expand() -> Self { + Self::new(OperatorKind::Expand) + } + fn sort() -> Self { + Self::new(OperatorKind::Sort) + } + fn new(kind: OperatorKind) -> Self { + let capture = ACTIVE_CAPTURE.try_with(Arc::clone).ok(); + if let Some(capture) = &capture { + match kind { + OperatorKind::Expand => &capture.expand_active, + OperatorKind::Sort => &capture.sort_active, + } + .fetch_add(1, Ordering::AcqRel); + } + Self { kind, capture } + } +} + +pub(crate) fn sort_activity(plan: &Arc) -> Option { + fn contains(plan: &Arc) -> bool { + plan.is::() || plan.children().into_iter().any(contains) + } + contains(plan).then(OperatorActivity::sort) +} +impl Drop for OperatorActivity { + fn drop(&mut self) { + if let Some(capture) = &self.capture { + match self.kind { + OperatorKind::Expand => &capture.expand_active, + OperatorKind::Sort => &capture.sort_active, + } + .fetch_sub(1, Ordering::AcqRel); + } + } +} + +pub(crate) fn record_memory_before(bytes: usize) { + with_capture(|capture| { + capture + .snapshot + .lock() + .expect("query capture lock") + .memory_reserved_before = bytes as u64; + }); +} + +/// Capture metrics only after collection has dropped every operator stream. +pub(crate) fn record_plan_after(plan: &Arc, memory_reserved_after: usize) { + fn value(metrics: &datafusion::physical_plan::metrics::MetricsSet, name: &str) -> u64 { + metrics + .sum(|metric| metric.value().name() == name) + .map_or(0, |metric| metric.as_usize() as u64) + } + fn visit(plan: &Arc, sorts: &mut Vec) { + if plan.is::() { + let metrics = plan.metrics().unwrap_or_default(); + sorts.push(SortSnapshot { + ordinal: sorts.len(), + fetch: plan.fetch(), + output_rows: metrics.output_rows().map_or(0, |rows| rows as u64), + output_batches: value(&metrics, "output_batches"), + spill_count: metrics.spill_count().map_or(0, |count| count as u64), + spilled_bytes: metrics.spilled_bytes().map_or(0, |bytes| bytes as u64), + memory_used_after: value(&metrics, "mem_used"), + }); + } + for child in plan.children() { + visit(child, sorts); + } } - let mut capture = CAPTURE.lock().expect("demand stats lock"); - update(capture.hops.entry(edge_var).or_default()); + + with_capture(|capture| { + let mut snapshot = capture.snapshot.lock().expect("query capture lock"); + snapshot.sorts.clear(); + visit(plan, &mut snapshot.sorts); + snapshot.memory_reserved_after = memory_reserved_after as u64; + }); +} + +fn with_hop(edge_var: u32, update: impl FnOnce(&mut HopSnapshot)) { + with_capture(|capture| { + update( + capture + .snapshot + .lock() + .expect("query capture lock") + .hops + .entry(edge_var) + .or_default(), + ); + }); } pub(crate) fn record_input(edge_var: u32, rows: usize) { @@ -168,20 +382,19 @@ pub(crate) fn record_emitted(edge_var: u32, rows: usize) { } fn record_filter(ordinal: usize, uniqueness: bool, input: bool, rows: usize) { - if !capture_enabled() { - return; - } - let mut capture = CAPTURE.lock().expect("demand stats lock"); - let filter = capture.filters.entry(ordinal).or_insert(FilterSnapshot { - ordinal, - relationship_uniqueness: uniqueness, - ..FilterSnapshot::default() + with_capture(|capture| { + let mut snapshot = capture.snapshot.lock().expect("query capture lock"); + let filter = snapshot.filters.entry(ordinal).or_insert(FilterSnapshot { + ordinal, + relationship_uniqueness: uniqueness, + ..FilterSnapshot::default() + }); + if input { + filter.input_rows += rows as u64; + } else { + filter.output_rows += rows as u64; + } }); - if input { - filter.input_rows += rows as u64; - } else { - filter.output_rows += rows as u64; - } } /// Shared state attached to every fixed hop in one bounded physical plan. @@ -210,7 +423,13 @@ impl QueryDemand { fn cancel(&self) { if !self.cancelled.swap(true, Ordering::AcqRel) && capture_enabled() { - CAPTURE.lock().expect("demand stats lock").cancellations += 1; + with_capture(|capture| { + capture + .snapshot + .lock() + .expect("query capture lock") + .cancellations += 1; + }); } } @@ -228,8 +447,10 @@ impl QueryDemand { self.max_in_flight_reads .fetch_max(current, Ordering::AcqRel); if capture_enabled() { - let mut capture = CAPTURE.lock().expect("demand stats lock"); - capture.max_in_flight_reads = capture.max_in_flight_reads.max(current as u64); + with_capture(|capture| { + let mut snapshot = capture.snapshot.lock().expect("query capture lock"); + snapshot.max_in_flight_reads = snapshot.max_in_flight_reads.max(current as u64); + }); } if self.is_cancelled() { self.finish_read(); @@ -784,7 +1005,7 @@ impl RecordBatchStream for DemandGuardStream { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, LazyLock}; use std::task::Context; use arrow::datatypes::Schema; @@ -795,135 +1016,51 @@ mod tests { use super::*; - #[test] - fn capture_accounts_for_every_hop_filter_and_storage_outcome() { - use graphforge_storage::io_stats::{ - FilteredReadObserver, FilteredReadPruning, FilteredReadStrategy, FilteredReadTable, - }; - - // Process-global capture must not interleave with other capture users. - static CAPTURE_TEST_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); - let _guard = CAPTURE_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - - reset(); - record_input(12, 3); - record_candidates(12, 7); - record_emitted(12, 2); - record_filter(4, true, true, 7); - record_filter(4, true, false, 2); - - let observer = HopReadObserver::new(12); - for table in [FilteredReadTable::Edge, FilteredReadTable::Node] { - observer.read_started(table); - observer.rows_scanned(table, 11); - observer.read_completed(table, 5, true); - observer.read_failed(table); - } - for strategy in [ - FilteredReadStrategy::DenseRowSelection, - FilteredReadStrategy::RowGroupPredicate, - FilteredReadStrategy::FullFallback, - ] { - observer.pruning( - FilteredReadTable::Node, - FilteredReadPruning { - strategy, - row_groups_considered: 9, - row_groups_selected: 4, - pages_considered: 8, - pages_selected: 3, - exact_rows_selected: 2, - metadata_fallbacks: 1, - validation_fallbacks: 1, - }, - ); - } - observer.pruning( - FilteredReadTable::Edge, - FilteredReadPruning { - strategy: FilteredReadStrategy::DenseRowSelection, - row_groups_considered: 99, - row_groups_selected: 99, - pages_considered: 99, - pages_selected: 99, - exact_rows_selected: 99, - metadata_fallbacks: 99, - validation_fallbacks: 99, - }, - ); + static OBSERVATION_TEST_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); - let captured = snapshot(); - let hop = &captured.hops[&12]; - assert_eq!((hop.input_batches, hop.input_rows), (1, 3)); - assert_eq!((hop.candidates_generated, hop.rows_emitted), (7, 2)); - assert_eq!( - ( - hop.edge_reads_started, - hop.edge_reads_completed, - hop.edge_reads_failed - ), - (1, 1, 1) - ); - assert_eq!( - ( - hop.node_reads_started, - hop.node_reads_completed, - hop.node_reads_failed - ), - (1, 1, 1) - ); - assert_eq!( - ( - hop.edge_rows_scanned, - hop.edge_rows_returned, - hop.edge_full_reads - ), - (11, 5, 1) - ); - assert_eq!( - ( - hop.node_rows_scanned, - hop.node_rows_returned, - hop.node_full_reads - ), - (11, 5, 1) - ); - assert_eq!( - ( - hop.node_dense_row_selection_reads, - hop.node_row_group_predicate_reads - ), - (1, 1) - ); - assert_eq!( - (hop.node_row_groups_considered, hop.node_row_groups_selected), - (27, 12) - ); - assert_eq!( - (hop.node_pages_considered, hop.node_pages_selected), - (24, 9) - ); - assert_eq!( - ( - hop.node_exact_rows_selected, - hop.node_metadata_fallbacks, - hop.node_validation_fallbacks - ), - (6, 3, 3) - ); + #[tokio::test] + async fn capture_is_task_scoped_for_overlapping_nested_error_and_unobserved_work() { + let _guard = OBSERVATION_TEST_LOCK.lock().unwrap(); + record_input(99, 1); + let left = observe(async { + record_memory_before(17); + record_input(1, 2); + let (_, nested) = observe(async { record_input(2, 3) }).await; + assert_eq!(nested.hops[&2].input_rows, 3); + let plan: Arc = Arc::new(EmptyExec::new(Arc::new(Schema::empty()))); + record_plan_after(&plan, 0); + Err::<(), _>("typed failure") + }); + let right = observe(async { record_input(7, 5) }); + let ((left_result, left), (_, right)) = tokio::join!(left, right); + assert_eq!(left_result, Err("typed failure")); + assert_eq!(left.hops.len(), 1); + assert_eq!(left.hops[&1].input_rows, 2); assert_eq!( - ( - captured.filters[&4].input_rows, - captured.filters[&4].output_rows - ), - (7, 2) + (left.memory_reserved_before, left.memory_reserved_after), + (17, 0) ); + assert_eq!(right.hops.len(), 1); + assert_eq!(right.hops[&7].input_rows, 5); + assert!(!left.hops.contains_key(&99)); + } + + #[tokio::test] + async fn sampler_is_reaped_when_observation_is_aborted_or_panics() { + let _guard = OBSERVATION_TEST_LOCK.lock().unwrap(); + let aborted = tokio::spawn(async { + observe(std::future::pending::<()>()).await; + }); + tokio::task::yield_now().await; + aborted.abort(); + assert!(aborted.await.unwrap_err().is_cancelled()); + assert_eq!(ACTIVE_SAMPLERS.load(Ordering::Acquire), 0); - disable(); - record_input(12, 100); - assert_eq!(snapshot(), captured); + let panicked = tokio::spawn(async { + observe(async { panic!("observed future panic") }).await; + }); + assert!(panicked.await.unwrap_err().is_panic()); + assert_eq!(ACTIVE_SAMPLERS.load(Ordering::Acquire), 0); } #[test] diff --git a/crates/graphforge-exec/src/lib.rs b/crates/graphforge-exec/src/lib.rs index 3f004966..3c8b11be 100644 --- a/crates/graphforge-exec/src/lib.rs +++ b/crates/graphforge-exec/src/lib.rs @@ -419,6 +419,15 @@ pub struct ExecutionResult { pub mutation_receipt: Option, } +/// One query outcome together with evidence isolated to that query. +#[derive(Debug)] +pub struct ObservedExecution { + /// Typed execution outcome; failures do not discard the evidence. + pub result: Result, + /// Demand, operator, memory-pool, and process-RSS evidence for this query. + pub evidence: demand::DemandSnapshot, +} + impl SideEffects { /// Read a single-write summary batch (`GraphCreateExec` / `GraphDeleteExec` /// / `GraphSetExec` / `GraphRemoveExec`) into a ledger by column name, so one @@ -3369,6 +3378,7 @@ impl ExecutionPlan for ExpandExec { None, batch_size, initial_batch_goal, + demand::OperatorActivity::expand(), ), |( mut input_stream, @@ -3377,6 +3387,7 @@ impl ExecutionPlan for ExpandExec { mut pending, batch_size, mut next_batch_goal, + activity, )| async move { loop { if remaining == Some(0) @@ -3414,6 +3425,7 @@ impl ExecutionPlan for ExpandExec { pending, batch_size, next_batch_goal, + activity, ), ))); } @@ -5031,6 +5043,22 @@ impl ExecutionSession { self.execute_plan_with_params(plan, &HashMap::new()).await } + /// Execute a read plan with evidence scoped to this query and returned on failure. + pub async fn execute_plan_observed(&self, plan: &GraphPlan) -> ObservedExecution { + self.execute_plan_with_params_observed(plan, &HashMap::new()) + .await + } + + /// Execute a parameterized read plan with query-scoped evidence. + pub async fn execute_plan_with_params_observed( + &self, + plan: &GraphPlan, + params: &HashMap, + ) -> ObservedExecution { + let (result, evidence) = demand::observe(self.execute_plan_with_params(plan, params)).await; + ObservedExecution { result, evidence } + } + /// Execute a read [`GraphPlan`], substituting `$name` placeholders with the /// supplied parameter values, and return the result. /// @@ -5052,9 +5080,12 @@ impl ExecutionSession { let resolved_plan = self.resolve_row_count_expressions(plan, params).await?; let (physical, fallback_schema) = self.plan_physical(&resolved_plan, params).await?; - let mut batches = collect(Arc::clone(&physical), self.ctx.task_ctx()) - .await - .map_err(|e| GfError::Execution(e.to_string()))?; + demand::record_memory_before(self.ctx.runtime_env().memory_pool.reserved()); + + let _sort_activity = demand::sort_activity(&physical); + let collected = collect(Arc::clone(&physical), self.ctx.task_ctx()).await; + demand::record_plan_after(&physical, self.ctx.runtime_env().memory_pool.reserved()); + let mut batches = collected.map_err(|e| GfError::Execution(e.to_string()))?; // DataFusion's collect may return zero batches for an empty stream. // Public callers (and DF54-era optimistic publish tests) index diff --git a/crates/graphforge-exec/tests/explain_snapshots.rs b/crates/graphforge-exec/tests/explain_snapshots.rs index d55d70fe..15ea1535 100644 --- a/crates/graphforge-exec/tests/explain_snapshots.rs +++ b/crates/graphforge-exec/tests/explain_snapshots.rs @@ -74,9 +74,7 @@ fn manifest_dir() -> std::path::PathBuf { if raw.is_absolute() { return raw.to_path_buf(); } - std::env::current_dir() - .map(|cwd| cwd.join(raw)) - .unwrap_or_else(|_| raw.to_path_buf()) + std::env::current_dir().map_or_else(|_| raw.to_path_buf(), |cwd| cwd.join(raw)) } fn golden_settings(dir: &Path) -> insta::Settings { diff --git a/crates/graphforge-exec/tests/persistent_adjacency.rs b/crates/graphforge-exec/tests/persistent_adjacency.rs index 15d47c6a..c027b560 100644 --- a/crates/graphforge-exec/tests/persistent_adjacency.rs +++ b/crates/graphforge-exec/tests/persistent_adjacency.rs @@ -24,6 +24,7 @@ const PERSON: TypeId = TypeId(0); /// Strict-mode diamond a→b, a→c, b→d, c→d plus a parallel a→b and a self-loop /// d→d, all KNOWS — the fixture whose self-loop pins the undirected merge /// order. Returns the surrogate node ids. +#[allow(clippy::many_single_char_names)] fn write_diamond(dir: &Path) -> [u64; 4] { let mut w = GraphWriter::open_at(dir, OntologyMode::Strict, TS).unwrap(); let uuids: Vec = (0..4).map(|_| new_v7()).collect(); diff --git a/crates/graphforge-storage/src/adjacency.rs b/crates/graphforge-storage/src/adjacency.rs index 34549995..4f627d10 100644 --- a/crates/graphforge-storage/src/adjacency.rs +++ b/crates/graphforge-storage/src/adjacency.rs @@ -61,6 +61,22 @@ use crate::staging::RewriteBatch; /// relation types (matching the `_exploratory.parquet` convention). pub const ALL_RELATIONS_STEM: &str = "_all"; +/// Fixed-size, path-safe persistent key for one exact UTF-8 relation name. +/// Identity is additionally bound and validated through the manifest's exact +/// `relation_name`; the digest alone is never accepted as proof of identity. +#[must_use] +pub fn adjacency_relation_key(relation_type_name: &str) -> String { + format!("rel-{}", sha256_hex(relation_type_name.as_bytes())) +} + +/// Whether `value` is a structurally valid exact-relation key. +#[must_use] +pub fn is_adjacency_relation_key(value: &str) -> bool { + value.len() == 68 + && value.starts_with("rel-") + && value.as_bytes()[4..].iter().all(u8::is_ascii_hexdigit) +} + /// File name of the adjacency index manifest within `indexes/adjacency/`. pub const MANIFEST_FILE: &str = "index_manifest.parquet"; @@ -682,6 +698,8 @@ impl<'a> CsrRow<'a> { pub struct AdjacencyManifestRow { /// Relation type name, or [`ALL_RELATIONS_STEM`] for the union index. pub relation_type: String, + /// Exact original relation name; `None` only for the wildcard union row. + pub relation_name: Option, /// Direction the CSR file is keyed by. pub direction: Direction, /// Project topology generation the CSR was built from. @@ -785,7 +803,12 @@ pub fn adjacency_dir(project_dir: &Path) -> PathBuf { /// `indexes/adjacency/..csr`. #[must_use] pub fn csr_path(project_dir: &Path, relation_type: &str, direction: Direction) -> PathBuf { - adjacency_dir(project_dir).join(format!("{relation_type}.{}.csr", direction.as_str())) + let key = if relation_type == ALL_RELATIONS_STEM || is_adjacency_relation_key(relation_type) { + relation_type.to_owned() + } else { + adjacency_relation_key(relation_type) + }; + adjacency_dir(project_dir).join(format!("{key}.{}.csr", direction.as_str())) } /// Path of `index_manifest.parquet` within `project_dir`. @@ -991,6 +1014,7 @@ pub fn write_manifest(project_dir: &Path, rows: &[AdjacencyManifestRow]) -> Resu .map(|r| Some(r.relation_type.as_str())) .collect(); let directions: StringArray = rows.iter().map(|r| Some(r.direction.as_str())).collect(); + let relation_names: StringArray = rows.iter().map(|r| r.relation_name.as_deref()).collect(); let generations: Vec = rows.iter().map(|r| r.topology_generation).collect(); let built_ats: Vec = rows.iter().map(|r| r.built_at_micros).collect(); let node_counts: Vec = rows.iter().map(|r| r.node_count).collect(); @@ -999,6 +1023,7 @@ pub fn write_manifest(project_dir: &Path, rows: &[AdjacencyManifestRow]) -> Resu Arc::clone(&ADJACENCY_MANIFEST_SCHEMA), vec![ Arc::new(relation_types), + Arc::new(relation_names), Arc::new(directions), Arc::new(UInt64Array::from(generations)), Arc::new(TimestampMicrosecondArray::from(built_ats).with_timezone("UTC")), @@ -1047,20 +1072,23 @@ pub fn read_manifest(project_dir: &Path) -> Result, Gf ))); } let relation_types = string_column(batch.column(0), "relation_type")?; - let directions = string_column(batch.column(1), "direction")?; - let generations = uint64_column(batch.column(2), "topology_generation")?; + let relation_names = string_column(batch.column(1), "relation_name")?; + let directions = string_column(batch.column(2), "direction")?; + let generations = uint64_column(batch.column(3), "topology_generation")?; let built_ats = batch - .column(3) + .column(4) .as_any() .downcast_ref::() .ok_or_else(|| { GfError::Storage("adjacency manifest: built_at is not a timestamp".to_owned()) })?; - let node_counts = uint64_column(batch.column(4), "node_count")?; - let edge_counts = uint64_column(batch.column(5), "edge_count")?; + let node_counts = uint64_column(batch.column(5), "node_count")?; + let edge_counts = uint64_column(batch.column(6), "edge_count")?; for i in 0..batch.num_rows() { rows.push(AdjacencyManifestRow { relation_type: relation_types.value(i).to_owned(), + relation_name: (!relation_names.is_null(i)) + .then(|| relation_names.value(i).to_owned()), direction: Direction::parse(directions.value(i))?, topology_generation: generations.value(i), built_at_micros: built_ats.value(i), @@ -1186,10 +1214,9 @@ fn resource_limit(message: impl Into) -> GfError { /// /// Mode-agnostic: `_exploratory.parquet` rows are grouped by their /// `rel_type_name` column; every other file is a typed edge table keyed by its -/// file stem. Relation names that are not usable as a file stem (path -/// separators, `..`, empty) or that collide with the reserved -/// [`ALL_RELATIONS_STEM`] are skipped — those relations are served by -/// scan-build forever, but their rows still flow into the union index. +/// file stem. Every exact relation name is mapped through +/// [`adjacency_relation_key`], so path-unsafe names and the literal `_all` are +/// independently addressable without colliding with the wildcard union. /// /// The project `topology_generation` is read **before** any edge scan and /// stamped into the manifest: a concurrent topology write mid-build bumps the @@ -1315,6 +1342,7 @@ pub fn build_adjacency_index_into_with_metrics( metrics.peak_shard_nodes = metrics.peak_shard_nodes.max(outcome.peak_shard_nodes); manifest.push(AdjacencyManifestRow { relation_type: stem.to_owned(), + relation_name: group.relation_name.clone(), direction, topology_generation: generation, built_at_micros, @@ -1455,6 +1483,7 @@ struct EntryGroup { out_runs: Vec, in_runs: Vec, label: String, + relation_name: Option, } impl EntryGroup { @@ -1465,6 +1494,14 @@ impl EntryGroup { } } + fn for_relation(label: impl Into, relation_name: &str) -> Self { + Self { + label: label.into(), + relation_name: Some(relation_name.to_owned()), + ..Self::default() + } + } + fn push( &mut self, entry: BuildEntry, @@ -1840,17 +1877,22 @@ fn stream_build_groups( .expect("union group") .push(entry, options.chunk_rows, spill, checkpoint)?; let rel = rel_names.map_or(stem, |names| names.value(i)); - if usable_stem(rel) { - if !groups.contains_key(rel) { - groups.insert(rel.to_owned(), EntryGroup::with_label(rel)); + let key = adjacency_relation_key(rel); + if let Some(existing) = groups.get(&key) { + if existing.relation_name.as_deref() != Some(rel) { + return Err(GfError::Storage( + "adjacency relation-key collision between distinct exact names".into(), + )); } - groups.get_mut(rel).expect("rel group").push( - entry, - options.chunk_rows, - spill, - checkpoint, - )?; + } else { + groups.insert(key.clone(), EntryGroup::for_relation(&key, rel)); } + groups.get_mut(&key).expect("rel group").push( + entry, + options.chunk_rows, + spill, + checkpoint, + )?; } Ok(()) }, @@ -1942,8 +1984,7 @@ pub(crate) fn stream_projected_parquet_batches( } /// Scan `topology/edges/` and group every edge occurrence by relation type: -/// per-relation entries (stems unusable as file names are skipped, see -/// [`build_adjacency_index`]) plus the full union. Shared by the validator and +/// collision-safe encoded per-relation entries plus the full union. Shared by the validator and /// inspector. Uses the projected streaming reader so validation/inspection /// cannot hit the full-file UUID concat ceiling (#336). #[allow(clippy::type_complexity)] @@ -1989,10 +2030,8 @@ fn collect_adjacency_groups_with_batch_size( for i in 0..batch.num_rows() { let entry = (src_ids.value(i), edge_ids.value(i), dst_ids.value(i)); union_out.push(entry); - let rel = rel_names.map_or(stem, |names| names.value(i)); - if usable_stem(rel) { - groups.entry(rel.to_owned()).or_default().push(entry); - } + let rel = adjacency_relation_key(rel_names.map_or(stem, |names| names.value(i))); + groups.entry(rel).or_default().push(entry); } Ok(()) })?; @@ -2405,17 +2444,6 @@ pub(crate) fn csr_from_entries(entries: &[BuildEntry], direction: Direction) -> csr } -/// Whether `rel` is usable as a CSR file stem: a single plain path component -/// (no separators, no `..`, non-empty — the same rule `read_edges` applies to -/// typed file names) and not the reserved [`ALL_RELATIONS_STEM`]. -pub(crate) fn usable_stem(rel: &str) -> bool { - if rel == ALL_RELATIONS_STEM { - return false; - } - let mut comps = Path::new(rel).components(); - matches!(comps.next(), Some(std::path::Component::Normal(_))) && comps.next().is_none() -} - /// Borrow a column by name, erroring on absence. fn named_column<'a>( batch: &'a arrow::record_batch::RecordBatch, @@ -2874,6 +2902,7 @@ mod tests { let rows = vec![ AdjacencyManifestRow { relation_type: "WORKS_AT".to_owned(), + relation_name: Some("WORKS_AT".to_owned()), direction: Direction::Out, topology_generation: 7, built_at_micros: TS, @@ -2882,6 +2911,7 @@ mod tests { }, AdjacencyManifestRow { relation_type: "WORKS_AT".to_owned(), + relation_name: Some("WORKS_AT".to_owned()), direction: Direction::In, topology_generation: 7, built_at_micros: TS, @@ -2890,6 +2920,7 @@ mod tests { }, AdjacencyManifestRow { relation_type: "OWNS".to_owned(), + relation_name: Some("OWNS".to_owned()), direction: Direction::Out, topology_generation: 7, built_at_micros: TS + 1, @@ -2898,6 +2929,7 @@ mod tests { }, AdjacencyManifestRow { relation_type: ALL_RELATIONS_STEM.to_owned(), + relation_name: None, direction: Direction::Out, topology_generation: 7, built_at_micros: TS + 2, @@ -2920,6 +2952,7 @@ mod tests { let dir = TempDir::new().unwrap(); let first = vec![AdjacencyManifestRow { relation_type: "KNOWS".to_owned(), + relation_name: Some("KNOWS".to_owned()), direction: Direction::Out, topology_generation: 1, built_at_micros: 0, @@ -2930,6 +2963,7 @@ mod tests { let second = vec![AdjacencyManifestRow { relation_type: "KNOWS".to_owned(), + relation_name: Some("KNOWS".to_owned()), direction: Direction::Out, topology_generation: 2, built_at_micros: 1, @@ -3047,7 +3081,9 @@ mod tests { let knows_manifest = read_manifest(dir.path()) .unwrap() .into_iter() - .find(|r| r.relation_type == "KNOWS" && r.direction == Direction::Out) + .find(|r| { + r.relation_type == adjacency_relation_key("KNOWS") && r.direction == Direction::Out + }) .unwrap(); assert_eq!(knows_manifest.node_count, csr.node_count()); assert_eq!(knows_manifest.edge_count, 6); @@ -3159,32 +3195,43 @@ mod tests { } #[test] - fn hostile_and_reserved_stems_are_skipped_but_counted_in_union() { + fn unsafe_and_reserved_relation_names_get_distinct_persistent_keys() { let dir = TempDir::new().unwrap(); let mut w = GraphWriter::open_at(dir.path(), OntologyMode::Exploratory, BUILD_TS).unwrap(); - let (a, b, c) = (new_v7(), new_v7(), new_v7()); - for u in [a, b, c] { + let (a, b, c, d) = (new_v7(), new_v7(), new_v7(), new_v7()); + for u in [a, b, c, d] { w.create_node(u, TypeId(0)).unwrap(); } + let long_name = "x".repeat(1_024); w.create_edge(new_v7(), "a/b", &a, &b).unwrap(); w.create_edge(new_v7(), ALL_RELATIONS_STEM, &a, &c).unwrap(); + w.create_edge(new_v7(), &long_name, &a, &d).unwrap(); w.flush().unwrap(); let rows = build_adjacency_index(dir.path(), BUILD_TS).unwrap(); - // Only the union pair: both rel names are unusable as stems. - assert!(rows.iter().all(|r| r.relation_type == ALL_RELATIONS_STEM)); - assert_eq!(rows.len(), 2); + let unsafe_key = adjacency_relation_key("a/b"); + let literal_all_key = adjacency_relation_key(ALL_RELATIONS_STEM); + assert_ne!(unsafe_key, literal_all_key); + assert_ne!(literal_all_key, ALL_RELATIONS_STEM); + assert!(rows.iter().any(|r| r.relation_type == unsafe_key)); + assert!(rows.iter().any(|r| r.relation_type == literal_all_key)); + assert_eq!(rows.len(), 8, "three exact pairs plus the wildcard pair"); let all = read_csr(&csr_path(dir.path(), ALL_RELATIONS_STEM, Direction::Out)).unwrap(); + assert_eq!(all.edge_count(), 3, "exact rels still flow into the union"); + let unsafe_exact = read_csr(&csr_path(dir.path(), "a/b", Direction::Out)).unwrap(); + assert_eq!(unsafe_exact.edge_count(), 1); + let literal_all = + read_csr(&csr_path(dir.path(), ALL_RELATIONS_STEM, Direction::Out)).unwrap(); + assert_eq!(literal_all.edge_count(), 3, "reserved path means wildcard"); + let literal_all_exact = + read_csr(&csr_path(dir.path(), &literal_all_key, Direction::Out)).unwrap(); + assert_eq!(literal_all_exact.edge_count(), 1); + let long_exact = read_csr(&csr_path(dir.path(), &long_name, Direction::Out)).unwrap(); assert_eq!( - all.edge_count(), - 2, - "skipped rels still flow into the union" - ); - assert!( - !csr_path(dir.path(), "a/b", Direction::Out).exists(), - "no nested path written for the separator-bearing rel name" + long_exact.edge_count(), + 1, + "fixed digest stays below NAME_MAX" ); - assert!(!csr_path(dir.path(), "a", Direction::Out).exists()); } #[test] diff --git a/crates/graphforge-storage/src/adjacency_delta.rs b/crates/graphforge-storage/src/adjacency_delta.rs index 6c703235..58f3eb87 100644 --- a/crates/graphforge-storage/src/adjacency_delta.rs +++ b/crates/graphforge-storage/src/adjacency_delta.rs @@ -30,8 +30,8 @@ use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; use graphforge_core::GfError; use crate::adjacency::{ - ALL_RELATIONS_STEM, BuildEntry, CsrIndex, CsrRow, Direction, adjacency_dir, csr_from_entries, - usable_stem, + ALL_RELATIONS_STEM, BuildEntry, CsrIndex, CsrRow, Direction, adjacency_dir, + adjacency_relation_key, csr_from_entries, }; use crate::schemas::ADJACENCY_DELTA_SCHEMA; use crate::staging::RewriteBatch; @@ -236,7 +236,7 @@ pub fn prune_delta_segments(project_dir: &Path, up_to: u64) { /// plus the chain's, for `stem`. /// /// `stem == _all` takes every delta edge; a per-relation `stem` takes edges -/// whose `rel_type_name == stem` (and `usable_stem`, matching the builder, so a +/// whose exact relation key matches `stem` (matching the builder, so a /// hostile relation name can never be materialized at a per-relation path). /// /// Implementation: reconstruct the base `(src, edge, dst)` entries from the CSR, @@ -256,7 +256,7 @@ pub fn apply_delta_segments( let take_all = stem == ALL_RELATIONS_STEM; for seg in chain { for e in &seg.edges { - if take_all || (e.rel_type_name == stem && usable_stem(&e.rel_type_name)) { + if take_all || adjacency_relation_key(&e.rel_type_name) == stem { entries.push((e.src_id, e.edge_id, e.dst_id)); } } @@ -350,7 +350,7 @@ pub fn overlay_delta_segments( let mut saw_key = false; for seg in chain { for e in &seg.edges { - if take_all || (e.rel_type_name == stem && usable_stem(&e.rel_type_name)) { + if take_all || adjacency_relation_key(&e.rel_type_name) == stem { let (key, neighbor) = match direction { Direction::Out => (e.src_id, e.dst_id), Direction::In => (e.dst_id, e.src_id), diff --git a/crates/graphforge-storage/src/catalog.rs b/crates/graphforge-storage/src/catalog.rs index f509727b..1951da63 100644 --- a/crates/graphforge-storage/src/catalog.rs +++ b/crates/graphforge-storage/src/catalog.rs @@ -1165,6 +1165,77 @@ where Ok(()) } +/// Count canonical node rows, optionally restricted to one exact persisted +/// entity [`TypeId`](graphforge_core::TypeId), without materializing the node +/// table or unrelated graph inventory. +/// +/// The scan is bounded by `batch_size` and applies the same legacy scalar-label +/// normalization as [`read_nodes`]. A node carrying the requested type in its +/// complete `type_ids` set contributes exactly once. +/// +/// # Errors +/// Propagates Parquet / Arrow errors and rejects a count that exceeds `u64`. +pub fn count_nodes_batched( + dir: &Path, + type_id: Option, + batch_size: usize, +) -> Result { + use arrow::array::{Array, ListArray, UInt32Array}; + + let mut count = 0_u64; + visit_nodes_batched(dir, batch_size, |batch| { + let increment = if let Some(type_id) = type_id { + let labels = batch + .column_by_name("type_ids") + .and_then(|column| column.as_any().downcast_ref::()) + .ok_or_else(|| { + DataFusionError::Execution( + "canonical node topology type_ids is not List".into(), + ) + })?; + let values = labels + .values() + .as_any() + .downcast_ref::() + .ok_or_else(|| { + DataFusionError::Execution( + "canonical node topology type_ids values are not UInt32".into(), + ) + })?; + let mut matched = 0_u64; + for row in 0..batch.num_rows() { + if labels.is_null(row) { + return Err(DataFusionError::Execution( + "canonical node topology type_ids row is null".into(), + )); + } + let offsets = labels.value_offsets(); + let start = usize::try_from(offsets[row]).map_err(|_| { + DataFusionError::Execution("type_ids start offset exceeds usize".into()) + })?; + let end = usize::try_from(offsets[row + 1]).map_err(|_| { + DataFusionError::Execution("type_ids end offset exceeds usize".into()) + })?; + if (start..end).any(|index| values.value(index) == type_id.0) { + matched = matched.checked_add(1).ok_or_else(|| { + DataFusionError::Execution("node count exceeds UInt64".into()) + })?; + } + } + matched + } else { + u64::try_from(batch.num_rows()).map_err(|_| { + DataFusionError::Execution("node batch row count exceeds UInt64".into()) + })? + }; + count = count + .checked_add(increment) + .ok_or_else(|| DataFusionError::Execution("node count exceeds UInt64".into()))?; + Ok(true) + })?; + Ok(count) +} + /// Edge analogue of [`read_properties`]: read `edge_properties/.parquet` /// (keyed by `edge_uuid`), discovering its dynamic schema from the file. Returns /// an **empty `Vec`** when the file is absent. @@ -2025,15 +2096,33 @@ mod tests { } fn write_nodes_parquet(path: &Path) { - let uuid_bytes: Vec = vec![1u8; 16]; - let uuid_arr = - FixedSizeBinaryArray::try_from_iter(std::iter::once(uuid_bytes.clone())).unwrap(); - let ts = - TimestampMicrosecondArray::from(vec![0i64]).with_timezone_opt(Some(Arc::from("UTC"))); + write_nodes_with_labels(path, &[vec![0]]); + } + + fn write_nodes_with_labels(path: &Path, row_labels: &[Vec]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let uuid_arr = FixedSizeBinaryArray::try_from_iter( + (0..row_labels.len()).map(|index| (index as u128 + 1).to_be_bytes()), + ) + .unwrap(); + let ts = TimestampMicrosecondArray::from(vec![0i64; row_labels.len()]) + .with_timezone_opt(Some(Arc::from("UTC"))); + let offsets = row_labels + .iter() + .scan(0_i32, |offset, labels| { + let current = *offset; + *offset += i32::try_from(labels.len()).unwrap(); + Some(current) + }) + .chain(std::iter::once( + i32::try_from(row_labels.iter().map(Vec::len).sum::()).unwrap(), + )) + .collect::>(); + let values = row_labels.iter().flatten().copied().collect::>(); let labels = arrow::array::ListArray::new( Arc::new(Field::new("item", DataType::UInt32, false)), - OffsetBuffer::new(vec![0, 1].into()), - Arc::new(UInt32Array::from(vec![0u32])), + OffsetBuffer::new(offsets.into()), + Arc::new(UInt32Array::from(values)), None, ); @@ -2041,8 +2130,15 @@ mod tests { TOPOLOGY_NODES_SCHEMA.clone(), vec![ Arc::new(uuid_arr), - Arc::new(UInt64Array::from(vec![1u64])), - Arc::new(UInt32Array::from(vec![0u32])), + Arc::new(UInt64Array::from_iter_values( + 1..=u64::try_from(row_labels.len()).unwrap(), + )), + Arc::new(UInt32Array::from( + row_labels + .iter() + .map(|labels| labels[0]) + .collect::>(), + )), Arc::new(labels), Arc::new(ts.clone()), Arc::new(ts), @@ -2603,6 +2699,23 @@ mod tests { assert_eq!(batches[0].schema(), TOPOLOGY_NODES_SCHEMA.clone()); } + #[test] + fn count_nodes_batched_counts_complete_label_sets_without_materializing_inventory() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("topology/nodes.parquet"); + write_nodes_with_labels(&path, &[vec![7, 9], vec![9], vec![11]]); + + assert_eq!(count_nodes_batched(dir.path(), None, 1).unwrap(), 3); + assert_eq!( + count_nodes_batched(dir.path(), Some(graphforge_core::TypeId(9)), 1).unwrap(), + 2 + ); + assert_eq!( + count_nodes_batched(dir.path(), Some(graphforge_core::TypeId(42)), 1).unwrap(), + 0 + ); + } + #[test] fn catalog_and_schema_debug_identity_are_stable_and_content_free() { let schema = GraphSchema::new(); diff --git a/crates/graphforge-storage/src/lib.rs b/crates/graphforge-storage/src/lib.rs index 96c39b14..3e1913c4 100644 --- a/crates/graphforge-storage/src/lib.rs +++ b/crates/graphforge-storage/src/lib.rs @@ -310,10 +310,10 @@ pub use uuid_membership::{ pub mod catalog; pub use catalog::{ EdgePropertyTable, GraphCatalog, PropertyTable, TopologyNodeTable, TypedEdgeTable, - UnionEdgeTable, list_edge_property_stems, list_property_stems, read_edge_properties, - read_edges, read_edges_filtered, read_edges_filtered_observed, read_nodes, read_nodes_filtered, - read_nodes_filtered_observed, read_properties, read_properties_batched, visit_nodes_batched, - visit_properties_batched, + UnionEdgeTable, count_nodes_batched, list_edge_property_stems, list_property_stems, + read_edge_properties, read_edges, read_edges_filtered, read_edges_filtered_observed, + read_nodes, read_nodes_filtered, read_nodes_filtered_observed, read_properties, + read_properties_batched, visit_nodes_batched, visit_properties_batched, }; pub mod runtime_entity_labels; diff --git a/crates/graphforge-storage/src/schemas.rs b/crates/graphforge-storage/src/schemas.rs index 9c6202e4..592cdd64 100644 --- a/crates/graphforge-storage/src/schemas.rs +++ b/crates/graphforge-storage/src/schemas.rs @@ -296,11 +296,13 @@ pub static ADJACENCY_CSR_SCHEMA: LazyLock = LazyLock::new(|| { /// /// One row per CSR file. `topology_generation` records the topology counter /// the CSR was built from; a mismatch against the project's current counter -/// marks the index stale. `relation_type` is a relation type name or the -/// reserved `_all` stem for the union index. +/// marks the index stale. `relation_type` is a fixed path-safe relation key or +/// the reserved `_all` stem; nullable `relation_name` binds an exact key to its +/// original UTF-8 name and is absent only for the wildcard union. pub static ADJACENCY_MANIFEST_SCHEMA: LazyLock = LazyLock::new(|| { Arc::new(Schema::new(vec![ Field::new("relation_type", DataType::Utf8, false), + Field::new("relation_name", DataType::Utf8, true), Field::new("direction", DataType::Utf8, false), Field::new("topology_generation", DataType::UInt64, false), ts_field("built_at"), @@ -681,6 +683,7 @@ mod tests { names, [ "relation_type", + "relation_name", "direction", "topology_generation", "built_at", @@ -688,7 +691,7 @@ mod tests { "edge_count" ] ); - for name in ["relation_type", "direction"] { + for name in ["relation_type", "relation_name", "direction"] { let f = s.field_with_name(name).unwrap(); assert_eq!(f.data_type(), &DataType::Utf8); } @@ -700,7 +703,13 @@ mod tests { s.field_with_name("built_at").unwrap().data_type(), &DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())) ); - assert!(s.fields().iter().all(|f| !f.is_nullable())); + assert!(s.field_with_name("relation_name").unwrap().is_nullable()); + assert!( + s.fields() + .iter() + .filter(|field| field.name() != "relation_name") + .all(|field| !field.is_nullable()) + ); } #[test] diff --git a/docs/book/architecture/execution-model.md b/docs/book/architecture/execution-model.md index 6fcede5d..081edb8e 100644 --- a/docs/book/architecture/execution-model.md +++ b/docs/book/architecture/execution-model.md @@ -119,18 +119,28 @@ adjacency-backed physical node when the provider covers the relation type + dire back to the DataFusion hash-join path otherwise. Both paths produce identical results; only speed differs. -The index is **optional and never authoritative**: a stale or missing index -falls back to scan-and-build with identical output. The pinned v0.5 -project-generation UUID and graph source fingerprint detect staleness; the -committed Parquet graph participant is always the source of truth. +The index is **derived and never authoritative**: a stale, corrupt, or missing +index is reconstructed from committed Parquet by the bounded external-sort +builder. The pinned v0.5 project-generation UUID and graph source fingerprint +detect staleness; committed Parquet remains the source of truth. If bounded +reconstruction cannot complete, execution fails closed; the project facade +does not substitute an O(E)-memory scan-built hash map. + +Query-triggered reconstruction publishes into an instance-private derived +cache, never into the immutable generation graph tree. The cache lives on the +admitted project volume by default, or under the explicitly configured spill +directory, and is removed with the instance. Each provider has its own child +namespace so projected and alternate-mode providers cannot race the builder's +fixed staging paths. Fresh index hits serve a **CSR-native** adjacency view (#340): validated offsets with parallel edge/neighbor columns and O(1) row lookup. Undirected requests keep separate out/in CSRs and merge per accessed row (out before in on equal `edge_id`) without materializing a full merged hash map. Delta overlays attach a bounded replacement map over touched keys only — they do -not copy the complete valid base CSR. Scan-built fallback retains the -historical hash-map representation for oracle parity. Analyst +not copy the complete valid base CSR. The standalone scan-build provider +retains the historical hash-map representation solely for oracle and +foreign-session use. Analyst `export_adjacency` projects selected nodes into a flat CSR of algorithm edges rather than duplicating the full graph into per-node heap vectors. @@ -311,6 +321,15 @@ Arrow is used as the internal execution currency: - **C Stream Interface** — batch readers for streaming results - **Arrow IPC** — serialized stream for cross-process use (Node today; future UniFFI consumers) +Observed execution is query scoped. `GraphForge::execute_observed` returns the +typed query outcome, including an execution error, together with demand, sort, +memory-pool, and sampled process-RSS evidence. Ordinary execution has no +observer overhead or mutable global capture state. Expand RSS is sampled for +the lifetime of each `ExpandExec` stream; sort RSS covers physical collection +when the plan contains a sort. The lifetimes may overlap, so both peaks +intentionally attribute shared process RSS rather than claiming an exclusive +per-operator partition of process memory. + Query-result files use the same demand-driven `RecordBatch` stream. Parquet and Arrow IPC sinks request one batch only after the preceding batch has been accepted, enforce configured batch and Parquet row-group limits, and publish a diff --git a/docs/book/architecture/storage.md b/docs/book/architecture/storage.md index 51ac8b5b..758b5d2e 100644 --- a/docs/book/architecture/storage.md +++ b/docs/book/architecture/storage.md @@ -287,7 +287,8 @@ Conventions: `ShardedCsrIndex` on a persisted hit and materialize only the requested logical row from its bounded shard fragments. Legacy single-batch `.csr` files remain readable and migrate on rebuild. - Scan-build fallback still materializes a hash map for oracle parity. + The standalone scan-build provider remains a test/oracle implementation; ordinary + project execution never selects its graph-cardinality-sized hash map. ### Rebuild and versioning semantics @@ -305,11 +306,19 @@ Conventions: - **Staleness detection.** The provider compares the manifest's topology counter with the current counter and validates any required bounded delta chain. A corrupt accelerator is never served as a hit. -- **Fallback.** On mismatch (or absent index), the provider scans the typed edge tables and - builds the adjacency in memory — yielding identical results, only slower. A stale or missing - index can therefore never cause incorrect output. -- **Rebuild triggers.** Lazy on first traversal when the `indexes/adjacency/` capability is - present, or explicit via `forge.index("adjacency", ...)`. Append-only commits +- **Bounded recovery.** On mismatch, corruption, an absent index, or relation coverage + missing while a delta chain is present, the persistent provider runs the external-sort + builder into an instance-private, per-provider cache and serves the resulting sharded + CSR. Lazy query repair never mutates an immutable generation or its authenticated + graph/files inventory. The cache uses the admitted project volume by default, honors + an explicitly configured spill directory, and is removed on instance teardown. A + complete current manifest with no row and no deltas proves that relation is empty. + Ordinary traversal never diverts into the + O(E)-memory scan-build oracle. A build or load failure fails closed with a typed + execution error rather than risking OOM. +- **Rebuild triggers.** Lazy on the first traversal, including when the + `indexes/adjacency/` capability is absent, or explicit via + `forge.index("adjacency", ...)`. Append-only commits publish bounded delta segments; a full rebuild compacts them into sharded bases. - **Determinism (R-ADJ-2).** Full rebuild streams each typed edge file once; `out` entries sort by `(src_id, edge_id)` and `in` entries by `(dst_id, edge_id)` — the `edge_id` @@ -330,13 +339,12 @@ Conventions: - **Loader semantics** (`graphforge_exec::PersistentAdjacencyProvider`). Freshness requires a non-empty manifest whose topology generation is current directly or through a complete bounded delta chain. Fresh + row present ⇒ load - (`adjacency=hit`); stale or torn ⇒ lazy rebuild, then serve; fresh but **no - row** for the requested relation ⇒ scan-build *without* rebuild (rebuilding - cannot add an unknown relation — prevents a rebuild-per-query loop); a - corrupt accelerator ⇒ always-stale scan-build; capability absent ⇒ scan-build - (`adjacency=building`). Typed-mode `"*"` bypasses the index entirely - (reported as `building`, never a false miss). A build or load failure never - fails the query — only its speed. + (`adjacency=hit`); stale, torn, absent, or missing a requested relation row + while deltas exist ⇒ bounded lazy rebuild, then serve. A complete current + manifest with no delta chain and no such row proves the relation is empty. + Build/load failure is an execution error. The standalone + `ScanBuildAdjacencyProvider` is retained only for + explicit oracle and foreign-session uses, not the project facade. - **Direction.** `out` and `in` CSRs are stored separately; undirected traversal unions them. In exploratory mode, `_exploratory.parquet` rows are routed by their `rel_type_name` column. diff --git a/docs/development/perf-g500-ladder.md b/docs/development/perf-g500-ladder.md index 9904e6d3..90dc31e4 100644 --- a/docs/development/perf-g500-ladder.md +++ b/docs/development/perf-g500-ladder.md @@ -96,9 +96,11 @@ seconds. ## First-fail ladder `run_ladder` walks the provisioned rungs in increasing scale and, after each -phase (`generate`, `ingest`, `reopen`, `query`), compares peak RSS / disk / +phase (`generate`, `ingest`, `reopen`, `node_count`, `edge_count`, `one_hop`, +`two_hop`), compares peak RSS / disk / elapsed time against the envelope. On the **first** violation it records the -failing phase and `error_class` (`oom` | `disk_exhaustion` | `timeout`) and +failing phase and `error_class` (`oom` | `disk_exhaustion` | `timeout` | `execution_failure` | +`result_mismatch`) and stops — no larger rung is attempted and no SCALE-26 pass is claimed. > RSS fidelity: peak RSS is read from `/proc/self/status` `VmHWM` on Linux @@ -119,6 +121,28 @@ stops — no larger rung is attempted and no SCALE-26 pass is claimed. > construction is tracked separately by #901 and remains required before the > billion-edge close gate. +### S20 Fly baseline and interpretation + +The exact-merge `eccb6e06726d05cdef9e5242cad885be80565eee` S20 attempt ran on +a Fly performance Machine with 2 vCPUs, 4 GiB RAM, and an attached NVMe volume. +Ingest completed at approximately **688 MB peak RSS**. Reopen/recount later +reached approximately **3.33 GB RSS**, and the kernel killed the process during +fixed-hop query execution at approximately **3.80 GB anonymous RSS** (exit +137). The volume was only **52% used**. These observations diagnose a +GraphForge reopen/query execution-memory defect, not disk exhaustion, Fly page +cache, or bounded-generator growth. They are a historical failing baseline, +not a current pass or a universal memory requirement; S20 must be rerun at the +fix's exact merge SHA before this gate can be called green. + +The journal now publishes a separate atomic `running` and completed/failed +boundary for node count, edge count, one-hop, and two-hop execution. Every +normal completion includes both high-water RSS and current Linux process-memory +components. A process-level OOM or `SIGKILL` cannot execute Rust cleanup code; +in that case the last durable `running` boundary identifies the interrupted +phase, while the external Machine event/exit status supplies the typed `oom`. +The journal deliberately leaves `error_class` null rather than inventing a +cause when no typed in-process failure was observed. + ## Commands Always-on CI (SCALE-10 smoke + all reconciliation / determinism / bounded / @@ -144,7 +168,8 @@ passes. Default evidence path: `docs/development/g500-ladder-evidence.json`. The runner also atomically updates `build/g500-ladder-journal.json` before and -after every phase and after every rung. Retrieve the journal after an OOM, +after every phase—including separate count and hop phases—and after every rung. +Retrieve the journal after an OOM, ENOSPC, timeout, or operator safety stop; `completed_rungs` remain valid, while `active_rung`, `active_phase`, and `run_state` describe the interrupted work and must not be presented as a pass. On Linux, each journal observation separates @@ -166,6 +191,22 @@ One object per attempted rung (schema - `machine_envelope` (128 GiB / 1 TiB / 4 h fail-safe), `sut`, `generator`. - `track` and `teps` are always `null`. +The `reopen`, `node_count`, `edge_count`, `cypher_limit_1hop`, and +`cypher_limit_2hop` steps each carry `rss_peak_bytes` plus `process_memory` +(`vmrss_bytes`, `rss_anon_bytes`, and `rss_file_bytes` on Linux), rather than a +single aggregate query observation. + +Each hop step also carries aggregate-only `operators` evidence. Expansion +records input batches/rows, generated candidates, emitted rows, selective +edge/node scan rows, and maximum concurrent reads per edge binding. Ordered +operators record the physical TopK row bound, output rows/batches, actual spill +count/bytes, and their post-execution memory gauge. Query-level DataFusion +memory-pool reservations are sampled before execution and after all operator +streams have been dropped; equality proves reservation quiescence. These are +engine metrics, not estimated heap sizes. Global `ORDER BY` remains a semantic +barrier: terminal cancellation is never pushed through the sort, while +`SortExec: TopK(fetch=N)` bounds retained sort state to the requested limit. + Wall-clock and RSS numbers are hardware-specific observations, never CI millisecond gates. For #745, `sut` must name the cloud SKU; laptop SUTs are rejected as certification evidence. From 53da6f268c2a28d3563772c4bf65166501102427 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:30:30 -0600 Subject: [PATCH 02/16] test: certify bounded S20 lifecycle --- Makefile | 8 +- containers/fly-g500-s20/Dockerfile | 17 + containers/fly-g500-s20/run-s20.sh | 45 ++ crates/graphforge-api/src/lib.rs | 17 +- .../graphforge-api/tests/fixed_hop_limit.rs | 30 +- .../graphforge-api/tests/scale_g500_ladder.rs | 411 +++++++++++++++--- crates/graphforge-exec/src/adjacency.rs | 4 +- crates/graphforge-exec/src/demand.rs | 180 +++++++- crates/graphforge-exec/src/lib.rs | 11 +- docs/book/architecture/execution-model.md | 11 +- docs/development/perf-g500-ladder.md | 67 +++ scripts/ci/test-fly-g500-s20.py | 125 ++++++ scripts/fly-g500-s20.py | 348 +++++++++++++++ .../drift/cargo_feature_fingerprint.json | 126 +----- 14 files changed, 1196 insertions(+), 204 deletions(-) create mode 100644 containers/fly-g500-s20/Dockerfile create mode 100644 containers/fly-g500-s20/run-s20.sh create mode 100644 scripts/ci/test-fly-g500-s20.py create mode 100644 scripts/fly-g500-s20.py diff --git a/Makefile b/Makefile index 9d0f2c67..e9a905e8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help lint format type-check security workflow-lint license-check third-party-notices third-party-notices-check cargo-deny-licenses test pre-push pre-push-clean pre-push-preflight pre-push-fast bazel-test clean test-tck docstring-coverage test-network benchmark test-perf test-perf-xs test-perf-slow test-perf-large coverage coverage-rust coverage-python coverage-node coverage-quick coverage-report coverage-diff coverage-strict check-coverage check-coverage-rust check-coverage-python check-coverage-node check-patch-coverage test-durations test-analytics docs-serve docs-build docs-clean cargo-build codspeed-build codspeed-build-walltime codspeed-run bench-traversal bench-fixed-hop-limit bench-fixed-hop-livejournal bench-m4-entry bench-g500-scale20 bench-g500-ladder bench-adjacency-200m bench-file-backed-128m m4-entry-matrix-check durability-isolation-check native-consumers release-load-matrix-check release-load-matrix bulk-construction-conformance-check bulk-construction-conformance cargo-test cargo-check cargo-clippy cargo-fmt cargo-fmt-check clean-builds clean-builds-all pnpm-install pnpm-build pnpm-test-bdd install build release-version-check package-license-verify publish-dry-run publish-dry-run-npm publish-dry-run-docs publish-dry-run-python publish-dry-run-cargo record-release-artifacts clean-env-verify-check clean-env-verify-preflight clean-env-verify +.PHONY: help lint format type-check security workflow-lint license-check third-party-notices third-party-notices-check cargo-deny-licenses test pre-push pre-push-clean pre-push-preflight pre-push-fast bazel-test clean test-tck docstring-coverage test-network benchmark test-perf test-perf-xs test-perf-slow test-perf-large coverage coverage-rust coverage-python coverage-node coverage-quick coverage-report coverage-diff coverage-strict check-coverage check-coverage-rust check-coverage-python check-coverage-node check-patch-coverage test-durations test-analytics docs-serve docs-build docs-clean cargo-build codspeed-build codspeed-build-walltime codspeed-run bench-traversal bench-fixed-hop-limit bench-fixed-hop-livejournal bench-m4-entry bench-g500-scale20 bench-g500-s20-lifecycle bench-g500-ladder bench-adjacency-200m bench-file-backed-128m m4-entry-matrix-check durability-isolation-check native-consumers release-load-matrix-check release-load-matrix bulk-construction-conformance-check bulk-construction-conformance cargo-test cargo-check cargo-clippy cargo-fmt cargo-fmt-check clean-builds clean-builds-all pnpm-install pnpm-build pnpm-test-bdd install build release-version-check package-license-verify publish-dry-run publish-dry-run-npm publish-dry-run-docs publish-dry-run-python publish-dry-run-cargo record-release-artifacts clean-env-verify-check clean-env-verify-preflight clean-env-verify help: ## Show this help message @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' @@ -319,6 +319,12 @@ bench-m4-entry: ## Emit the M4 entry large/manual evidence envelope (#334; hard bench-g500-scale20: ## Official-parameter SCALE-20 public-facade engineering green (#710; ignored, not Official-track) cargo test -p graphforge-api --release --test scale_g500_scale20 scale20_public_facade_engineering_green -- --ignored --nocapture --test-threads=1 +bench-g500-s20-lifecycle: ## S20 full source/export/verify/clean-import/reopen lifecycle (#904; provisioned host) + @test -n "$$GF_G500_S20_WORK_ROOT" || (echo "GF_G500_S20_WORK_ROOT is required" && exit 2) + @test -n "$$GF_G500_S20_EVIDENCE_OUT" || (echo "GF_G500_S20_EVIDENCE_OUT is required" && exit 2) + @test -n "$$GF_G500_CERT_JOURNAL_OUT" || (echo "GF_G500_CERT_JOURNAL_OUT is required" && exit 2) + cargo test -p graphforge-api --release --test scale_g500_ladder s20_integrated_full_lifecycle_evidence -- --ignored --exact --nocapture --test-threads=1 + bench-g500-ladder: ## Bounded billion-edge scale ladder S20-S26 first-fail evidence (#736; ignored, provisioned scale-host) @test -n "$$GF_G500_LADDER_MAX_SCALE" || (echo "GF_G500_LADDER_MAX_SCALE is required" && exit 2) GF_G500_LADDER_EVIDENCE_OUT="$(CURDIR)/docs/development/g500-ladder-evidence.json" \ diff --git a/containers/fly-g500-s20/Dockerfile b/containers/fly-g500-s20/Dockerfile new file mode 100644 index 00000000..7fc68a41 --- /dev/null +++ b/containers/fly-g500-s20/Dockerfile @@ -0,0 +1,17 @@ +FROM rust:1.96-bookworm AS build +WORKDIR /source +COPY . . +RUN cargo test --locked --release -p graphforge-api --test scale_g500_ladder --no-run +RUN set -eu; \ + binary="$(find target/release/deps -maxdepth 1 -type f -name 'scale_g500_ladder-*' ! -name '*.d' | head -n 1)"; \ + test -n "$binary"; \ + install -Dm755 "$binary" /out/scale-g500-ladder + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates coreutils \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /out/scale-g500-ladder /usr/local/bin/scale-g500-ladder +COPY containers/fly-g500-s20/run-s20.sh /usr/local/bin/run-s20 +RUN chmod 0555 /usr/local/bin/scale-g500-ladder /usr/local/bin/run-s20 +ENTRYPOINT ["/usr/local/bin/run-s20"] diff --git a/containers/fly-g500-s20/run-s20.sh b/containers/fly-g500-s20/run-s20.sh new file mode 100644 index 00000000..6de39f1f --- /dev/null +++ b/containers/fly-g500-s20/run-s20.sh @@ -0,0 +1,45 @@ +#!/bin/sh +set -eu + +: "${GF_G500_S20_EXPECTED_SHA:?exact source SHA is required}" +case "$GF_G500_S20_EXPECTED_SHA" in + *[!0-9a-f]*|'') echo "invalid source SHA" >&2; exit 2 ;; +esac +test "${#GF_G500_S20_EXPECTED_SHA}" -eq 40 +test "$(stat -c %d /work)" != "$(stat -c %d /)" || { + echo "/work is not an attached volume" >&2 + exit 3 +} + +rm -rf /work/s20 /work/tmp +rm -f /work/s20-evidence.json /work/s20-journal.json /work/controller-ack +mkdir -p /work/tmp +export TMPDIR=/work/tmp + +# Small full-lifecycle admission first. The S20 product envelope starts only +# after this bounded proof succeeds. +timeout --signal=TERM --kill-after=30s 600s \ + /usr/local/bin/scale-g500-ladder \ + certification_lifecycle_journals_equivalent_round_trip_and_drills \ + --exact --test-threads=1 + +export GF_G500_S20_WORK_ROOT=/work/s20 +export GF_G500_S20_EVIDENCE_OUT=/work/s20-evidence.json +export GF_G500_CERT_JOURNAL_OUT=/work/s20-journal.json + +set +e +timeout --signal=TERM --kill-after=30s 14430s \ + /usr/local/bin/scale-g500-ladder \ + s20_integrated_full_lifecycle_evidence \ + --ignored --exact --nocapture --test-threads=1 +status=$? +set -e + +# Preserve the Machine briefly for evidence retrieval. The controller's +# independent 4h30 deadline remains authoritative even if this loop is alive. +remaining=900 +while [ ! -f /work/controller-ack ] && [ "$remaining" -gt 0 ]; do + sleep 1 + remaining=$((remaining - 1)) +done +exit "$status" diff --git a/crates/graphforge-api/src/lib.rs b/crates/graphforge-api/src/lib.rs index 6d5e562f..2ce58300 100644 --- a/crates/graphforge-api/src/lib.rs +++ b/crates/graphforge-api/src/lib.rs @@ -4562,7 +4562,7 @@ mod tests { let default_policy = ExecutionResourcePolicy::default().normalize().unwrap(); let default_cache = create_adjacency_cache(admitted.path(), &default_policy).unwrap(); - let default_path = default_cache.path(); + let default_path = default_cache.path().to_path_buf(); assert_eq!(default_path.parent(), Some(admitted.path())); drop(default_cache); assert!(!default_path.exists()); @@ -4570,7 +4570,7 @@ mod tests { let mut spill_policy = default_policy; spill_policy.spill_directory = Some(spill.path().to_path_buf()); let spill_cache = create_adjacency_cache(admitted.path(), &spill_policy).unwrap(); - let spill_path = spill_cache.path(); + let spill_path = spill_cache.path().to_path_buf(); assert_eq!(spill_path.parent(), Some(spill.path())); drop(spill_cache); assert!(!spill_path.exists()); @@ -7179,6 +7179,19 @@ mod tests { ) .expect("parameterized query"); assert_eq!(result.stats.rows_produced, 2); + + let observed = gf.execute_with_params_observed( + "MATCH (n:Person) WHERE n.age > $min RETURN n.node_uuid", + ¶ms, + ); + let result = observed.result.expect("parameterized query"); + assert_eq!(result.stats.rows_produced, 2); + + let observed = gf.execute_observed("MATCH (n:Person) RETURN n.node_uuid LIMIT 1"); + assert_eq!( + observed.result.expect("observed query").stats.rows_produced, + 1 + ); } #[test] diff --git a/crates/graphforge-api/tests/fixed_hop_limit.rs b/crates/graphforge-api/tests/fixed_hop_limit.rs index e1af242a..b316854e 100644 --- a/crates/graphforge-api/tests/fixed_hop_limit.rs +++ b/crates/graphforge-api/tests/fixed_hop_limit.rs @@ -235,6 +235,11 @@ fn assert_indexed_limit_io(io: &io_stats::IoSnapshot) { fn assert_bounded_demand(snapshot: &DemandSnapshot, expected_hops: usize, required: u64) { assert_eq!(snapshot.hops.len(), expected_hops, "{snapshot:#?}"); + assert_eq!( + snapshot.operator_rss.expand_by_hop.len(), + expected_hops, + "every fixed hop needs a distinct RSS lifetime: {snapshot:#?}" + ); assert!(snapshot.cancellations >= 1, "{snapshot:#?}"); assert!(snapshot.max_in_flight_reads <= 1, "{snapshot:#?}"); for hop in snapshot.hops.values() { @@ -521,9 +526,12 @@ fn fixed_hop_limit_preserves_skip_parameters_filters_and_blockers() { ); assert_eq!(sort.spilled_bytes, 0, "{ordered_metrics:#?}"); assert_eq!(sort.memory_used_after, 0, "{ordered_metrics:#?}"); - assert_eq!( - ordered_metrics.memory_reserved_after, ordered_metrics.memory_reserved_before, - "query memory reservations must quiesce: {ordered_metrics:#?}" + assert!( + ordered_metrics + .memory_reserved_after + .saturating_sub(ordered_metrics.memory_reserved_before) + <= ordered_metrics.returned_batch_bytes, + "only returned Arrow batches may remain reserved: {ordered_metrics:#?}" ); let distinct = forge @@ -571,8 +579,20 @@ fn ordered_limit_topk_state_is_cardinality_independent_and_released() { assert_eq!(sort.spill_count, 0, "{snapshot:#?}"); assert_eq!(sort.spilled_bytes, 0, "{snapshot:#?}"); assert_eq!(sort.memory_used_after, 0, "{snapshot:#?}"); - assert_eq!( - snapshot.memory_reserved_after, snapshot.memory_reserved_before, + assert!( + snapshot + .memory_reserved_after + .saturating_sub(snapshot.memory_reserved_before) + <= snapshot.returned_batch_bytes, + "{snapshot:#?}" + ); + let sort_rss = &snapshot.operator_rss.sort_exclusive; + assert!( + sort_rss.before_bytes > 0 || !cfg!(target_os = "linux"), + "{snapshot:#?}" + ); + assert!( + sort_rss.after_bytes > 0 || !cfg!(target_os = "linux"), "{snapshot:#?}" ); } diff --git a/crates/graphforge-api/tests/scale_g500_ladder.rs b/crates/graphforge-api/tests/scale_g500_ladder.rs index 75ba9a82..4f31ceac 100644 --- a/crates/graphforge-api/tests/scale_g500_ladder.rs +++ b/crates/graphforge-api/tests/scale_g500_ladder.rs @@ -61,7 +61,20 @@ static JOURNAL_WRITE_SEQUENCE: AtomicU64 = AtomicU64::new(0); static INGEST_SUBPHASE: AtomicU64 = AtomicU64::new(0); static INGEST_CHUNK_INDEX: AtomicU64 = AtomicU64::new(0); -fn query_operator_evidence(snapshot: &demand::DemandSnapshot) -> Value { +fn query_operator_evidence(snapshot: &demand::DemandSnapshot, memory_budget_bytes: u64) -> Value { + let lifetime = |rss: &demand::RssLifetimeSnapshot| { + let working_set_bytes = rss.peak_bytes.saturating_sub(rss.before_bytes); + json!({ + "before_bytes": rss.before_bytes, + "peak_bytes": rss.peak_bytes, + "current_bytes": rss.current_bytes, + "after_bytes": rss.after_bytes, + "working_set_bytes": working_set_bytes, + "budget_bytes": memory_budget_bytes, + "headroom_bytes": memory_budget_bytes.saturating_sub(working_set_bytes), + "within_budget": working_set_bytes <= memory_budget_bytes, + }) + }; json!({ "expands": snapshot.hops.iter().map(|(edge_var, hop)| json!({ "edge_var": edge_var, @@ -84,15 +97,60 @@ fn query_operator_evidence(snapshot: &demand::DemandSnapshot) -> Value { })).collect::>(), "memory_reserved_before": snapshot.memory_reserved_before, "memory_reserved_after": snapshot.memory_reserved_after, + "returned_batch_bytes": snapshot.returned_batch_bytes, + "operator_memory_quiescent": snapshot.memory_reserved_after + .saturating_sub(snapshot.memory_reserved_before) <= snapshot.returned_batch_bytes, "operator_rss": { "expand_peak_bytes": snapshot.operator_rss.expand_peak_bytes, "expand_current_bytes": snapshot.operator_rss.expand_current_bytes, "sort_peak_bytes": snapshot.operator_rss.sort_peak_bytes, "sort_current_bytes": snapshot.operator_rss.sort_current_bytes, + "expand_by_hop": snapshot.operator_rss.expand_by_hop.iter().map(|(edge_var, rss)| { + let mut value = lifetime(rss); + value["edge_var"] = json!(edge_var); + value + }).collect::>(), + "sort_exclusive": lifetime(&snapshot.operator_rss.sort_exclusive), }, }) } +fn operator_rss_within_budgets( + snapshot: &demand::DemandSnapshot, + expected_hops: usize, + memory_budget_bytes: u64, + process_budget_bytes: u64, +) -> bool { + snapshot.operator_rss.expand_by_hop.len() == expected_hops + && snapshot + .operator_rss + .expand_by_hop + .values() + .chain(std::iter::once(&snapshot.operator_rss.sort_exclusive)) + .all(|rss| { + rss.peak_bytes <= process_budget_bytes + && rss.peak_bytes.saturating_sub(rss.before_bytes) <= memory_budget_bytes + && (rss.before_bytes > 0 || !cfg!(target_os = "linux")) + && (rss.after_bytes > 0 || !cfg!(target_os = "linux")) + }) +} + +fn max_operator_working_set(steps: &[Value]) -> u64 { + steps + .iter() + .filter_map(|step| step["detail"]["operators"]["operator_rss"].as_object()) + .flat_map(|rss| { + rss["expand_by_hop"] + .as_array() + .into_iter() + .flatten() + .chain(std::iter::once(&rss["sort_exclusive"])) + }) + .filter_map(|lifetime| lifetime["working_set_bytes"].as_u64()) + .max() + .unwrap_or(0) +} + // --------------------------------------------------------------------------- // Versioned profile (single source of truth for the ladder). // --------------------------------------------------------------------------- @@ -912,6 +970,7 @@ fn run_rung( let mut node_count = 0u64; let mut edge_count = 0u64; let mut gsi = String::new(); + let mut query_memory_budget_bytes = 0; if first_failing_phase.is_none() { persist_phase_journal( profile, @@ -923,6 +982,7 @@ fn run_rung( None, ); let reopen_started = Instant::now(); + let reopen_rss_before = linux_memory_bytes(); let graph = GraphForge::new(Some(project.to_str().expect("utf8 project"))) .expect("reopen GraphForge"); let reopen_s = reopen_started.elapsed().as_secs_f64(); @@ -937,7 +997,11 @@ fn run_rung( "wall_time_s": reopen_s, "rss_peak_bytes": rss_value(), "process_memory": linux_process_memory(), - "detail": {} + "detail": { + "rss_before_bytes": reopen_rss_before, + "rss_after_bytes": linux_memory_bytes(), + "retention_contract": "instance_workspace_bounded_by_process_envelope" + } })); persist_phase_journal( profile, @@ -968,6 +1032,7 @@ fn run_rung( None, ); let phase_started = Instant::now(); + let rss_before = linux_memory_bytes(); node_count = graph.node_count(NODE_LABEL).expect("node_count"); let expected_nodes = 1u64 << rung.scale; let mut violation = envelope_violation(&env, ladder_started, &project, &spill_dir); @@ -984,7 +1049,13 @@ fn run_rung( "wall_time_s": phase_started.elapsed().as_secs_f64(), "rss_peak_bytes": rss_value(), "process_memory": linux_process_memory(), - "detail": { "node_count": node_count, "expected": expected_nodes } + "detail": { + "node_count": node_count, + "expected": expected_nodes, + "rss_before_bytes": rss_before, + "rss_after_bytes": linux_memory_bytes(), + "retention_contract": "no_session_memory_pool_reservation" + } })); persist_phase_journal( profile, @@ -1012,12 +1083,22 @@ fn run_rung( None, ); let phase_started = Instant::now(); - edge_count = scalar_count(&graph.execute(COUNT_EDGES).expect("edge count")); + let rss_before = linux_memory_bytes(); + let observed_count = graph.execute_observed(COUNT_EDGES); + edge_count = scalar_count(observed_count.result.as_ref().expect("edge count")); gsi = gsi_undirected(node_count, edge_count); let mut violation = envelope_violation(&env, ladder_started, &project, &spill_dir); if edge_count != live_unique_edges { violation = Some("result_mismatch"); } + if observed_count + .evidence + .memory_reserved_after + .saturating_sub(observed_count.evidence.memory_reserved_before) + > observed_count.evidence.returned_batch_bytes + { + violation = Some("memory_limit"); + } if let Some(class) = violation { first_failing_phase = Some("edge_count"); error_class = Some(class); @@ -1028,7 +1109,19 @@ fn run_rung( "wall_time_s": phase_started.elapsed().as_secs_f64(), "rss_peak_bytes": rss_value(), "process_memory": linux_process_memory(), - "detail": { "edge_count": edge_count, "expected": live_unique_edges, "gsi": gsi } + "detail": { + "edge_count": edge_count, + "expected": live_unique_edges, + "gsi": gsi, + "rss_before_bytes": rss_before, + "rss_after_bytes": linux_memory_bytes(), + "memory_reserved_before": observed_count.evidence.memory_reserved_before, + "memory_reserved_after": observed_count.evidence.memory_reserved_after, + "returned_batch_bytes": observed_count.evidence.returned_batch_bytes, + "memory_quiescent": observed_count.evidence.memory_reserved_after + .saturating_sub(observed_count.evidence.memory_reserved_before) + <= observed_count.evidence.returned_batch_bytes + } })); persist_phase_journal( profile, @@ -1057,7 +1150,9 @@ fn run_rung( ); let hop1_started = Instant::now(); let hop1 = graph.execute_observed(ONE_HOP); - let hop1_operators = query_operator_evidence(&hop1.evidence); + let memory_budget = graph.resource_policy().memory_budget_bytes; + query_memory_budget_bytes = memory_budget; + let hop1_operators = query_operator_evidence(&hop1.evidence, memory_budget); let hop1_failure = hop1.result.as_ref().err().map(ToString::to_string); let hop1_rows = hop1.result.as_ref().map_or(0, row_count); let mut violation = envelope_violation(&env, ladder_started, &project, &spill_dir); @@ -1067,6 +1162,9 @@ fn run_rung( if hop1_rows != 1_000 { violation = Some("result_mismatch"); } + if !operator_rss_within_budgets(&hop1.evidence, 1, memory_budget, env.rss_bytes) { + violation = Some("memory_limit"); + } if let Some(class) = violation { first_failing_phase = Some("one_hop"); error_class = Some(class); @@ -1106,7 +1204,9 @@ fn run_rung( ); let hop2_started = Instant::now(); let hop2 = graph.execute_observed(TWO_HOP); - let hop2_operators = query_operator_evidence(&hop2.evidence); + let memory_budget = graph.resource_policy().memory_budget_bytes; + query_memory_budget_bytes = memory_budget; + let hop2_operators = query_operator_evidence(&hop2.evidence, memory_budget); let hop2_failure = hop2.result.as_ref().err().map(ToString::to_string); let hop2_rows = hop2.result.as_ref().map_or(0, row_count); let mut violation = envelope_violation(&env, ladder_started, &project, &spill_dir); @@ -1116,6 +1216,9 @@ fn run_rung( if hop2_rows != 1_000 { violation = Some("result_mismatch"); } + if !operator_rss_within_budgets(&hop2.evidence, 2, memory_budget, env.rss_bytes) { + violation = Some("memory_limit"); + } if let Some(class) = violation { first_failing_phase = Some("two_hop"); error_class = Some(class); @@ -1162,6 +1265,12 @@ fn run_rung( None => (Value::Null, Value::Null), }; + let max_operator_working_set_bytes = max_operator_working_set(&steps); + let lower_rungs_within_same_budget = completed_rungs.iter().all(|completed| { + completed["operator_memory_contract"]["max_working_set_bytes"] + .as_u64() + .is_none_or(|bytes| bytes <= query_memory_budget_bytes) + }); let evidence = json!({ "schema": EVIDENCE_SCHEMA, "schema_version": SCHEMA_VERSION, @@ -1209,6 +1318,16 @@ fn run_rung( "teps": null, "notes": "Bounded-memory engineering green. NOT Official-track, NOT TEPS. Certification of one billion live edges is #745, not this profile.", "steps": steps, + "operator_memory_contract": { + "classification": "bounded_plateau", + "budget_source": "GraphForge.resource_policy.memory_budget_bytes", + "budget_bytes": query_memory_budget_bytes, + "max_working_set_bytes": max_operator_working_set_bytes, + "headroom_bytes": query_memory_budget_bytes.saturating_sub(max_operator_working_set_bytes), + "lower_rungs_within_same_budget": lower_rungs_within_same_budget, + "pass": max_operator_working_set_bytes <= query_memory_budget_bytes + && lower_rungs_within_same_budget, + }, }); RungOutcome { passed, evidence } @@ -2032,6 +2151,16 @@ impl PhaseJournal { } fn pass(&mut self, id: &str, started: Instant, fingerprint: Option) { + self.pass_with_evidence(id, started, fingerprint, &Value::Null); + } + + fn pass_with_evidence( + &mut self, + id: &str, + started: Instant, + fingerprint: Option, + operator_evidence: &Value, + ) { let fingerprint = fingerprint.map_or(Value::Null, Value::String); self.monitor.sample_disk(); if let Some(code) = self.monitor.failure_code() { @@ -2041,6 +2170,7 @@ impl PhaseJournal { "rss_peak_bytes": self.monitor.peak_rss.load(Ordering::Relaxed), "disk_peak_bytes": self.monitor.peak_disk.load(Ordering::Relaxed), "fingerprint": fingerprint, "failure_code": code, + "operator_evidence": operator_evidence, })); self.flush(); panic!("certification resource watchdog stopped phase {id}: {code}"); @@ -2054,6 +2184,7 @@ impl PhaseJournal { "disk_peak_bytes": disk_peak_bytes, "fingerprint": fingerprint, "failure_code": null, + "operator_evidence": operator_evidence, })); self.flush(); } @@ -2363,7 +2494,15 @@ fn create_bounded_drill_package(root: &Path, limits: PortableV2Limits) -> (PathB } #[allow(clippy::too_many_lines)] -fn run_integrated_certification(root: &Path, target_live: Option) -> Value { +#[derive(Clone, Copy)] +enum IntegratedRun<'a> { + Preflight, + ProfileRung(&'a Rung), + TargetLive(u64), +} + +#[allow(clippy::too_many_lines)] // the ordered 17-phase certification transaction is intentionally linear +fn run_integrated_certification(root: &Path, run: IntegratedRun<'_>) -> Value { let source = root.join("source"); let imported = root.join("imported"); let package = root.join("project.gfpb"); @@ -2387,30 +2526,28 @@ fn run_integrated_certification(root: &Path, target_live: Option) -> Value journal.pass("preflight", phase, None); let phase = Instant::now(); - let scale = if target_live.is_some() { - certification_profile.scale - } else { - certification_profile.preflight_scale + let scale = match run { + IntegratedRun::Preflight => certification_profile.preflight_scale, + IntegratedRun::ProfileRung(rung) => rung.scale, + IntegratedRun::TargetLive(_) => certification_profile.scale, }; - let edge_factor = if target_live.is_some() { - certification_profile.edgefactor - } else { - 4 + let edge_factor = match run { + IntegratedRun::Preflight => 4, + IntegratedRun::ProfileRung(_) => load_profile().edgefactor, + IntegratedRun::TargetLive(_) => certification_profile.edgefactor, }; - let initiator = if target_live.is_some() { - certification_profile.initiator - } else { - load_profile().initiator + let initiator = match run { + IntegratedRun::TargetLive(_) => certification_profile.initiator, + IntegratedRun::Preflight | IntegratedRun::ProfileRung(_) => load_profile().initiator, }; - let seed = if target_live.is_some() { - certification_profile.seed - } else { - load_profile().seed + let seed = match run { + IntegratedRun::TargetLive(_) => certification_profile.seed, + IntegratedRun::Preflight | IntegratedRun::ProfileRung(_) => load_profile().seed, }; let spill_root = root.join("spill"); fs::create_dir_all(&spill_root).expect("certification spill root"); - let generated = target_live.map(|target| { - generate_target_live_runs( + let generated = match run { + IntegratedRun::TargetLive(target) => Some(generate_target_live_runs( &TargetLiveGeneration { scale, edge_factor, @@ -2421,44 +2558,67 @@ fn run_integrated_certification(root: &Path, target_live: Option) -> Value }, &spill_root, journal.cancellation(), - ) - }); + )), + IntegratedRun::Preflight | IntegratedRun::ProfileRung(_) => None, + }; + let rung_spills = match run { + IntegratedRun::ProfileRung(rung) => Some(generate_spill_runs( + scale, + edge_factor, + initiator, + seed, + rung.buffer_edges, + &spill_root, + Some(journal.cancellation()), + )), + IntegratedRun::Preflight | IntegratedRun::TargetLive(_) => None, + }; let (spills, generated_counts, target_live_fingerprint) = generated .map_or((None, None, None), |(spills, counts, fingerprint)| { (Some(spills), Some(counts), Some(fingerprint)) }); - let (summary, edges) = if target_live.is_none() { + let (summary, edges) = if matches!(run, IntegratedRun::Preflight) { let (summary, edges) = bounded_generation(scale, edge_factor, initiator, seed, 512); assert!(summary.reconciles()); (Some(summary), Some(edges)) } else { (None, None) }; - let generation_fingerprint = summary.as_ref().map_or_else( - || target_live_fingerprint.expect("target-live payload fingerprint"), - |value| value.input_fingerprint.clone(), - ); - journal.pass("generate", phase, Some(generation_fingerprint.clone())); + let generation_fingerprint = summary + .as_ref() + .map(|value| value.input_fingerprint.clone()) + .or(target_live_fingerprint); + journal.pass("generate", phase, generation_fingerprint.clone()); let phase = Instant::now(); let graph = GraphForge::new(source.to_str()).expect("open certification source"); publish_nodes(&graph, 1u64 << scale, Some(journal.cancellation())); let mut sink = EdgeSink::new(&graph, Some(journal.cancellation())); + let mut rung_merge_counts = None; if let Some(edges) = edges { for (src, dst) in edges { sink.push(src, dst); } } else { - merge_runs( - &spills.as_ref().expect("target spills").runs, + let active_spills = spills + .as_ref() + .or(rung_spills.as_ref()) + .expect("spill runs"); + let counts = merge_runs( + &active_spills.runs, Some(journal.cancellation()), |src, dst| sink.push(src, dst), ) .expect("certification merge was cancelled"); + if rung_spills.is_some() { + rung_merge_counts = Some(counts); + } } sink.flush(); let input_fingerprint = format!("sha256:{}", sink.finish()); - assert_eq!(input_fingerprint, generation_fingerprint); + if let Some(expected) = generation_fingerprint { + assert_eq!(input_fingerprint, expected); + } journal.pass("ingest", phase, Some(input_fingerprint)); let phase = Instant::now(); @@ -2477,25 +2637,48 @@ fn run_integrated_certification(root: &Path, target_live: Option) -> Value let graph = GraphForge::new(source.to_str()).expect("reopen source"); let source_nodes = graph.node_count(NODE_LABEL).expect("source nodes"); let source_edges = scalar_count(&graph.execute(COUNT_EDGES).expect("source edges")); - let expected_live_edges = generated_counts.as_ref().map_or_else( - || { - summary - .as_ref() - .expect("bounded generation summary") - .live_unique_edges - }, - |counts| counts.live_unique_edges, - ); + let expected_live_edges = generated_counts + .as_ref() + .or(rung_merge_counts.as_ref()) + .map_or_else( + || { + summary + .as_ref() + .expect("bounded generation summary") + .live_unique_edges + }, + |counts| counts.live_unique_edges, + ); assert_eq!(source_edges, expected_live_edges); journal.pass("source_reopen", phase, None); let phase = Instant::now(); - let source_1hop = result_fingerprint(&graph.execute(ONE_HOP).expect("source 1hop")); - journal.pass("source_query_1hop", phase, Some(source_1hop.clone())); + let source_1hop_observed = graph.execute_observed(ONE_HOP); + let source_1hop = + result_fingerprint(source_1hop_observed.result.as_ref().expect("source 1hop")); + journal.pass_with_evidence( + "source_query_1hop", + phase, + Some(source_1hop.clone()), + &query_operator_evidence( + &source_1hop_observed.evidence, + graph.resource_policy().memory_budget_bytes, + ), + ); let phase = Instant::now(); - let source_2hop = result_fingerprint(&graph.execute(TWO_HOP).expect("source 2hop")); + let source_2hop_observed = graph.execute_observed(TWO_HOP); + let source_2hop = + result_fingerprint(source_2hop_observed.result.as_ref().expect("source 2hop")); let source_authority_fingerprint = authority_fingerprint(&graph); let source_generation = current_generation_uuid(&graph); - journal.pass("source_query_2hop", phase, Some(source_2hop.clone())); + journal.pass_with_evidence( + "source_query_2hop", + phase, + Some(source_2hop.clone()), + &query_operator_evidence( + &source_2hop_observed.evidence, + graph.resource_policy().memory_budget_bytes, + ), + ); let phase = Instant::now(); let exported = graph @@ -2558,11 +2741,23 @@ fn run_integrated_certification(root: &Path, target_live: Option) -> Value ); journal.pass("imported_reopen", phase, None); let phase = Instant::now(); - let imported_1hop = result_fingerprint(&imported_graph.execute(ONE_HOP).expect("import 1hop")); + let imported_1hop_observed = imported_graph.execute_observed(ONE_HOP); + let imported_1hop = + result_fingerprint(imported_1hop_observed.result.as_ref().expect("import 1hop")); assert_eq!(source_1hop, imported_1hop); - journal.pass("imported_query_1hop", phase, Some(imported_1hop.clone())); + journal.pass_with_evidence( + "imported_query_1hop", + phase, + Some(imported_1hop.clone()), + &query_operator_evidence( + &imported_1hop_observed.evidence, + imported_graph.resource_policy().memory_budget_bytes, + ), + ); let phase = Instant::now(); - let imported_2hop = result_fingerprint(&imported_graph.execute(TWO_HOP).expect("import 2hop")); + let imported_2hop_observed = imported_graph.execute_observed(TWO_HOP); + let imported_2hop = + result_fingerprint(imported_2hop_observed.result.as_ref().expect("import 2hop")); let imported_authority_fingerprint = authority_fingerprint(&imported_graph); assert_eq!( current_generation_uuid(&imported_graph), @@ -2570,7 +2765,15 @@ fn run_integrated_certification(root: &Path, target_live: Option) -> Value ); assert_eq!(source_2hop, imported_2hop); assert_eq!(source_authority_fingerprint, imported_authority_fingerprint); - journal.pass("imported_query_2hop", phase, Some(imported_2hop.clone())); + journal.pass_with_evidence( + "imported_query_2hop", + phase, + Some(imported_2hop.clone()), + &query_operator_evidence( + &imported_2hop_observed.evidence, + imported_graph.resource_policy().memory_budget_bytes, + ), + ); // Representative drills use the same verifier/import boundaries but never // repeat the billion-edge payload. @@ -2669,10 +2872,10 @@ fn run_integrated_certification(root: &Path, target_live: Option) -> Value "source_generation": exported.generation_uuid.to_string(), "package": exported.package_digest, "transport": exported.transport_digest, "imported_generation": imported_receipt.generation_uuid.to_string(), - "raw_attempts": spills.as_ref().map_or_else(|| summary.as_ref().unwrap().raw_attempts, |value| value.raw_attempts), - "self_loops_rejected": spills.as_ref().map_or_else(|| summary.as_ref().unwrap().self_loops_rejected, |value| value.self_loops_rejected), - "duplicates_rejected": generated_counts.as_ref().map_or_else(|| summary.as_ref().unwrap().duplicates_rejected, |value| value.duplicates_rejected), - "generated_live_unique_edges": generated_counts.as_ref().map_or_else(|| summary.as_ref().unwrap().live_unique_edges, |value| value.live_unique_edges), + "raw_attempts": spills.as_ref().or(rung_spills.as_ref()).map_or_else(|| summary.as_ref().unwrap().raw_attempts, |value| value.raw_attempts), + "self_loops_rejected": spills.as_ref().or(rung_spills.as_ref()).map_or_else(|| summary.as_ref().unwrap().self_loops_rejected, |value| value.self_loops_rejected), + "duplicates_rejected": generated_counts.as_ref().or(rung_merge_counts.as_ref()).map_or_else(|| summary.as_ref().unwrap().duplicates_rejected, |value| value.duplicates_rejected), + "generated_live_unique_edges": expected_live_edges, "source_nodes": source_nodes, "source_edges": source_edges, "imported_nodes": imported_nodes, "imported_edges": imported_edges, "source_project_fingerprint": project_fingerprint(source_nodes, source_edges, &source_1hop, &source_2hop), @@ -2690,12 +2893,38 @@ fn run_integrated_certification(root: &Path, target_live: Option) -> Value #[test] fn certification_lifecycle_journals_equivalent_round_trip_and_drills() { let root = TempDir::new().expect("certification smoke root"); - let evidence = run_integrated_certification(root.path(), None); + let evidence = run_integrated_certification(root.path(), IntegratedRun::Preflight); assert_eq!(evidence["source_edges"], evidence["imported_edges"]); assert_ne!( evidence["source_generation"], evidence["imported_generation"] ); + let phases = evidence["phases"] + .as_array() + .expect("integrated lifecycle phases"); + assert_eq!( + phases + .iter() + .map(|phase| phase["id"].as_str().expect("phase id")) + .collect::>(), + CERTIFICATION_PHASES, + "every integrated rung must execute the full source/export/verify/clean-import lifecycle" + ); + assert!(phases.iter().all(|phase| phase["status"] == "pass")); +} + +#[test] +fn s20_integrated_entry_uses_the_versioned_profile_rung() { + let profile = load_profile(); + let rung = profile + .rungs + .iter() + .find(|rung| rung.id == "S20") + .expect("versioned S20 rung"); + assert_eq!(rung.scale, 20); + assert_eq!(rung.tier, "provisioned"); + assert_eq!(rung.buffer_edges, 8_388_608); + assert_eq!(profile.edgefactor, 16); } #[test] @@ -2809,7 +3038,10 @@ fn certification_target_live_full_lifecycle_evidence() { let started = Instant::now(); let profile = load_certification_profile(); let root = TempDir::new().expect("certification workspace"); - let lifecycle = run_integrated_certification(root.path(), Some(profile.target_live_edges)); + let lifecycle = run_integrated_certification( + root.path(), + IntegratedRun::TargetLive(profile.target_live_edges), + ); let phases = lifecycle["phases"].as_array().expect("phase array"); let peak_rss = phases .iter() @@ -2876,6 +3108,65 @@ fn certification_target_live_full_lifecycle_evidence() { .expect("write certification evidence"); } +#[test] +#[ignore = "provisioned S20 full lifecycle; requires explicit work and evidence paths"] +fn s20_integrated_full_lifecycle_evidence() { + let profile = load_profile(); + let rung = profile + .rungs + .iter() + .find(|rung| rung.id == "S20" && rung.scale == 20 && rung.tier == "provisioned") + .expect("versioned provisioned S20 rung"); + let work_root = PathBuf::from( + std::env::var("GF_G500_S20_WORK_ROOT").expect("GF_G500_S20_WORK_ROOT is required"), + ); + assert!( + !work_root.exists(), + "S20 work root must be absent so import proves a clean destination" + ); + let journal_out = PathBuf::from( + std::env::var("GF_G500_CERT_JOURNAL_OUT").expect("GF_G500_CERT_JOURNAL_OUT is required"), + ); + assert!( + !journal_out.exists(), + "S20 phase journal output must not reuse prior evidence" + ); + fs::create_dir(&work_root).expect("create fresh S20 work root"); + let lifecycle = run_integrated_certification(&work_root, IntegratedRun::ProfileRung(rung)); + let phases = lifecycle["phases"].as_array().expect("S20 phase array"); + assert_eq!( + phases + .iter() + .map(|phase| phase["id"].as_str().expect("phase id")) + .collect::>(), + CERTIFICATION_PHASES + ); + assert!(phases.iter().all(|phase| phase["status"] == "pass")); + assert_eq!(lifecycle["source_edges"], lifecycle["imported_edges"]); + assert_eq!( + lifecycle["source_project_fingerprint"], + lifecycle["imported_project_fingerprint"] + ); + let evidence = json!({ + "schema": "graphforge-s20-integrated-lifecycle-evidence/1", + "git_sha": std::env::var("GF_G500_S20_EXPECTED_SHA") + .unwrap_or_else(|_| git_sha().as_str().unwrap_or("unknown").to_owned()), + "profile_schema": profile.schema, + "rung": rung.id, + "scale": rung.scale, + "edgefactor": profile.edgefactor, + "seed": profile.seed, + "buffer_edges": rung.buffer_edges, + "lifecycle": lifecycle, + "result": "pass", + "first_failure": null, + }); + let out = PathBuf::from( + std::env::var("GF_G500_S20_EVIDENCE_OUT").expect("GF_G500_S20_EVIDENCE_OUT is required"), + ); + write_json_atomically(&out, &evidence); +} + fn normalized_filesystem(path: &Path) -> String { match command_text("stat", &["-f", "-c", "%T", path.to_str().unwrap()]).as_str() { "ext2/ext3" => "ext4".to_owned(), diff --git a/crates/graphforge-exec/src/adjacency.rs b/crates/graphforge-exec/src/adjacency.rs index 81858dc7..11b91ca3 100644 --- a/crates/graphforge-exec/src/adjacency.rs +++ b/crates/graphforge-exec/src/adjacency.rs @@ -1625,8 +1625,8 @@ mod tests { release_tx.send(()).unwrap(); let built = builder.join().unwrap().unwrap(); - let waited = waiter.join().unwrap().unwrap(); - assert!(Arc::ptr_eq(&built, &waited)); + let waiter_view = waiter.join().unwrap().unwrap(); + assert!(Arc::ptr_eq(&built, &waiter_view)); }); } diff --git a/crates/graphforge-exec/src/demand.rs b/crates/graphforge-exec/src/demand.rs index 0da431d3..5e2e6fef 100644 --- a/crates/graphforge-exec/src/demand.rs +++ b/crates/graphforge-exec/src/demand.rs @@ -121,6 +121,8 @@ pub struct DemandSnapshot { pub memory_reserved_before: u64, /// Query memory-pool reservation after every stream/operator was dropped. pub memory_reserved_after: u64, + /// Arrow bytes retained by returned batches at the post-operator boundary. + pub returned_batch_bytes: u64, /// Process RSS attributed to operator lifetimes by the query sampler. pub operator_rss: OperatorRssSnapshot, } @@ -136,6 +138,24 @@ pub struct OperatorRssSnapshot { pub sort_peak_bytes: u64, /// Last RSS sample while a plan containing a sort was collecting. pub sort_current_bytes: u64, + /// Per-hop RSS lifetime evidence keyed by edge variable. + pub expand_by_hop: BTreeMap, + /// RSS sampled while sort collection was active and no expansion stream + /// was active. This is the non-overlapping ordered-operator attribution. + pub sort_exclusive: RssLifetimeSnapshot, +} + +/// Process RSS at the boundaries and peak of one operator lifetime. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RssLifetimeSnapshot { + /// RSS when the first matching operator became active. + pub before_bytes: u64, + /// Highest RSS sampled while the operator was active. + pub peak_bytes: u64, + /// Last RSS sampled while the operator was active. + pub current_bytes: u64, + /// RSS after the last matching operator was dropped. + pub after_bytes: u64, } /// Authoritative post-execution DataFusion metrics for one ordered operator. @@ -167,6 +187,8 @@ struct QueryCapture { expand_current: AtomicU64, sort_peak: AtomicU64, sort_current: AtomicU64, + expand_lifetimes: Mutex>, + sort_exclusive: Mutex, stop: AtomicBool, } @@ -180,11 +202,33 @@ impl QueryCapture { expand_current: AtomicU64::new(0), sort_peak: AtomicU64::new(0), sort_current: AtomicU64::new(0), + expand_lifetimes: Mutex::new(BTreeMap::new()), + sort_exclusive: Mutex::new(ActiveRssLifetime::default()), stop: AtomicBool::new(false), } } } +#[derive(Default)] +struct ActiveRssLifetime { + active: usize, + before_bytes: u64, + peak_bytes: u64, + current_bytes: u64, + after_bytes: u64, +} + +impl ActiveRssLifetime { + fn snapshot(&self) -> RssLifetimeSnapshot { + RssLifetimeSnapshot { + before_bytes: self.before_bytes, + peak_bytes: self.peak_bytes, + current_bytes: self.current_bytes, + after_bytes: self.after_bytes, + } + } +} + /// Run one future with isolated, task-scoped query evidence. pub async fn observe(future: F) -> (F::Output, DemandSnapshot) { let capture = Arc::new(QueryCapture::new()); @@ -202,6 +246,18 @@ pub async fn observe(future: F) -> (F::Output, DemandSna expand_current_bytes: capture.expand_current.load(Ordering::Acquire), sort_peak_bytes: capture.sort_peak.load(Ordering::Acquire), sort_current_bytes: capture.sort_current.load(Ordering::Acquire), + expand_by_hop: capture + .expand_lifetimes + .lock() + .expect("expand RSS lifetime lock") + .iter() + .map(|(edge_var, lifetime)| (*edge_var, lifetime.snapshot())) + .collect(), + sort_exclusive: capture + .sort_exclusive + .lock() + .expect("sort RSS lifetime lock") + .snapshot(), }; (output, snapshot) } @@ -251,10 +307,28 @@ fn sample_rss(capture: &QueryCapture) { if capture.expand_active.load(Ordering::Acquire) > 0 { capture.expand_current.store(rss, Ordering::Release); capture.expand_peak.fetch_max(rss, Ordering::AcqRel); + for lifetime in capture + .expand_lifetimes + .lock() + .expect("expand RSS lifetime lock") + .values_mut() + .filter(|lifetime| lifetime.active > 0) + { + lifetime.current_bytes = rss; + lifetime.peak_bytes = lifetime.peak_bytes.max(rss); + } } if capture.sort_active.load(Ordering::Acquire) > 0 { capture.sort_current.store(rss, Ordering::Release); capture.sort_peak.fetch_max(rss, Ordering::AcqRel); + if capture.expand_active.load(Ordering::Acquire) == 0 { + let mut lifetime = capture + .sort_exclusive + .lock() + .expect("sort RSS lifetime lock"); + lifetime.current_bytes = rss; + lifetime.peak_bytes = lifetime.peak_bytes.max(rss); + } } } std::thread::sleep(std::time::Duration::from_millis(10)); @@ -268,12 +342,12 @@ pub(crate) struct OperatorActivity { capture: Option>, } enum OperatorKind { - Expand, + Expand(u32), Sort, } impl OperatorActivity { - pub(crate) fn expand() -> Self { - Self::new(OperatorKind::Expand) + pub(crate) fn expand(edge_var: u32) -> Self { + Self::new(OperatorKind::Expand(edge_var)) } fn sort() -> Self { Self::new(OperatorKind::Sort) @@ -282,10 +356,34 @@ impl OperatorActivity { let capture = ACTIVE_CAPTURE.try_with(Arc::clone).ok(); if let Some(capture) = &capture { match kind { - OperatorKind::Expand => &capture.expand_active, + OperatorKind::Expand(_) => &capture.expand_active, OperatorKind::Sort => &capture.sort_active, } .fetch_add(1, Ordering::AcqRel); + let rss = current_rss_bytes().unwrap_or(0); + match kind { + OperatorKind::Expand(edge_var) => { + let mut lifetimes = capture + .expand_lifetimes + .lock() + .expect("expand RSS lifetime lock"); + let lifetime = lifetimes.entry(edge_var).or_default(); + if lifetime.active == 0 { + lifetime.before_bytes = rss; + } + lifetime.active += 1; + } + OperatorKind::Sort => { + let mut lifetime = capture + .sort_exclusive + .lock() + .expect("sort RSS lifetime lock"); + if lifetime.active == 0 { + lifetime.before_bytes = rss; + } + lifetime.active += 1; + } + } } Self { kind, capture } } @@ -301,10 +399,34 @@ impl Drop for OperatorActivity { fn drop(&mut self) { if let Some(capture) = &self.capture { match self.kind { - OperatorKind::Expand => &capture.expand_active, + OperatorKind::Expand(_) => &capture.expand_active, OperatorKind::Sort => &capture.sort_active, } .fetch_sub(1, Ordering::AcqRel); + let rss = current_rss_bytes().unwrap_or(0); + match self.kind { + OperatorKind::Expand(edge_var) => { + let mut lifetimes = capture + .expand_lifetimes + .lock() + .expect("expand RSS lifetime lock"); + let lifetime = lifetimes.entry(edge_var).or_default(); + lifetime.active = lifetime.active.saturating_sub(1); + if lifetime.active == 0 { + lifetime.after_bytes = rss; + } + } + OperatorKind::Sort => { + let mut lifetime = capture + .sort_exclusive + .lock() + .expect("sort RSS lifetime lock"); + lifetime.active = lifetime.active.saturating_sub(1); + if lifetime.active == 0 { + lifetime.after_bytes = rss; + } + } + } } } } @@ -320,7 +442,11 @@ pub(crate) fn record_memory_before(bytes: usize) { } /// Capture metrics only after collection has dropped every operator stream. -pub(crate) fn record_plan_after(plan: &Arc, memory_reserved_after: usize) { +pub(crate) fn record_plan_after( + plan: &Arc, + memory_reserved_after: usize, + returned_batch_bytes: usize, +) { fn value(metrics: &datafusion::physical_plan::metrics::MetricsSet, name: &str) -> u64 { metrics .sum(|metric| metric.value().name() == name) @@ -349,6 +475,7 @@ pub(crate) fn record_plan_after(plan: &Arc, memory_reserved_a snapshot.sorts.clear(); visit(plan, &mut snapshot.sorts); snapshot.memory_reserved_after = memory_reserved_after as u64; + snapshot.returned_batch_bytes = returned_batch_bytes as u64; }); } @@ -1028,7 +1155,7 @@ mod tests { let (_, nested) = observe(async { record_input(2, 3) }).await; assert_eq!(nested.hops[&2].input_rows, 3); let plan: Arc = Arc::new(EmptyExec::new(Arc::new(Schema::empty()))); - record_plan_after(&plan, 0); + record_plan_after(&plan, 0, 0); Err::<(), _>("typed failure") }); let right = observe(async { record_input(7, 5) }); @@ -1063,6 +1190,45 @@ mod tests { assert_eq!(ACTIVE_SAMPLERS.load(Ordering::Acquire), 0); } + #[tokio::test] + async fn rss_lifetimes_separate_each_expand_from_sort_only_work() { + let _guard = OBSERVATION_TEST_LOCK.lock().unwrap(); + let (_, snapshot) = observe(async { + let sort = OperatorActivity::sort(); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + { + let _first = OperatorActivity::expand(11); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + { + let _second = OperatorActivity::expand(22); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + drop(sort); + }) + .await; + + assert_eq!( + snapshot + .operator_rss + .expand_by_hop + .keys() + .copied() + .collect::>(), + [11, 22] + ); + for lifetime in snapshot.operator_rss.expand_by_hop.values() { + assert!(lifetime.before_bytes > 0 || !cfg!(target_os = "linux")); + assert!(lifetime.after_bytes > 0 || !cfg!(target_os = "linux")); + assert!(lifetime.peak_bytes >= lifetime.current_bytes); + } + let sort = &snapshot.operator_rss.sort_exclusive; + assert!(sort.before_bytes > 0 || !cfg!(target_os = "linux")); + assert!(sort.after_bytes > 0 || !cfg!(target_os = "linux")); + assert!(sort.peak_bytes >= sort.current_bytes); + } + #[test] fn quiescence_tracks_live_permits_and_output_thresholds() { let demand = Arc::new(QueryDemand::new()); diff --git a/crates/graphforge-exec/src/lib.rs b/crates/graphforge-exec/src/lib.rs index 3c8b11be..bee3faeb 100644 --- a/crates/graphforge-exec/src/lib.rs +++ b/crates/graphforge-exec/src/lib.rs @@ -3378,7 +3378,7 @@ impl ExecutionPlan for ExpandExec { None, batch_size, initial_batch_goal, - demand::OperatorActivity::expand(), + demand::OperatorActivity::expand(self.edge_var), ), |( mut input_stream, @@ -5084,7 +5084,14 @@ impl ExecutionSession { let _sort_activity = demand::sort_activity(&physical); let collected = collect(Arc::clone(&physical), self.ctx.task_ctx()).await; - demand::record_plan_after(&physical, self.ctx.runtime_env().memory_pool.reserved()); + let returned_batch_bytes = collected.as_ref().map_or(0, |batches| { + batches.iter().map(RecordBatch::get_array_memory_size).sum() + }); + demand::record_plan_after( + &physical, + self.ctx.runtime_env().memory_pool.reserved(), + returned_batch_bytes, + ); let mut batches = collected.map_err(|e| GfError::Execution(e.to_string()))?; // DataFusion's collect may return zero batches for an empty stream. diff --git a/docs/book/architecture/execution-model.md b/docs/book/architecture/execution-model.md index 081edb8e..b8327efd 100644 --- a/docs/book/architecture/execution-model.md +++ b/docs/book/architecture/execution-model.md @@ -325,10 +325,13 @@ Observed execution is query scoped. `GraphForge::execute_observed` returns the typed query outcome, including an execution error, together with demand, sort, memory-pool, and sampled process-RSS evidence. Ordinary execution has no observer overhead or mutable global capture state. Expand RSS is sampled for -the lifetime of each `ExpandExec` stream; sort RSS covers physical collection -when the plan contains a sort. The lifetimes may overlap, so both peaks -intentionally attribute shared process RSS rather than claiming an exclusive -per-operator partition of process memory. +the lifetime of each `ExpandExec` stream and reported separately for every hop. +Sort RSS includes its physical collection lifetime plus a `sort_exclusive` +measurement sampled only while no expansion is active. This preserves the real +nested lifetime while providing non-overlapping attribution. Memory quiescence +compares post-operator pool reservations with the pre-query baseline plus Arrow +bytes intentionally retained by returned batches; sort operator retained memory +must independently reach zero. Query-result files use the same demand-driven `RecordBatch` stream. Parquet and Arrow IPC sinks request one batch only after the preceding batch has been diff --git a/docs/development/perf-g500-ladder.md b/docs/development/perf-g500-ladder.md index 90dc31e4..ad38eece 100644 --- a/docs/development/perf-g500-ladder.md +++ b/docs/development/perf-g500-ladder.md @@ -152,6 +152,73 @@ first-fail unit tests): cargo test -p graphforge-api --test scale_g500_ladder ``` +Provisioned S20 full lifecycle (the work root must not already exist): + +```bash +GF_G500_S20_WORK_ROOT=/mounted-work/s20 \ +GF_G500_S20_EVIDENCE_OUT=/mounted-work/s20-evidence.json \ +GF_G500_CERT_JOURNAL_OUT=/mounted-work/s20-journal.json \ +GF_G500_S20_EXPECTED_SHA="$(git rev-parse HEAD)" \ +make bench-g500-s20-lifecycle +``` + +This is distinct from the first-fail ladder entry. It uses the versioned S20 +profile values and runs all 17 integrated lifecycle phases: source generation, +ingest, CSR, reopen and queries; portable export and full verification; import +into a previously absent destination; imported reopen and equivalent queries; +and the four bounded negative drills. Its evidence is not a pass unless every +phase is present and successful and the source/import fingerprints match. + +### Disposable Fly S20 controller + +The checked-in Fly harness is +[`scripts/fly-g500-s20.py`](../../scripts/fly-g500-s20.py), with its immutable +runtime image under +[`containers/fly-g500-s20/`](../../containers/fly-g500-s20/). Build and push +the image for the final clean commit as Linux/amd64. A Fly registry namespace +requires its empty disposable app to exist before the push; execution accepts +only that exact empty app and owns its final destruction. Resolve the +**platform-child** manifest digest after pushing (an OCI index digest is +rejected): + +```bash +SHA="$(git rev-parse HEAD)" +APP="gf-s20-${SHA%????????????????????????????????}" +flyctl apps create "$APP" --org personal +docker buildx build --platform linux/amd64 --provenance=false --push \ + -f containers/fly-g500-s20/Dockerfile \ + -t "registry.fly.io/${APP}:${SHA}" . +docker buildx imagetools inspect --raw "registry.fly.io/${APP}:${SHA}" +``` + +Run the controller without `--execute` against the resolved child digest before +execution. This creates no Machine or volume. It fetches the current official +Fly pricing page, extracts the fixed `dfw` performance-2x/4GB and volume rates, +and refuses a projected 4h30 maximum that exceeds the approved $10 ceiling. A +$1 reserve covers unpriced registry/rootfs/network variance. The controller +fixes 2 performance CPUs, 4096 MiB RAM, one 50 GB volume, no services, restart +`no`, auto-destroy, and a 16,200-second hard controller deadline. + +```bash +python3 scripts/fly-g500-s20.py \ + --expected-sha "$SHA" \ + --image "registry.fly.io/${APP}@sha256:" \ + --org personal --app-name "$APP" \ + --machine-name "${APP}-machine" --volume-name gf_s20_volume +``` + +Only after inspecting that dry-run, add `--execute --confirm-disposable`. Live +execution additionally requires the exact clean checkout, re-resolves the child +manifest, keeps the Fly token only in process memory, retrieves and validates +the journal/evidence, acknowledges retrieval, and destroys and verifies absence +of the Machine, volume, and app in `finally`. Do not use pricing fixtures with +execution; `--pricing-html` and `--manifest-json` exist only for deterministic +dry-run tests. + +```bash +python3 scripts/ci/test-fly-g500-s20.py +``` + Provisioned full ladder (long; isolate the target dir; **Linux cloud scale-host** matching the declared SKU — not a developer laptop for #745 evidence): diff --git a/scripts/ci/test-fly-g500-s20.py b/scripts/ci/test-fly-g500-s20.py new file mode 100644 index 00000000..1384cea0 --- /dev/null +++ b/scripts/ci/test-fly-g500-s20.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Deterministic contract tests for the disposable Fly S20 controller.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +from pathlib import Path +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +CONTROLLER = ROOT / "scripts/fly-g500-s20.py" +spec = importlib.util.spec_from_file_location("fly_g500_s20", CONTROLLER) +assert spec and spec.loader +controller = importlib.util.module_from_spec(spec) +spec.loader.exec_module(controller) + + +def pricing_html(hour: str = "0.1076") -> str: + second = f"{float(hour) / 3600:.8f}" + return f''' +
+ +
performance-2x2 performance4GB${second}${hour}
+

$0.15/GB per month of provisioned capacity

+ ''' + + +def args(root: Path) -> argparse.Namespace: + return argparse.Namespace( + expected_sha="a" * 40, + image="registry.fly.io/gf-s20@sha256:" + "b" * 64, + region="dfw", org="personal", app_name="gf-s20-test", + machine_name="gf-s20-machine", volume_name="gf_s20_volume", + ceiling_usd=10.0, unpriced_reserve_usd=1.0, + pricing_html=root / "pricing.html", manifest_json=root / "manifest.json", + evidence_out=root / "evidence.json", journal_out=root / "journal.json", + execute=False, confirm_disposable=False, + ) + + +def main() -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + parsed = controller.parse_live_rates(pricing_html(), "dfw") + assert parsed == {"compute_per_hour_usd": 0.1076, "volume_gb_month_usd": 0.15} + cost = controller.cost_plan(parsed, 10.0, 1.0) + assert cost["projected_max_usd"] < 10.0 + try: + controller.cost_plan({"compute_per_hour_usd": 3.0, "volume_gb_month_usd": 1.0}, 10.0, 1.0) + except controller.ControllerError: + pass + else: + raise AssertionError("over-budget live rates must be refused") + + child = json.dumps({"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json"}) + controller.assert_platform_child("unused", child) + try: + controller.assert_platform_child( + "unused", json.dumps({"mediaType": "application/vnd.oci.image.index.v1+json", "manifests": []}) + ) + except controller.ControllerError: + pass + else: + raise AssertionError("OCI index must be refused") + + options = args(root) + digest = controller.validate_args(options) + assert digest == "sha256:" + "b" * 64 + payload = controller.machine_payload(options, "vol_test") + config = payload["config"] + assert config["services"] == [] + assert config["restart"] == {"policy": "no"} + assert config["auto_destroy"] is True + assert config["guest"] == {"cpu_kind": "performance", "cpus": 2, "memory_mb": 4096} + assert config["mounts"] == [{"volume": "vol_test", "path": "/work"}] + + phases = [{"id": phase, "status": "pass"} for phase in controller.PHASES] + lifecycle = { + "phases": phases, "source_edges": 1, "imported_edges": 1, + "source_project_fingerprint": "sha256:x", "imported_project_fingerprint": "sha256:x", + "source_authority_fingerprint": "sha256:y", "imported_authority_fingerprint": "sha256:y", + } + evidence = { + "schema": "graphforge-s20-integrated-lifecycle-evidence/1", + "git_sha": "a" * 40, "result": "pass", "lifecycle": lifecycle, + } + controller.validate_evidence(evidence, phases, "a" * 40) + evidence["lifecycle"]["imported_edges"] = 2 + try: + controller.validate_evidence(evidence, phases, "a" * 40) + except controller.ControllerError: + pass + else: + raise AssertionError("non-equivalent import must be refused") + + (root / "pricing.html").write_text(pricing_html()) + (root / "manifest.json").write_text(child) + # Main dry-run exercises argument/config/rate/manifest validation without Fly. + import subprocess + result = subprocess.run( + [ + "python3", str(CONTROLLER), "--expected-sha", "a" * 40, + "--image", options.image, "--org", "personal", + "--app-name", options.app_name, "--machine-name", options.machine_name, + "--volume-name", options.volume_name, "--pricing-html", str(options.pricing_html), + "--manifest-json", str(options.manifest_json), + "--evidence-out", str(options.evidence_out), "--journal-out", str(options.journal_out), + ], + cwd=ROOT, check=True, capture_output=True, text=True, + ) + plan = json.loads(result.stdout) + assert plan["mode"] == "dry-run" and plan["hard_ttl_s"] == 16200 + assert plan["volume_gb"] == 50 and plan["public_services"] == 0 + + source = CONTROLLER.read_text() + assert "Authorization" in source and '["auth", "token"]' in source + assert "finally:" in source and "destroy_and_verify" in source + assert "FLY_API_TOKEN" not in source + print("Fly S20 controller tests passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/fly-g500-s20.py b/scripts/fly-g500-s20.py new file mode 100644 index 00000000..e50302ab --- /dev/null +++ b/scripts/fly-g500-s20.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Plan or run one disposable Fly 4 GiB S20 full-lifecycle Machine.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from datetime import datetime, timezone +import json +from pathlib import Path +import re +import subprocess +import tempfile +import time +from typing import Any +import urllib.error +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +PRICING_URL = "https://fly.io/docs/about/pricing/" +SHA = re.compile(r"^[0-9a-f]{40}$") +CHILD_IMAGE = re.compile(r"^[^\s@]+@(?Psha256:[0-9a-f]{64})$") +SAFE_NAME = re.compile(r"^[a-z][a-z0-9-]{2,62}$") +SAFE_VOLUME = re.compile(r"^[a-z][a-z0-9_]{0,29}$") +PHASES = [ + "preflight", "generate", "ingest", "csr", "source_reopen", + "source_query_1hop", "source_query_2hop", "export", "verify", "import", + "imported_reopen", "imported_query_1hop", "imported_query_2hop", + "drill_corruption", "drill_cancellation", "drill_resource_limit", + "drill_interrupted_finalization", +] +HARD_TTL_S = 4 * 3600 + 30 * 60 +VOLUME_GB = 50 +MEMORY_MB = 4096 +CPUS = 2 + + +class ControllerError(RuntimeError): + pass + + +class Flyctl: + def run(self, args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["flyctl", *args], cwd=ROOT, check=check, capture_output=True, + text=True, timeout=120, + ) + + def json(self, args: Sequence[str]) -> Any: + return json.loads(self.run([*args, "--json"]).stdout) + + +def fetch_pricing() -> str: + request = urllib.request.Request(PRICING_URL, headers={"User-Agent": "graphforge-s20-controller/1"}) + with urllib.request.urlopen(request, timeout=30) as response: + if response.geturl() != PRICING_URL: + raise ControllerError("official pricing request redirected") + return response.read().decode("utf-8") + + +def parse_live_rates(html: str, region: str) -> dict[str, float]: + matrix = re.search( + rf'id="started-machines-pricing-matrix-{re.escape(region)}".*?', + html, re.DOTALL, + ) + if not matrix: + raise ControllerError(f"official pricing has no region {region}") + row = re.search( + r"performance-2x.*?2 performance.*?4GB.*?" + r"\$(?P[0-9.]+).*?\$(?P[0-9.]+)", + matrix.group(), re.DOTALL, + ) + volume = re.search(r"\$(?P[0-9.]+)/GB per month of provisioned capacity", html) + if not row or not volume: + raise ControllerError("official pricing format did not contain required live rates") + per_second = float(row.group("second")) + per_hour = float(row.group("hour")) + if abs(per_second * 3600 - per_hour) > 0.001: + raise ControllerError("official per-second/hour compute rates disagree") + return {"compute_per_hour_usd": per_hour, "volume_gb_month_usd": float(volume.group("rate"))} + + +def cost_plan(rates: dict[str, float], ceiling: float, reserve: float) -> dict[str, float]: + compute = rates["compute_per_hour_usd"] * HARD_TTL_S / 3600 + # Volume billing is hourly; conservatively charge a full five hours. + volume = rates["volume_gb_month_usd"] * VOLUME_GB * 5 / (30 * 24) + projected = compute + volume + reserve + if projected > ceiling: + raise ControllerError( + f"projected maximum ${projected:.4f} exceeds ${ceiling:.2f} ceiling" + ) + return {"compute_usd": compute, "volume_usd": volume, "unpriced_reserve_usd": reserve, + "projected_max_usd": projected, "ceiling_usd": ceiling} + + +def validate_args(args: argparse.Namespace) -> str: + image = CHILD_IMAGE.fullmatch(args.image) + if not image: + raise ControllerError("--image must pin one immutable platform child @sha256 digest") + if not SHA.fullmatch(args.expected_sha): + raise ControllerError("--expected-sha must be exact lowercase 40-hex") + if args.region != "dfw": + raise ControllerError("S20 comparison region is fixed to dfw") + if any(not SAFE_NAME.fullmatch(value) for value in (args.app_name, args.machine_name)): + raise ControllerError("unsafe app or Machine name") + if not SAFE_VOLUME.fullmatch(args.volume_name): + raise ControllerError("unsafe volume name") + if args.ceiling_usd != 10.0 or args.unpriced_reserve_usd < 1.0: + raise ControllerError("controller requires the approved $10 ceiling and >=$1 reserve") + if args.execute and (not args.confirm_disposable or args.pricing_html or args.manifest_json): + raise ControllerError("execution requires confirmation and live official pricing") + if not args.evidence_out.parent.is_dir() or not args.journal_out.parent.is_dir(): + raise ControllerError("local output parents must already exist") + return image.group("digest") + + +def check_source(expected_sha: str) -> None: + head = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=ROOT, check=True, + capture_output=True, text=True, + ).stdout.strip() + dirty = subprocess.run( + ["git", "status", "--porcelain"], cwd=ROOT, check=True, + capture_output=True, text=True, + ).stdout + if head != expected_sha or dirty: + raise ControllerError("execution requires the exact clean checked-out source SHA") + + +def assert_platform_child(image: str, manifest_json: str | None = None) -> None: + if manifest_json is None: + result = subprocess.run( + ["docker", "buildx", "imagetools", "inspect", "--raw", image], + cwd=ROOT, check=True, capture_output=True, text=True, timeout=120, + ) + manifest_json = result.stdout + manifest = json.loads(manifest_json) + if "manifests" in manifest: + raise ControllerError("image digest identifies an OCI index, not a platform child") + media_type = manifest.get("mediaType", "") + if "manifest" not in media_type: + raise ControllerError("image digest did not resolve to an OCI/Docker child manifest") + + +def machine_payload(args: argparse.Namespace, volume_id: str) -> dict[str, Any]: + return { + "name": args.machine_name, "region": args.region, + "skip_launch": False, "skip_service_registration": True, + "config": { + "image": args.image, "auto_destroy": True, "restart": {"policy": "no"}, + "guest": {"cpu_kind": "performance", "cpus": CPUS, "memory_mb": MEMORY_MB}, + "mounts": [{"volume": volume_id, "path": "/work"}], "services": [], + "env": {"GF_G500_S20_EXPECTED_SHA": args.expected_sha}, + }, + } + + +def create_machine(args: argparse.Namespace, fly: Flyctl, volume_id: str) -> dict[str, Any]: + token = fly.run(["auth", "token"]).stdout.strip() + if not token: + raise ControllerError("Fly authentication token is unavailable") + request = urllib.request.Request( + f"https://api.machines.dev/v1/apps/{args.app_name}/machines", + data=json.dumps(machine_payload(args, volume_id)).encode(), + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + return json.load(response) + except (urllib.error.HTTPError, urllib.error.URLError): + raise ControllerError("Fly Machines API rejected creation") from None + + +def assert_machine(machine: dict[str, Any], args: argparse.Namespace, digest: str) -> None: + config = machine.get("config", {}) + guest = config.get("guest", {}) + if machine.get("region") != args.region or machine.get("image_ref", {}).get("digest") != digest: + raise ControllerError("observed region or child image digest differs from plan") + if config.get("auto_destroy") is not True or config.get("restart") != {"policy": "no"}: + raise ControllerError("observed Machine is not disposable") + if config.get("services") not in (None, []) or guest != { + "cpu_kind": "performance", "cpus": CPUS, "memory_mb": MEMORY_MB + }: + raise ControllerError("observed Machine resources/services differ from plan") + mounts = config.get("mounts", []) + if len(mounts) != 1 or mounts[0].get("path") != "/work": + raise ControllerError("observed work-root volume differs from plan") + + +def validate_evidence(evidence: dict[str, Any], journal: list[dict[str, Any]], sha: str) -> None: + if evidence.get("schema") != "graphforge-s20-integrated-lifecycle-evidence/1": + raise ControllerError("unexpected S20 evidence schema") + if evidence.get("git_sha") != sha or evidence.get("result") != "pass": + raise ControllerError("S20 evidence SHA/result mismatch") + lifecycle = evidence.get("lifecycle", {}) + observed = [phase.get("id") for phase in lifecycle.get("phases", [])] + if observed != PHASES or [phase.get("id") for phase in journal] != PHASES: + raise ControllerError("S20 evidence does not contain the exact 17 phases") + if any(phase.get("status") != "pass" for phase in journal) or any( + phase.get("status") != "pass" for phase in lifecycle.get("phases", []) + ): + raise ControllerError("S20 evidence or journal contains a non-pass phase") + for left, right in ( + ("source_edges", "imported_edges"), + ("source_project_fingerprint", "imported_project_fingerprint"), + ("source_authority_fingerprint", "imported_authority_fingerprint"), + ): + if lifecycle.get(left) != lifecycle.get(right): + raise ControllerError(f"S20 lifecycle mismatch: {left}/{right}") + + +def destroy_and_verify(fly: Flyctl, app: str, machine_id: str | None, volume_id: str | None) -> None: + if machine_id: + fly.run(["machine", "destroy", machine_id, "--app", app, "--force"], check=False) + if volume_id: + fly.run(["volumes", "destroy", volume_id, "--app", app, "--yes"], check=False) + for _ in range(10): + machines = fly.json(["machines", "list", "--app", app]) + volumes = fly.json(["volumes", "list", "--app", app]) + machine_absent = not machine_id or not any(item.get("id") == machine_id for item in machines) + volume_absent = not volume_id or not any(item.get("id") == volume_id for item in volumes) + if machine_absent and volume_absent: + break + time.sleep(2) + else: + raise ControllerError("cleanup verification found a Machine or volume still present") + fly.run(["apps", "destroy", app, "--yes"], check=False) + for _ in range(10): + apps = fly.json(["apps", "list"]) + if not any(item.get("Name") == app or item.get("name") == app for item in apps): + return + time.sleep(2) + raise ControllerError("cleanup verification found the disposable app still present") + + +def retrieve(fly: Flyctl, app: str, machine: str, remote: str, local: Path) -> bool: + result = fly.run( + ["ssh", "sftp", "get", remote, str(local), "--app", app, "--machine", machine], + check=False, + ) + return result.returncode == 0 and local.is_file() + + +def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: + app_created = False + machine_id = volume_id = None + deadline = time.monotonic() + HARD_TTL_S + try: + apps = fly.json(["apps", "list"]) + app_exists = any( + item.get("Name") == args.app_name or item.get("name") == args.app_name for item in apps + ) + if app_exists: + if fly.json(["machines", "list", "--app", args.app_name]) or fly.json( + ["volumes", "list", "--app", args.app_name] + ): + raise ControllerError("refusing to reuse a non-empty image-staging app") + else: + app_created = True + fly.run(["apps", "create", args.app_name, "--org", args.org]) + app_created = True + volume = fly.json([ + "volumes", "create", args.volume_name, "--app", args.app_name, + "--region", args.region, "--size", str(VOLUME_GB), + "--scheduled-snapshots=false", "--yes", + ]) + volume_id = volume["id"] + machine = create_machine(args, fly, volume_id) + machine_id = machine["id"] + assert_machine(machine, args, digest) + with tempfile.TemporaryDirectory(prefix="graphforge-fly-s20-") as directory: + journal_path = Path(directory) / "journal.json" + evidence_path = Path(directory) / "evidence.json" + while time.monotonic() < deadline: + retrieve(fly, args.app_name, machine_id, "/work/s20-journal.json", journal_path) + if retrieve(fly, args.app_name, machine_id, "/work/s20-evidence.json", evidence_path): + break + time.sleep(5) + else: + raise ControllerError("4h30 hard deadline reached before S20 evidence") + evidence = json.loads(evidence_path.read_text()) + journal = json.loads(journal_path.read_text()) + validate_evidence(evidence, journal, args.expected_sha) + args.evidence_out.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n") + args.journal_out.write_text(json.dumps(journal, indent=2, sort_keys=True) + "\n") + fly.run([ + "machine", "exec", machine_id, "--app", args.app_name, + "touch /work/controller-ack", + ]) + finally: + if app_created: + destroy_and_verify(fly, args.app_name, machine_id, volume_id) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + result.add_argument("--expected-sha", required=True) + result.add_argument("--image", required=True) + result.add_argument("--region", default="dfw") + result.add_argument("--org", required=True) + result.add_argument("--app-name", required=True) + result.add_argument("--machine-name", required=True) + result.add_argument("--volume-name", required=True) + result.add_argument("--ceiling-usd", type=float, default=10.0) + result.add_argument("--unpriced-reserve-usd", type=float, default=1.0) + result.add_argument("--pricing-html", type=Path, help="dry-run test fixture only") + result.add_argument("--manifest-json", type=Path, help="dry-run manifest fixture only") + result.add_argument("--evidence-out", type=Path, default=Path("s20-evidence.json")) + result.add_argument("--journal-out", type=Path, default=Path("s20-journal.json")) + result.add_argument("--execute", action="store_true") + result.add_argument("--confirm-disposable", action="store_true") + return result + + +def main() -> int: + args = parser().parse_args() + try: + digest = validate_args(args) + if args.execute: + check_source(args.expected_sha) + assert_platform_child( + args.image, + args.manifest_json.read_text() if args.manifest_json else None, + ) + html = args.pricing_html.read_text() if args.pricing_html else fetch_pricing() + rates = parse_live_rates(html, args.region) + costs = cost_plan(rates, args.ceiling_usd, args.unpriced_reserve_usd) + plan = { + "mode": "execute" if args.execute else "dry-run", + "checked_at": datetime.now(timezone.utc).isoformat(), + "pricing_source": PRICING_URL, "rates": rates, "cost": costs, + "git_sha": args.expected_sha, "image_digest": digest, "region": args.region, + "machine": {"cpu_kind": "performance", "cpus": CPUS, "memory_mb": MEMORY_MB}, + "volume_gb": VOLUME_GB, "public_services": 0, "restart": "no", + "auto_destroy": True, "hard_ttl_s": HARD_TTL_S, + } + print(json.dumps(plan, indent=2, sort_keys=True)) + if args.execute: + execute(args, Flyctl(), digest) + except (ControllerError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as error: + print(f"Fly S20 controller refused: {error}", file=__import__("sys").stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bazel/drift/cargo_feature_fingerprint.json b/tools/bazel/drift/cargo_feature_fingerprint.json index 8fd38ee7..aedb09d4 100644 --- a/tools/bazel/drift/cargo_feature_fingerprint.json +++ b/tools/bazel/drift/cargo_feature_fingerprint.json @@ -1,6 +1,6 @@ { "schema": "graphforge.cargo-feature-fingerprint.v1", - "sha256": "abee5518fa5758615df53d8fad39a8e5b082e50d14a9e1c3c6b4562d7d457e41", + "sha256": "6bd46b262dcc95fdb3de2d8fc3945ddee6032426f0e137afcb19728c1c5480d0", "entries": [ { "name": "graphforge-api", @@ -117,15 +117,6 @@ "kind": null, "target": null }, - { - "name": "graphforge-observability", - "req": "^0.5.2", - "features": [], - "optional": false, - "uses_default_features": true, - "kind": null, - "target": null - }, { "name": "graphforge-ontology", "req": "^0.5.2", @@ -547,15 +538,6 @@ "kind": null, "target": null }, - { - "name": "fs4", - "req": "^1.1", - "features": [], - "optional": false, - "uses_default_features": true, - "kind": null, - "target": null - }, { "name": "graphforge-api", "req": "^0.5.2", @@ -565,31 +547,13 @@ "kind": null, "target": null }, - { - "name": "graphforge-discovery", - "req": "^0.5.2", - "features": [], - "optional": false, - "uses_default_features": true, - "kind": null, - "target": null - }, { "name": "graphforge-storage", "req": "^0.5.2", "features": [], "optional": false, "uses_default_features": true, - "kind": null, - "target": null - }, - { - "name": "libc", - "req": "^0.2", - "features": [], - "optional": false, - "uses_default_features": true, - "kind": null, + "kind": "dev", "target": null }, { @@ -636,7 +600,7 @@ "features": [], "optional": false, "uses_default_features": true, - "kind": null, + "kind": "dev", "target": null }, { @@ -645,29 +609,7 @@ "features": [], "optional": false, "uses_default_features": true, - "kind": null, - "target": null - }, - { - "name": "ureq", - "req": "=3.4.0", - "features": [ - "rustls" - ], - "optional": false, - "uses_default_features": false, - "kind": null, - "target": null - }, - { - "name": "url", - "req": "^2", - "features": [ - "serde" - ], - "optional": false, - "uses_default_features": true, - "kind": null, + "kind": "dev", "target": null }, { @@ -1057,7 +999,7 @@ ], "optional": false, "uses_default_features": true, - "kind": "dev", + "kind": null, "target": null } ] @@ -1348,64 +1290,6 @@ } ] }, - { - "name": "graphforge-observability", - "version": "0.5.2", - "features": [], - "dependencies": [ - { - "name": "serde", - "req": "^1", - "features": [ - "derive" - ], - "optional": false, - "uses_default_features": true, - "kind": null, - "target": null - }, - { - "name": "serde_json", - "req": "^1", - "features": [], - "optional": false, - "uses_default_features": true, - "kind": null, - "target": null - }, - { - "name": "thiserror", - "req": "^2", - "features": [], - "optional": false, - "uses_default_features": true, - "kind": null, - "target": null - }, - { - "name": "ureq", - "req": "=3.4.0", - "features": [ - "rustls" - ], - "optional": false, - "uses_default_features": false, - "kind": null, - "target": null - }, - { - "name": "url", - "req": "^2", - "features": [ - "serde" - ], - "optional": false, - "uses_default_features": true, - "kind": null, - "target": null - } - ] - }, { "name": "graphforge-ontology", "version": "0.5.2", From 30fc7571d0fe5192b25624b34cba77bd6cea6295 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:45:09 -0600 Subject: [PATCH 03/16] ci: align S20 harness gates --- .../tests/non-cypher-parity-policy.json | 4 +- scripts/ci/test-fly-g500-s20.py | 84 +++++++--- scripts/fly-g500-s20.py | 149 +++++++++++++----- 3 files changed, 173 insertions(+), 64 deletions(-) diff --git a/crates/graphforge-bindings-node/tests/non-cypher-parity-policy.json b/crates/graphforge-bindings-node/tests/non-cypher-parity-policy.json index 50e93e18..dc0a2cf6 100644 --- a/crates/graphforge-bindings-node/tests/non-cypher-parity-policy.json +++ b/crates/graphforge-bindings-node/tests/non-cypher-parity-policy.json @@ -1,8 +1,8 @@ { "contractVersion": 1, "rustManifest": "../../../tests/contracts/non-cypher-rust-surface.json", - "releaseSurfaceCount": 247, - "releaseSurfaceDigest": "fa31f90944981d9e850cb115e70beb0c987ba36cedd71c681a51b122d59954fb", + "releaseSurfaceCount": 249, + "releaseSurfaceDigest": "ddbc785e294fbbb2869fcc5721b2cde49bf7cfe1b59370122a6ae1079e257dd1", "requiredEquivalent": [ "GraphForge.adopt_ontology", "GraphForge.clear_ontology", diff --git a/scripts/ci/test-fly-g500-s20.py b/scripts/ci/test-fly-g500-s20.py index 1384cea0..7b3f8813 100644 --- a/scripts/ci/test-fly-g500-s20.py +++ b/scripts/ci/test-fly-g500-s20.py @@ -19,24 +19,31 @@ def pricing_html(hour: str = "0.1076") -> str: second = f"{float(hour) / 3600:.8f}" - return f''' + return f"""
performance-2x2 performance4GB ${second}${hour}

$0.15/GB per month of provisioned capacity

- ''' + """ def args(root: Path) -> argparse.Namespace: return argparse.Namespace( expected_sha="a" * 40, image="registry.fly.io/gf-s20@sha256:" + "b" * 64, - region="dfw", org="personal", app_name="gf-s20-test", - machine_name="gf-s20-machine", volume_name="gf_s20_volume", - ceiling_usd=10.0, unpriced_reserve_usd=1.0, - pricing_html=root / "pricing.html", manifest_json=root / "manifest.json", - evidence_out=root / "evidence.json", journal_out=root / "journal.json", - execute=False, confirm_disposable=False, + region="dfw", + org="personal", + app_name="gf-s20-test", + machine_name="gf-s20-machine", + volume_name="gf_s20_volume", + ceiling_usd=10.0, + unpriced_reserve_usd=1.0, + pricing_html=root / "pricing.html", + manifest_json=root / "manifest.json", + evidence_out=root / "evidence.json", + journal_out=root / "journal.json", + execute=False, + confirm_disposable=False, ) @@ -48,17 +55,24 @@ def main() -> None: cost = controller.cost_plan(parsed, 10.0, 1.0) assert cost["projected_max_usd"] < 10.0 try: - controller.cost_plan({"compute_per_hour_usd": 3.0, "volume_gb_month_usd": 1.0}, 10.0, 1.0) + controller.cost_plan( + {"compute_per_hour_usd": 3.0, "volume_gb_month_usd": 1.0}, 10.0, 1.0 + ) except controller.ControllerError: pass else: raise AssertionError("over-budget live rates must be refused") - child = json.dumps({"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json"}) + child = json.dumps( + {"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json"} + ) controller.assert_platform_child("unused", child) try: controller.assert_platform_child( - "unused", json.dumps({"mediaType": "application/vnd.oci.image.index.v1+json", "manifests": []}) + "unused", + json.dumps( + {"mediaType": "application/vnd.oci.image.index.v1+json", "manifests": []} + ), ) except controller.ControllerError: pass @@ -78,13 +92,19 @@ def main() -> None: phases = [{"id": phase, "status": "pass"} for phase in controller.PHASES] lifecycle = { - "phases": phases, "source_edges": 1, "imported_edges": 1, - "source_project_fingerprint": "sha256:x", "imported_project_fingerprint": "sha256:x", - "source_authority_fingerprint": "sha256:y", "imported_authority_fingerprint": "sha256:y", + "phases": phases, + "source_edges": 1, + "imported_edges": 1, + "source_project_fingerprint": "sha256:x", + "imported_project_fingerprint": "sha256:x", + "source_authority_fingerprint": "sha256:y", + "imported_authority_fingerprint": "sha256:y", } evidence = { "schema": "graphforge-s20-integrated-lifecycle-evidence/1", - "git_sha": "a" * 40, "result": "pass", "lifecycle": lifecycle, + "git_sha": "a" * 40, + "result": "pass", + "lifecycle": lifecycle, } controller.validate_evidence(evidence, phases, "a" * 40) evidence["lifecycle"]["imported_edges"] = 2 @@ -99,16 +119,36 @@ def main() -> None: (root / "manifest.json").write_text(child) # Main dry-run exercises argument/config/rate/manifest validation without Fly. import subprocess + result = subprocess.run( [ - "python3", str(CONTROLLER), "--expected-sha", "a" * 40, - "--image", options.image, "--org", "personal", - "--app-name", options.app_name, "--machine-name", options.machine_name, - "--volume-name", options.volume_name, "--pricing-html", str(options.pricing_html), - "--manifest-json", str(options.manifest_json), - "--evidence-out", str(options.evidence_out), "--journal-out", str(options.journal_out), + "python3", + str(CONTROLLER), + "--expected-sha", + "a" * 40, + "--image", + options.image, + "--org", + "personal", + "--app-name", + options.app_name, + "--machine-name", + options.machine_name, + "--volume-name", + options.volume_name, + "--pricing-html", + str(options.pricing_html), + "--manifest-json", + str(options.manifest_json), + "--evidence-out", + str(options.evidence_out), + "--journal-out", + str(options.journal_out), ], - cwd=ROOT, check=True, capture_output=True, text=True, + cwd=ROOT, + check=True, + capture_output=True, + text=True, ) plan = json.loads(result.stdout) assert plan["mode"] == "dry-run" and plan["hard_ttl_s"] == 16200 diff --git a/scripts/fly-g500-s20.py b/scripts/fly-g500-s20.py index e50302ab..61f67a2c 100644 --- a/scripts/fly-g500-s20.py +++ b/scripts/fly-g500-s20.py @@ -23,10 +23,22 @@ SAFE_NAME = re.compile(r"^[a-z][a-z0-9-]{2,62}$") SAFE_VOLUME = re.compile(r"^[a-z][a-z0-9_]{0,29}$") PHASES = [ - "preflight", "generate", "ingest", "csr", "source_reopen", - "source_query_1hop", "source_query_2hop", "export", "verify", "import", - "imported_reopen", "imported_query_1hop", "imported_query_2hop", - "drill_corruption", "drill_cancellation", "drill_resource_limit", + "preflight", + "generate", + "ingest", + "csr", + "source_reopen", + "source_query_1hop", + "source_query_2hop", + "export", + "verify", + "import", + "imported_reopen", + "imported_query_1hop", + "imported_query_2hop", + "drill_corruption", + "drill_cancellation", + "drill_resource_limit", "drill_interrupted_finalization", ] HARD_TTL_S = 4 * 3600 + 30 * 60 @@ -42,8 +54,12 @@ class ControllerError(RuntimeError): class Flyctl: def run(self, args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: return subprocess.run( - ["flyctl", *args], cwd=ROOT, check=check, capture_output=True, - text=True, timeout=120, + ["flyctl", *args], + cwd=ROOT, + check=check, + capture_output=True, + text=True, + timeout=120, ) def json(self, args: Sequence[str]) -> Any: @@ -51,7 +67,9 @@ def json(self, args: Sequence[str]) -> Any: def fetch_pricing() -> str: - request = urllib.request.Request(PRICING_URL, headers={"User-Agent": "graphforge-s20-controller/1"}) + request = urllib.request.Request( + PRICING_URL, headers={"User-Agent": "graphforge-s20-controller/1"} + ) with urllib.request.urlopen(request, timeout=30) as response: if response.geturl() != PRICING_URL: raise ControllerError("official pricing request redirected") @@ -61,14 +79,16 @@ def fetch_pricing() -> str: def parse_live_rates(html: str, region: str) -> dict[str, float]: matrix = re.search( rf'id="started-machines-pricing-matrix-{re.escape(region)}".*?', - html, re.DOTALL, + html, + re.DOTALL, ) if not matrix: raise ControllerError(f"official pricing has no region {region}") row = re.search( r"performance-2x.*?2 performance.*?4GB.*?" r"\$(?P[0-9.]+).*?\$(?P[0-9.]+)", - matrix.group(), re.DOTALL, + matrix.group(), + re.DOTALL, ) volume = re.search(r"\$(?P[0-9.]+)/GB per month of provisioned capacity", html) if not row or not volume: @@ -86,11 +106,14 @@ def cost_plan(rates: dict[str, float], ceiling: float, reserve: float) -> dict[s volume = rates["volume_gb_month_usd"] * VOLUME_GB * 5 / (30 * 24) projected = compute + volume + reserve if projected > ceiling: - raise ControllerError( - f"projected maximum ${projected:.4f} exceeds ${ceiling:.2f} ceiling" - ) - return {"compute_usd": compute, "volume_usd": volume, "unpriced_reserve_usd": reserve, - "projected_max_usd": projected, "ceiling_usd": ceiling} + raise ControllerError(f"projected maximum ${projected:.4f} exceeds ${ceiling:.2f} ceiling") + return { + "compute_usd": compute, + "volume_usd": volume, + "unpriced_reserve_usd": reserve, + "projected_max_usd": projected, + "ceiling_usd": ceiling, + } def validate_args(args: argparse.Namespace) -> str: @@ -116,12 +139,18 @@ def validate_args(args: argparse.Namespace) -> str: def check_source(expected_sha: str) -> None: head = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=ROOT, check=True, - capture_output=True, text=True, + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, ).stdout.strip() dirty = subprocess.run( - ["git", "status", "--porcelain"], cwd=ROOT, check=True, - capture_output=True, text=True, + ["git", "status", "--porcelain"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, ).stdout if head != expected_sha or dirty: raise ControllerError("execution requires the exact clean checked-out source SHA") @@ -131,7 +160,11 @@ def assert_platform_child(image: str, manifest_json: str | None = None) -> None: if manifest_json is None: result = subprocess.run( ["docker", "buildx", "imagetools", "inspect", "--raw", image], - cwd=ROOT, check=True, capture_output=True, text=True, timeout=120, + cwd=ROOT, + check=True, + capture_output=True, + text=True, + timeout=120, ) manifest_json = result.stdout manifest = json.loads(manifest_json) @@ -144,12 +177,17 @@ def assert_platform_child(image: str, manifest_json: str | None = None) -> None: def machine_payload(args: argparse.Namespace, volume_id: str) -> dict[str, Any]: return { - "name": args.machine_name, "region": args.region, - "skip_launch": False, "skip_service_registration": True, + "name": args.machine_name, + "region": args.region, + "skip_launch": False, + "skip_service_registration": True, "config": { - "image": args.image, "auto_destroy": True, "restart": {"policy": "no"}, + "image": args.image, + "auto_destroy": True, + "restart": {"policy": "no"}, "guest": {"cpu_kind": "performance", "cpus": CPUS, "memory_mb": MEMORY_MB}, - "mounts": [{"volume": volume_id, "path": "/work"}], "services": [], + "mounts": [{"volume": volume_id, "path": "/work"}], + "services": [], "env": {"GF_G500_S20_EXPECTED_SHA": args.expected_sha}, }, } @@ -180,7 +218,9 @@ def assert_machine(machine: dict[str, Any], args: argparse.Namespace, digest: st if config.get("auto_destroy") is not True or config.get("restart") != {"policy": "no"}: raise ControllerError("observed Machine is not disposable") if config.get("services") not in (None, []) or guest != { - "cpu_kind": "performance", "cpus": CPUS, "memory_mb": MEMORY_MB + "cpu_kind": "performance", + "cpus": CPUS, + "memory_mb": MEMORY_MB, }: raise ControllerError("observed Machine resources/services differ from plan") mounts = config.get("mounts", []) @@ -210,7 +250,9 @@ def validate_evidence(evidence: dict[str, Any], journal: list[dict[str, Any]], s raise ControllerError(f"S20 lifecycle mismatch: {left}/{right}") -def destroy_and_verify(fly: Flyctl, app: str, machine_id: str | None, volume_id: str | None) -> None: +def destroy_and_verify( + fly: Flyctl, app: str, machine_id: str | None, volume_id: str | None +) -> None: if machine_id: fly.run(["machine", "destroy", machine_id, "--app", app, "--force"], check=False) if volume_id: @@ -218,7 +260,9 @@ def destroy_and_verify(fly: Flyctl, app: str, machine_id: str | None, volume_id: for _ in range(10): machines = fly.json(["machines", "list", "--app", app]) volumes = fly.json(["volumes", "list", "--app", app]) - machine_absent = not machine_id or not any(item.get("id") == machine_id for item in machines) + machine_absent = not machine_id or not any( + item.get("id") == machine_id for item in machines + ) volume_absent = not volume_id or not any(item.get("id") == volume_id for item in volumes) if machine_absent and volume_absent: break @@ -260,11 +304,21 @@ def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: app_created = True fly.run(["apps", "create", args.app_name, "--org", args.org]) app_created = True - volume = fly.json([ - "volumes", "create", args.volume_name, "--app", args.app_name, - "--region", args.region, "--size", str(VOLUME_GB), - "--scheduled-snapshots=false", "--yes", - ]) + volume = fly.json( + [ + "volumes", + "create", + args.volume_name, + "--app", + args.app_name, + "--region", + args.region, + "--size", + str(VOLUME_GB), + "--scheduled-snapshots=false", + "--yes", + ] + ) volume_id = volume["id"] machine = create_machine(args, fly, volume_id) machine_id = machine["id"] @@ -274,7 +328,9 @@ def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: evidence_path = Path(directory) / "evidence.json" while time.monotonic() < deadline: retrieve(fly, args.app_name, machine_id, "/work/s20-journal.json", journal_path) - if retrieve(fly, args.app_name, machine_id, "/work/s20-evidence.json", evidence_path): + if retrieve( + fly, args.app_name, machine_id, "/work/s20-evidence.json", evidence_path + ): break time.sleep(5) else: @@ -284,10 +340,16 @@ def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: validate_evidence(evidence, journal, args.expected_sha) args.evidence_out.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n") args.journal_out.write_text(json.dumps(journal, indent=2, sort_keys=True) + "\n") - fly.run([ - "machine", "exec", machine_id, "--app", args.app_name, - "touch /work/controller-ack", - ]) + fly.run( + [ + "machine", + "exec", + machine_id, + "--app", + args.app_name, + "touch /work/controller-ack", + ] + ) finally: if app_created: destroy_and_verify(fly, args.app_name, machine_id, volume_id) @@ -329,11 +391,18 @@ def main() -> int: plan = { "mode": "execute" if args.execute else "dry-run", "checked_at": datetime.now(timezone.utc).isoformat(), - "pricing_source": PRICING_URL, "rates": rates, "cost": costs, - "git_sha": args.expected_sha, "image_digest": digest, "region": args.region, + "pricing_source": PRICING_URL, + "rates": rates, + "cost": costs, + "git_sha": args.expected_sha, + "image_digest": digest, + "region": args.region, "machine": {"cpu_kind": "performance", "cpus": CPUS, "memory_mb": MEMORY_MB}, - "volume_gb": VOLUME_GB, "public_services": 0, "restart": "no", - "auto_destroy": True, "hard_ttl_s": HARD_TTL_S, + "volume_gb": VOLUME_GB, + "public_services": 0, + "restart": "no", + "auto_destroy": True, + "hard_ttl_s": HARD_TTL_S, } print(json.dumps(plan, indent=2, sort_keys=True)) if args.execute: From b96ddf713dfeebc7909109f5d9e3b30977d38d08 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:50:50 -0600 Subject: [PATCH 04/16] fix: invalidate stale private adjacency publications --- crates/graphforge-exec/src/adjacency.rs | 186 +++++++++++++++++- .../graphforge-exec/tests/adjacency_expand.rs | 4 +- .../tests/persistent_adjacency.rs | 37 ++-- crates/graphforge-storage/src/adjacency.rs | 22 ++- .../graphforge-storage/src/adjacency_delta.rs | 12 +- 5 files changed, 232 insertions(+), 29 deletions(-) diff --git a/crates/graphforge-exec/src/adjacency.rs b/crates/graphforge-exec/src/adjacency.rs index 11b91ca3..0d54f461 100644 --- a/crates/graphforge-exec/src/adjacency.rs +++ b/crates/graphforge-exec/src/adjacency.rs @@ -607,6 +607,10 @@ enum IndexState { /// read — what [`PersistentAdjacencyProvider::revalidate`] compares /// against for cheap cross-query freshness (#832). generation: u64, + /// Cheap identity of the counter/topology namespace. This distinguishes + /// a fixture/import that replaces the graph and resets the counter to + /// the same numeric generation from the graph previously cached here. + source_stamp: SourceStamp, /// The manifest rows. rows: Vec, /// The delta chain (#765) overlaid on the base CSRs to reach @@ -616,6 +620,36 @@ enum IndexState { }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SourceStamp { + generation: u64, + counter_modified_ns: Option, + counter_len: Option, + topology_modified_ns: Option, +} + +fn modified_ns(metadata: &std::fs::Metadata) -> Option { + metadata + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_nanos()) +} + +fn source_stamp(project_dir: &std::path::Path) -> Result { + let generation = read_topology_generation(project_dir)?; + let counter = + std::fs::metadata(graphforge_storage::generation::generation_path(project_dir)).ok(); + let topology = std::fs::metadata(project_dir.join("topology")).ok(); + Ok(SourceStamp { + generation, + counter_modified_ns: counter.as_ref().and_then(modified_ns), + counter_len: counter.as_ref().map(std::fs::Metadata::len), + topology_modified_ns: topology.as_ref().and_then(modified_ns), + }) +} + /// Provider over the on-disk CSR index (#761): serves `Hit`s from /// `indexes/adjacency/` when the manifest generation matches the project's /// `topology_generation`, and lazily runs the bounded external-sort builder @@ -637,6 +671,10 @@ pub struct PersistentAdjacencyProvider { state: Mutex>, /// Loaded views per `(stem, direction)`. cache: Mutex>>, + /// Source identity of a private derived publication. Kept independently + /// from query state so a later `clear()` can reject it even when an + /// intervening write already invalidated the memoized state. + publication_stamp: Mutex>, } impl PersistentAdjacencyProvider { @@ -673,6 +711,7 @@ impl PersistentAdjacencyProvider { rebuild: Mutex::new(()), state: Mutex::new(None), cache: Mutex::new(HashMap::new()), + publication_stamp: Mutex::new(None), } } @@ -705,9 +744,10 @@ impl PersistentAdjacencyProvider { if !csr::adjacency_dir(artifact_dir).exists() { return IndexState::Absent; } - let Ok(generation) = read_topology_generation(source_dir) else { + let Ok(source_stamp) = source_stamp(source_dir) else { return IndexState::Unreadable; }; + let generation = source_stamp.generation; let Ok(rows) = csr::read_manifest(artifact_dir) else { return IndexState::Unreadable; }; @@ -744,6 +784,7 @@ impl PersistentAdjacencyProvider { IndexState::Ready { fresh, generation, + source_stamp, rows, deltas: Arc::new(deltas), } @@ -907,6 +948,7 @@ impl PersistentAdjacencyProvider { if let IndexState::Ready { fresh: true, generation, + source_stamp: _, rows, deltas, } = Self::read_state(&self.dir, &active_artifact) @@ -925,6 +967,12 @@ impl PersistentAdjacencyProvider { *self.state.lock().expect("adjacency state lock") = Some(IndexState::Ready { fresh: true, generation, + source_stamp: source_stamp(&self.dir).unwrap_or(SourceStamp { + generation, + counter_modified_ns: None, + counter_len: None, + topology_modified_ns: None, + }), rows, deltas, }); @@ -984,9 +1032,20 @@ impl PersistentAdjacencyProvider { *self.state.lock().expect("adjacency state lock") = Some(IndexState::Ready { fresh: true, generation: current_generation, + source_stamp: source_stamp(&self.dir).map_err(|error| { + GfError::Execution(format!( + "cannot fingerprint bounded adjacency build source: {error}" + )) + })?, rows, deltas: Arc::new(deltas), }); + if self.cache_dir != self.dir { + *self + .publication_stamp + .lock() + .expect("adjacency publication stamp lock") = source_stamp(&self.dir).ok(); + } if let Some(view) = view { return Ok(self.cache_view(&stem, direction, view)); } @@ -1003,8 +1062,46 @@ impl PersistentAdjacencyProvider { /// the generation), and read again would otherwise serve the pre-write /// view from cache. pub fn invalidate(&self) { - *self.state.lock().expect("adjacency state lock") = None; + let _rebuild = self.rebuild.lock().expect("adjacency rebuild lock"); + let mut state = self.state.lock().expect("adjacency state lock"); + let private_stamp = self + .publication_stamp + .lock() + .expect("adjacency publication stamp lock") + .as_ref() + .copied() + .or_else(|| match state.as_ref() { + Some(IndexState::Ready { source_stamp, .. }) => Some(*source_stamp), + _ => None, + }); + let discard_private_publication = self.cache_dir != self.dir + && match (private_stamp, source_stamp(&self.dir)) { + (Some(stored), Ok(current)) => { + current.generation < stored.generation + || (current.generation == stored.generation && current != stored) + } + // A private derived publication has no authority while its + // source identity is unreadable. In particular, `clear()` + // removes topology before the replacement graph recreates its + // counter; retaining the old CSR through that interval could + // make it appear fresh again at the same small generation. + (Some(_), Err(_)) => true, + (None, _) => false, + }; + *state = None; + drop(state); self.cache.lock().expect("adjacency cache lock").clear(); + if discard_private_publication { + // `GraphForge::clear` and fixture/import replacement can reset the + // counter. A private artifact from the previous graph must not be + // reconsidered fresh merely because the next graph reaches the + // same small generation number. + let _ = std::fs::remove_dir_all(csr::adjacency_dir(&self.cache_dir)); + *self + .publication_stamp + .lock() + .expect("adjacency publication stamp lock") = None; + } *self.artifact_dir.lock().expect("adjacency artifact lock") = if csr::adjacency_dir(&self.dir).exists() { self.dir.clone() @@ -1024,14 +1121,24 @@ impl PersistentAdjacencyProvider { /// (scan-built views are query-scoped), or the observed generation no /// longer matches the counter. pub fn revalidate(&self) { + let _rebuild = self.rebuild.lock().expect("adjacency rebuild lock"); let mut state = self.state.lock().expect("adjacency state lock"); + let mut replaced_at_same_generation = false; let drop_state = match state.as_ref() { None => false, Some(IndexState::Ready { fresh: true, generation, + source_stamp: stored_stamp, .. - }) => !read_topology_generation(&self.dir).is_ok_and(|g| g == *generation), + }) => match source_stamp(&self.dir) { + Ok(current) if current == *stored_stamp => false, + Ok(current) => { + replaced_at_same_generation = current.generation == *generation; + true + } + Err(_) => true, + }, // Non-serving states — always retry (a cheap manifest re-read, not // a rebuild). For a stale `fresh: false` index this matters: it may // have been repaired in place at the *same* topology generation (an @@ -1048,6 +1155,16 @@ impl PersistentAdjacencyProvider { if drop_state { *state = None; self.cache.lock().expect("adjacency cache lock").clear(); + if replaced_at_same_generation + && self.cache_dir != self.dir + && *self.artifact_dir.lock().expect("adjacency artifact lock") == self.cache_dir + { + // A pooled fixture/import may replace topology and reset its + // numeric counter. Its private derived cache has no authority + // across that source-identity transition; remove only that + // disposable publication so the next query rebuilds it. + let _ = std::fs::remove_dir_all(csr::adjacency_dir(&self.cache_dir)); + } } } @@ -1475,6 +1592,69 @@ mod tests { ); } + #[test] + fn private_cache_is_rebuilt_when_source_is_replaced_at_same_generation() { + let dir = TempDir::new().unwrap(); + let cache = TempDir::new().unwrap(); + let [old_a, ..] = write_diamond(dir.path()); + let provider = PersistentAdjacencyProvider::new_with_cache( + dir.path().to_path_buf(), + cache.path(), + OntologyMode::Strict, + ); + assert_eq!( + provider + .adjacency("KNOWS", Direction::Out) + .unwrap() + .neighbors(old_a) + .len(), + 3 + ); + let original_generation = read_topology_generation(dir.path()).unwrap(); + + // An ordinary forward write invalidates query state but deliberately + // retains the private base publication for its delta chain. The source + // stamp must survive that state reset so the subsequent graph clear is + // still distinguishable. + let mut forward = GraphWriter::open_at(dir.path(), OntologyMode::Strict, TS).unwrap(); + let extra = new_v7(); + forward.create_node(extra, TypeId(0)).unwrap(); + forward + .create_edge(new_v7(), "KNOWS", &extra, &extra) + .unwrap(); + forward.flush().unwrap(); + provider.invalidate(); + + std::fs::write( + graphforge_storage::generation::generation_path(dir.path()), + "not json", + ) + .unwrap(); + provider.invalidate(); + assert!( + !csr::adjacency_dir(&provider.cache_dir).exists(), + "the prior private publication is discarded while source identity is unreadable" + ); + std::fs::remove_dir_all(dir.path().join("topology")).unwrap(); + provider.invalidate(); + let mut writer = GraphWriter::open_at(dir.path(), OntologyMode::Strict, TS).unwrap(); + let (new_a, new_b) = (new_v7(), new_v7()); + let new_a_id = writer.create_node(new_a, TypeId(0)).unwrap(); + let new_b_id = writer.create_node(new_b, TypeId(0)).unwrap(); + writer + .create_edge(new_v7(), "KNOWS", &new_a, &new_b) + .unwrap(); + writer.flush().unwrap(); + assert_eq!( + read_topology_generation(dir.path()).unwrap(), + original_generation, + "fixture replacement deliberately reuses the numeric generation" + ); + + let rebuilt = provider.adjacency("KNOWS", Direction::Out).unwrap(); + assert_eq!(rebuilt.neighbors(new_a_id).to_vec(), vec![(1, new_b_id)]); + } + #[test] fn persistent_exact_relation_keys_do_not_collide_with_wildcard_or_paths() { let dir = TempDir::new().unwrap(); diff --git a/crates/graphforge-exec/tests/adjacency_expand.rs b/crates/graphforge-exec/tests/adjacency_expand.rs index c71df34a..c37ef7b2 100644 --- a/crates/graphforge-exec/tests/adjacency_expand.rs +++ b/crates/graphforge-exec/tests/adjacency_expand.rs @@ -118,8 +118,8 @@ async fn explain_shows_stable_expand_exec_across_index_states() { .await .unwrap(); assert!( - without.contains("ExpandExec") && without.contains("adjacency=building"), - "no index: scan-build ExpandExec expected, got:\n{without}" + without.contains("ExpandExec") && without.contains("adjacency=hit"), + "no index: bounded lazy build must be reflected as a hit, got:\n{without}" ); build_adjacency_index(dir.path(), TS).unwrap(); diff --git a/crates/graphforge-exec/tests/persistent_adjacency.rs b/crates/graphforge-exec/tests/persistent_adjacency.rs index c027b560..094e0f9a 100644 --- a/crates/graphforge-exec/tests/persistent_adjacency.rs +++ b/crates/graphforge-exec/tests/persistent_adjacency.rs @@ -142,7 +142,7 @@ fn absent_capability_dir_is_building_and_scan_builds() { } #[test] -fn absent_index_scan_build_is_cached_across_stream_batches() { +fn lazily_built_private_csr_is_reused_across_queries() { let dir = TempDir::new().unwrap(); write_diamond(dir.path()); let provider = persistent(dir.path(), OntologyMode::Strict); @@ -151,13 +151,13 @@ fn absent_index_scan_build_is_cached_across_stream_batches() { let second = provider.adjacency("KNOWS", Direction::Out).unwrap(); assert!( Arc::ptr_eq(&first, &second), - "scan-build must be reused instead of rescanning per input batch" + "bounded CSR must be reused instead of rebuilding per input batch" ); provider.revalidate(); let next_query = provider.adjacency("KNOWS", Direction::Out).unwrap(); assert!( - !Arc::ptr_eq(&first, &next_query), - "an absent-index cache must not survive the next query" + Arc::ptr_eq(&first, &next_query), + "a generation-bound private CSR should survive the next query" ); } @@ -207,11 +207,11 @@ fn fresh_index_with_unknown_rel_scan_builds_without_rebuild() { } // --------------------------------------------------------------------------- -// Corrupt artifacts degrade, never fail +// Corrupt source identity fails closed // --------------------------------------------------------------------------- #[test] -fn corrupt_generation_counter_is_miss_without_rebuild() { +fn corrupt_generation_counter_returns_typed_bounded_build_failure() { let dir = TempDir::new().unwrap(); write_diamond(dir.path()); build_adjacency_index(dir.path(), TS).unwrap(); @@ -226,13 +226,14 @@ fn corrupt_generation_counter_is_miss_without_rebuild() { provider.status("KNOWS", Direction::Out), AdjacencyStatus::Miss ); - // Scan-build fallback still serves correct results; no rebuild was - // attempted (stamping a manifest requires a readable counter). - assert_eq!( - provider.adjacency("KNOWS", Direction::Out).unwrap(), - scan(dir.path(), OntologyMode::Strict) - .adjacency("KNOWS", Direction::Out) - .unwrap() + let error = provider + .adjacency("KNOWS", Direction::Out) + .expect_err("an unreadable source generation cannot safely stamp a rebuilt CSR"); + assert!( + matches!(error, graphforge_core::GfError::Execution(ref message) + if message.contains("bounded adjacency index build failed") + && message.contains("generation.json")), + "unexpected typed failure: {error:?}" ); } @@ -586,8 +587,8 @@ async fn zero_hop_traversal_on_hit_never_opens_edge_files() { // --------------------------------------------------------------------------- /// A warm shared provider serves repeat queries from its view cache: after -/// the first load, even deleting every index file does not affect the second -/// query (nothing is re-read). +/// the first load, deleting the authoritative index files does not affect the +/// second query (nothing is re-read while source identity is unchanged). #[test] fn shared_provider_serves_second_query_from_cache() { let dir = TempDir::new().unwrap(); @@ -597,9 +598,11 @@ fn shared_provider_serves_second_query_from_cache() { let provider = persistent(dir.path(), OntologyMode::Strict); let first = provider.adjacency("KNOWS", Direction::Out).unwrap(); - // Remove the whole index AND the edge files; cache must still serve. + // Remove the whole index; cache must still serve. Removing topology files + // without advancing the generation is a source-identity transition and is + // deliberately detected by revalidate, so it is not part of this cache + // amortization contract. std::fs::remove_dir_all(dir.path().join("indexes")).unwrap(); - std::fs::remove_dir_all(dir.path().join("topology").join("edges")).unwrap(); let second = provider.adjacency("KNOWS", Direction::Out).unwrap(); assert_eq!(first, second); diff --git a/crates/graphforge-storage/src/adjacency.rs b/crates/graphforge-storage/src/adjacency.rs index 4f627d10..f69576a9 100644 --- a/crates/graphforge-storage/src/adjacency.rs +++ b/crates/graphforge-storage/src/adjacency.rs @@ -2144,6 +2144,11 @@ pub fn validate_adjacency_index_against( let (groups, union_out) = collect_adjacency_groups(source_project_dir)?; for row in &manifest { + let relation_label = row + .relation_name + .as_deref() + .unwrap_or(&row.relation_type) + .to_owned(); let expected_entries: &[BuildEntry] = if row.relation_type == ALL_RELATIONS_STEM { &union_out } else { @@ -2155,7 +2160,7 @@ pub fn validate_adjacency_index_against( let path = csr_path(artifact_project_dir, &row.relation_type, row.direction); if !csr_artifact_exists(&path) { issues.push(AdjacencyValidationIssue::MissingCsr { - rel: row.relation_type.clone(), + rel: relation_label.clone(), direction: row.direction, }); continue; @@ -2176,13 +2181,13 @@ pub fn validate_adjacency_index_against( }; if actual != expected { issues.push(AdjacencyValidationIssue::Mismatch { - rel: row.relation_type.clone(), + rel: relation_label.clone(), direction: row.direction, }); } } Err(e) => issues.push(AdjacencyValidationIssue::UnreadableCsr { - rel: row.relation_type.clone(), + rel: relation_label, direction: row.direction, error: e.to_string(), }), @@ -3014,7 +3019,11 @@ mod tests { #[test] fn csr_path_layout() { let p = csr_path(Path::new("/proj"), "WORKS_AT", Direction::In); - assert_eq!(p, Path::new("/proj/indexes/adjacency/WORKS_AT.in.csr")); + let key = adjacency_relation_key("WORKS_AT"); + assert_eq!( + p, + Path::new("/proj/indexes/adjacency").join(format!("{key}.in.csr")) + ); assert_eq!( manifest_path(Path::new("/proj")), Path::new("/proj/indexes/adjacency/index_manifest.parquet") @@ -3752,8 +3761,9 @@ mod tests { let dir = TempDir::new().unwrap(); write_diamond(dir.path()); let (groups, union) = collect_adjacency_groups(dir.path()).unwrap(); - let expected_knows_out = csr_from_entries(groups.get("KNOWS").unwrap(), Direction::Out); - let expected_knows_in = csr_from_entries(groups.get("KNOWS").unwrap(), Direction::In); + let knows_key = adjacency_relation_key("KNOWS"); + let expected_knows_out = csr_from_entries(groups.get(&knows_key).unwrap(), Direction::Out); + let expected_knows_in = csr_from_entries(groups.get(&knows_key).unwrap(), Direction::In); let expected_all_out = csr_from_entries(&union, Direction::Out); let expected_all_in = csr_from_entries(&union, Direction::In); diff --git a/crates/graphforge-storage/src/adjacency_delta.rs b/crates/graphforge-storage/src/adjacency_delta.rs index 58f3eb87..7baf2aaa 100644 --- a/crates/graphforge-storage/src/adjacency_delta.rs +++ b/crates/graphforge-storage/src/adjacency_delta.rs @@ -31,8 +31,16 @@ use graphforge_core::GfError; use crate::adjacency::{ ALL_RELATIONS_STEM, BuildEntry, CsrIndex, CsrRow, Direction, adjacency_dir, - adjacency_relation_key, csr_from_entries, + adjacency_relation_key, csr_from_entries, is_adjacency_relation_key, }; + +fn normalized_relation_stem(stem: &str) -> std::borrow::Cow<'_, str> { + if stem == ALL_RELATIONS_STEM || is_adjacency_relation_key(stem) { + std::borrow::Cow::Borrowed(stem) + } else { + std::borrow::Cow::Owned(adjacency_relation_key(stem)) + } +} use crate::schemas::ADJACENCY_DELTA_SCHEMA; use crate::staging::RewriteBatch; @@ -253,6 +261,7 @@ pub fn apply_delta_segments( chain: &[DeltaSegment], ) -> CsrIndex { let mut entries: Vec = base_entries(base, direction); + let stem = normalized_relation_stem(stem); let take_all = stem == ALL_RELATIONS_STEM; for seg in chain { for e in &seg.edges { @@ -344,6 +353,7 @@ pub fn overlay_delta_segments( direction: Direction, chain: &[DeltaSegment], ) -> CsrDeltaOverlay { + let stem = normalized_relation_stem(stem); let take_all = stem == ALL_RELATIONS_STEM; let mut delta_by_key: HashMap> = HashMap::new(); let mut max_key = 0_u64; From bef2d3cc82b38987da49bbbbe919b147121c3bb5 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:21:53 -0600 Subject: [PATCH 05/16] fix: keep derived cache outside project authority --- crates/graphforge-api/src/lib.rs | 53 +++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/graphforge-api/src/lib.rs b/crates/graphforge-api/src/lib.rs index 2ce58300..22d11b29 100644 --- a/crates/graphforge-api/src/lib.rs +++ b/crates/graphforge-api/src/lib.rs @@ -738,7 +738,10 @@ impl GraphForge { // or alongside explicitly configured spill. It must not use OS /tmp: // billion-edge qualification is disk-bound and the container root may // have a much smaller ephemeral capacity than the data volume. - let adjacency_cache = Arc::new(create_adjacency_cache(&container_dir, &resource_policy)?); + let adjacency_cache = Arc::new(create_persistent_adjacency_cache( + &container_dir, + &resource_policy, + )?); let runtime_catalog = load_runtime_catalog(&dir)?; let semantic_storage_bindings = @@ -3942,6 +3945,27 @@ fn create_adjacency_cache( .map_err(|error| GfError::Storage(format!("failed to create adjacency cache: {error}"))) } +fn create_persistent_adjacency_cache( + project_root: &std::path::Path, + policy: &resource_policy::NormalizedResourcePolicy, +) -> Result { + // A facade open must not add runtime state to the project namespace. In + // particular, failed imports authenticate that the target entry set is + // unchanged even while the reopened facade is alive. Durable project + // admission already fail-closes unless the project and its parent have the + // same native volume identity, so a sibling remains on the admitted data + // volume without becoming project or generation state. Explicit spill + // configuration continues to override this default in + // `create_adjacency_cache`. + let data_volume_root = project_root + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| { + GfError::Storage("persistent project root has no admitted parent volume".to_owned()) + })?; + create_adjacency_cache(data_volume_root, policy) +} + fn hydrate_graph_workspace( generation: &ResolvedProjectGeneration, read_only: bool, @@ -4576,6 +4600,33 @@ mod tests { assert!(!spill_path.exists()); } + #[test] + fn persistent_open_keeps_derived_adjacency_cache_outside_project_namespace() { + let parent = tempfile::tempdir().unwrap(); + let project = parent.path().join("project"); + drop(GraphForge::new(Some(project.to_str().unwrap())).unwrap()); + let before = std::fs::read_dir(&project) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + let graph = GraphForge::new(Some(project.to_str().unwrap())).unwrap(); + + let entries = std::fs::read_dir(&project) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert!( + !entries + .iter() + .any(|name| { name.to_string_lossy().starts_with(".graphforge-adjacency-") }) + ); + assert_eq!(entries, before); + let cache_path = graph.adjacency_cache_guard.path().to_path_buf(); + assert_eq!(cache_path.parent(), Some(parent.path())); + drop(graph); + assert!(!cache_path.exists()); + } + fn spawn_absent_target_child(parent: &Path, child_id: &str) -> Child { Command::new(std::env::current_exe().expect("absent-target current test executable")) .args(["--exact", ABSENT_TARGET_CHILD, "--nocapture"]) From 6bd6c5c7e557cee8e676bf473f9e999a3b38b30e Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:25:34 -0600 Subject: [PATCH 06/16] ci: refresh combined public surface inventory --- crates/graphforge-bindings-py/tests/non_cypher_release.py | 6 +++--- scripts/ci/test-non-cypher-surface-gate.py | 2 +- tests/contracts/non-cypher-rust-surface.json | 7 ++++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/graphforge-bindings-py/tests/non_cypher_release.py b/crates/graphforge-bindings-py/tests/non_cypher_release.py index d50a5abd..414000c7 100644 --- a/crates/graphforge-bindings-py/tests/non_cypher_release.py +++ b/crates/graphforge-bindings-py/tests/non_cypher_release.py @@ -23,8 +23,8 @@ RUST_MANIFEST = ROOT / "tests/contracts/non-cypher-rust-surface.json" RUST_GATE = ROOT / "scripts/ci/non-cypher-surface-gate.py" PYO3_SOURCE = ROOT / "crates/graphforge-bindings-py/src/lib.rs" -EXPECTED_RUST_DIGEST = "2d86746c592716a79fe12cb5471f1c9b90ce8e91bfca772b19110cdfafa468d1" -EXPECTED_RELEASE_DIGEST = "fa31f90944981d9e850cb115e70beb0c987ba36cedd71c681a51b122d59954fb" +EXPECTED_RUST_DIGEST = "261f99ebab8e9d69a3a3607705d96fd4ad9e00f5102b5062440fa4bcd07ebc44" +EXPECTED_RELEASE_DIGEST = "ddbc785e294fbbb2869fcc5721b2cde49bf7cfe1b59370122a6ae1079e257dd1" PYTHON_ONLY_METHODS = frozenset( { @@ -254,7 +254,7 @@ def _classification_report() -> dict[str, object]: for group in manifest["method_evidence_groups"].values() for method_id in group["ids"] } - assert len(release_methods) == 247 + assert len(release_methods) == 249 assert _digest(release_methods) == EXPECTED_RELEASE_DIGEST assert set(EVIDENCE) == set(manifest["method_evidence_groups"]) diff --git a/scripts/ci/test-non-cypher-surface-gate.py b/scripts/ci/test-non-cypher-surface-gate.py index 916b8f09..8a9a6f61 100644 --- a/scripts/ci/test-non-cypher-surface-gate.py +++ b/scripts/ci/test-non-cypher-surface-gate.py @@ -29,7 +29,7 @@ def validate(self, manifest: dict) -> list[str]: def test_checked_in_inventory_is_complete(self) -> None: self.assertEqual(GATE.validate(), []) - self.assertEqual(len(GATE.public_methods()), 362) + self.assertEqual(len(GATE.public_methods()), 367) self.assertEqual(len(GATE.algorithm_registry()), 94) def test_new_or_removed_public_method_fails_frozen_digest(self) -> None: diff --git a/tests/contracts/non-cypher-rust-surface.json b/tests/contracts/non-cypher-rust-surface.json index d1eab72e..428a321b 100644 --- a/tests/contracts/non-cypher-rust-surface.json +++ b/tests/contracts/non-cypher-rust-surface.json @@ -1,7 +1,7 @@ { "contract_version": 1, "scope": "Rust non-Cypher public release surface", - "public_method_digest": "2d86746c592716a79fe12cb5471f1c9b90ce8e91bfca772b19110cdfafa468d1", + "public_method_digest": "261f99ebab8e9d69a3a3607705d96fd4ad9e00f5102b5062440fa4bcd07ebc44", "method_policy": { "receiver_defaults": { "GraphForge": "release-tested", @@ -43,6 +43,9 @@ "crate.composite_receipt_schema": "introspection", "crate.grade_gsi": "introspection", "crate.hold_writer": "internal-helper", + "crate.parse_hub_manifest": "designed-only", + "crate.parse_hub_refs": "designed-only", + "crate.parse_hub_repository_identity": "designed-only", "crate.verify_portable_v2": "designed-only", "GraphForge.new": "introspection", "GraphForge.new_with_options": "introspection", @@ -773,6 +776,7 @@ "ids": [ "GraphForge.clear", "GraphForge.execute", + "GraphForge.execute_observed", "GraphForge.execute_with_composition", "GraphForge.execute_stream", "GraphForge.execute_stream_owned", @@ -782,6 +786,7 @@ "GraphForge.execute_to_parquet_stream_with_params", "GraphForge.execute_to_parquet_with_params", "GraphForge.execute_with_params", + "GraphForge.execute_with_params_observed", "GraphForge.explain", "GraphForge.register_procedure", "GraphForge.runtime_catalog", From 7f9091b1798cdc4183e2bc8d9f7acf8e2a7d46df Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:52:48 -0600 Subject: [PATCH 07/16] fix(exec): bound and observe rooted scale probes (#904) --- .../graphforge-api/tests/fixed_hop_limit.rs | 54 ++++++++ .../graphforge-api/tests/scale_g500_ladder.rs | 91 +++++++++---- .../tests/non_cypher_release.py | 2 +- crates/graphforge-exec/src/demand.rs | 120 +++++++++++++++--- crates/graphforge-exec/src/lib.rs | 35 +++-- docs/development/perf-g500-ladder.md | 22 ++++ scripts/ci/test-fly-g500-s20.py | 18 +++ scripts/ci/test-non-cypher-surface-gate.py | 2 +- scripts/fly-g500-s20.py | 110 +++++++++++++++- tests/contracts/non-cypher-rust-surface.json | 5 +- 10 files changed, 397 insertions(+), 62 deletions(-) diff --git a/crates/graphforge-api/tests/fixed_hop_limit.rs b/crates/graphforge-api/tests/fixed_hop_limit.rs index b316854e..6055f58c 100644 --- a/crates/graphforge-api/tests/fixed_hop_limit.rs +++ b/crates/graphforge-api/tests/fixed_hop_limit.rs @@ -346,6 +346,60 @@ fn scattered_node_hydration_is_neighborhood_proportional() { ); } +#[test] +fn parameterized_rooted_ordered_hops_retain_query_scoped_evidence() { + let _guard = IO_GUARD.lock().unwrap(); + let dir = TempDir::new().unwrap(); + generate_scattered_destinations(dir.path(), 4_096, 4, 64); + let forge = open_forge(dir.path()); + let params = HashMap::from([( + "root".to_owned(), + IrLiteral::Uuid(*stable_fixture_uuid(1, 0).as_bytes()), + )]); + + for (query, expected_hops) in [ + ( + "MATCH (a)-[r]->(b) WHERE a.node_uuid = $root \ + RETURN b.node_uuid AS id ORDER BY id LIMIT 1000", + 1, + ), + ( + "MATCH (a)-[r1]->(b)-[r2]->(c) WHERE a.node_uuid = $root \ + RETURN c.node_uuid AS id ORDER BY id LIMIT 1000", + 2, + ), + ] { + let observed = forge.execute_with_params_observed(query, ¶ms); + let result = observed.result.unwrap(); + assert!(result.stats.rows_produced > 0, "{query}"); + assert_eq!(observed.evidence.hops.len(), expected_hops, "{query}"); + assert_eq!( + observed.evidence.operator_rss.expand_by_hop.len(), + expected_hops, + "{query}" + ); + assert_eq!( + observed + .evidence + .hops + .values() + .map(|hop| hop.input_rows) + .min(), + Some(1), + "the first expansion must receive only the selected root: {query}" + ); + for hop in observed.evidence.hops.values() { + assert!(hop.input_rows > 0, "{query}: {:#?}", observed.evidence); + assert!( + hop.candidates_generated > 0, + "{query}: {:#?}", + observed.evidence + ); + assert!(hop.rows_emitted > 0, "{query}: {:#?}", observed.evidence); + } + } +} + #[test] fn limits_sweep_bounded_multi_hop_work_and_repartition() { let _guard = IO_GUARD.lock().unwrap(); diff --git a/crates/graphforge-api/tests/scale_g500_ladder.rs b/crates/graphforge-api/tests/scale_g500_ladder.rs index 4f31ceac..18a20049 100644 --- a/crates/graphforge-api/tests/scale_g500_ladder.rs +++ b/crates/graphforge-api/tests/scale_g500_ladder.rs @@ -17,7 +17,7 @@ //! large rungs are opt-in via `make bench-g500-ladder`. use std::cmp::Reverse; -use std::collections::BinaryHeap; +use std::collections::{BinaryHeap, HashMap}; use std::fs::{self, File}; use std::io::{BufReader, BufWriter, ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; @@ -30,9 +30,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use arrow::array::{Array, FixedSizeBinaryArray, Int64Array, StringArray, UInt64Array}; use arrow::record_batch::RecordBatch; use graphforge_api::{ - CancellationToken, GraphForge, OperationId, PortableSelection, PortableV2ExportRequest, - PortableV2ImportRequest, PortableV2Limits, PortableV2Mode, PortableV2Output, - PortableV2SelectionProfile, PortableVerifyRequest, bulk_edge_input_schema, + CancellationToken, GraphForge, IrLiteral, ObservedExecution, OperationId, PortableSelection, + PortableV2ExportRequest, PortableV2ImportRequest, PortableV2Limits, PortableV2Mode, + PortableV2Output, PortableV2SelectionProfile, PortableVerifyRequest, bulk_edge_input_schema, bulk_node_input_schema, verify_portable_v2, }; use graphforge_core::uuid::Uuid; @@ -53,14 +53,26 @@ const REL_TYPE: &str = "LINK"; const BATCH_ROWS: usize = 8_192; const EDGE_PUBLISH_ROWS: usize = 1_048_576; -const ONE_HOP: &str = "MATCH (a)-[r]->(b) RETURN b.node_uuid AS id ORDER BY id LIMIT 1000"; -const TWO_HOP: &str = - "MATCH (a)-[r1]->(b)-[r2]->(c) RETURN c.node_uuid AS id ORDER BY id LIMIT 1000"; +const ONE_HOP: &str = + "MATCH (a)-[r]->(b) WHERE a.node_uuid = $root RETURN b.node_uuid AS id ORDER BY id LIMIT 1000"; +const TWO_HOP: &str = "MATCH (a)-[r1]->(b)-[r2]->(c) WHERE a.node_uuid = $root RETURN c.node_uuid AS id ORDER BY id LIMIT 1000"; const COUNT_EDGES: &str = "MATCH ()-[r:LINK]->() RETURN count(r) AS total"; static JOURNAL_WRITE_SEQUENCE: AtomicU64 = AtomicU64::new(0); static INGEST_SUBPHASE: AtomicU64 = AtomicU64::new(0); static INGEST_CHUNK_INDEX: AtomicU64 = AtomicU64::new(0); +/// Execute the scale proof from the deterministic node representing Graph500 +/// vertex fifteen. Its four set bits provide a stable, non-empty S10 probe +/// without selecting the generator's pathological highest-degree vertex. A +/// global two-hop ordered LIMIT still has to enumerate every +/// two-hop path before it can know the exact TopK; anchoring the traversal +/// keeps the proof proportional to the selected neighborhood while preserving +/// deterministic source/import comparison and ordinary facade execution. +fn execute_fixed_hop_observed(graph: &GraphForge, query: &str) -> ObservedExecution { + let params = HashMap::from([("root".to_owned(), IrLiteral::Uuid(*uuidv7(16).as_bytes()))]); + graph.execute_with_params_observed(query, ¶ms) +} + fn query_operator_evidence(snapshot: &demand::DemandSnapshot, memory_budget_bytes: u64) -> Value { let lifetime = |rss: &demand::RssLifetimeSnapshot| { let working_set_bytes = rss.peak_bytes.saturating_sub(rss.before_bytes); @@ -1149,7 +1161,7 @@ fn run_rung( None, ); let hop1_started = Instant::now(); - let hop1 = graph.execute_observed(ONE_HOP); + let hop1 = execute_fixed_hop_observed(&graph, ONE_HOP); let memory_budget = graph.resource_policy().memory_budget_bytes; query_memory_budget_bytes = memory_budget; let hop1_operators = query_operator_evidence(&hop1.evidence, memory_budget); @@ -1159,7 +1171,7 @@ fn run_rung( if hop1_failure.is_some() { violation = Some("execution_failure"); } - if hop1_rows != 1_000 { + if !(1..=1_000).contains(&hop1_rows) { violation = Some("result_mismatch"); } if !operator_rss_within_budgets(&hop1.evidence, 1, memory_budget, env.rss_bytes) { @@ -1203,7 +1215,7 @@ fn run_rung( None, ); let hop2_started = Instant::now(); - let hop2 = graph.execute_observed(TWO_HOP); + let hop2 = execute_fixed_hop_observed(&graph, TWO_HOP); let memory_budget = graph.resource_policy().memory_budget_bytes; query_memory_budget_bytes = memory_budget; let hop2_operators = query_operator_evidence(&hop2.evidence, memory_budget); @@ -1213,7 +1225,7 @@ fn run_rung( if hop2_failure.is_some() { violation = Some("execution_failure"); } - if hop2_rows != 1_000 { + if !(1..=1_000).contains(&hop2_rows) { violation = Some("result_mismatch"); } if !operator_rss_within_budgets(&hop2.evidence, 2, memory_budget, env.rss_bytes) { @@ -1807,6 +1819,35 @@ fn bounded_generation_reconciles_counts() { ); } +#[test] +fn fixed_hop_probe_root_is_nonempty_at_ci_scale() { + let init = Initiator { + a: 0.57, + b: 0.19, + c: 0.19, + d: 0.05, + }; + let (edges, _, _) = reference_generation(10, 16, init, 1); + let out_degrees = edges.iter().fold(HashMap::new(), |mut degrees, (src, _)| { + *degrees.entry(*src).or_insert(0usize) += 1; + degrees + }); + let one_hop = edges.iter().filter(|(src, _)| *src == 15).count(); + let two_hop = edges + .iter() + .filter(|(src, _)| *src == 15) + .map(|(_, dst)| out_degrees.get(dst).copied().unwrap_or(0)) + .sum::(); + assert_eq!( + one_hop, 13, + "the pinned root must exercise one-hop traversal" + ); + assert_eq!( + two_hop, 786, + "the pinned root must exercise two-hop traversal" + ); +} + /// Raw attempts can never be reported as live persisted edges. #[test] fn raw_attempts_exceed_live_edges() { @@ -2652,9 +2693,10 @@ fn run_integrated_certification(root: &Path, run: IntegratedRun<'_>) -> Value { assert_eq!(source_edges, expected_live_edges); journal.pass("source_reopen", phase, None); let phase = Instant::now(); - let source_1hop_observed = graph.execute_observed(ONE_HOP); - let source_1hop = - result_fingerprint(source_1hop_observed.result.as_ref().expect("source 1hop")); + let source_1hop_observed = execute_fixed_hop_observed(&graph, ONE_HOP); + let source_1hop_result = source_1hop_observed.result.as_ref().expect("source 1hop"); + assert!((1..=1_000).contains(&row_count(source_1hop_result))); + let source_1hop = result_fingerprint(source_1hop_result); journal.pass_with_evidence( "source_query_1hop", phase, @@ -2665,9 +2707,10 @@ fn run_integrated_certification(root: &Path, run: IntegratedRun<'_>) -> Value { ), ); let phase = Instant::now(); - let source_2hop_observed = graph.execute_observed(TWO_HOP); - let source_2hop = - result_fingerprint(source_2hop_observed.result.as_ref().expect("source 2hop")); + let source_2hop_observed = execute_fixed_hop_observed(&graph, TWO_HOP); + let source_2hop_result = source_2hop_observed.result.as_ref().expect("source 2hop"); + assert!((1..=1_000).contains(&row_count(source_2hop_result))); + let source_2hop = result_fingerprint(source_2hop_result); let source_authority_fingerprint = authority_fingerprint(&graph); let source_generation = current_generation_uuid(&graph); journal.pass_with_evidence( @@ -2741,9 +2784,10 @@ fn run_integrated_certification(root: &Path, run: IntegratedRun<'_>) -> Value { ); journal.pass("imported_reopen", phase, None); let phase = Instant::now(); - let imported_1hop_observed = imported_graph.execute_observed(ONE_HOP); - let imported_1hop = - result_fingerprint(imported_1hop_observed.result.as_ref().expect("import 1hop")); + let imported_1hop_observed = execute_fixed_hop_observed(&imported_graph, ONE_HOP); + let imported_1hop_result = imported_1hop_observed.result.as_ref().expect("import 1hop"); + assert!((1..=1_000).contains(&row_count(imported_1hop_result))); + let imported_1hop = result_fingerprint(imported_1hop_result); assert_eq!(source_1hop, imported_1hop); journal.pass_with_evidence( "imported_query_1hop", @@ -2755,9 +2799,10 @@ fn run_integrated_certification(root: &Path, run: IntegratedRun<'_>) -> Value { ), ); let phase = Instant::now(); - let imported_2hop_observed = imported_graph.execute_observed(TWO_HOP); - let imported_2hop = - result_fingerprint(imported_2hop_observed.result.as_ref().expect("import 2hop")); + let imported_2hop_observed = execute_fixed_hop_observed(&imported_graph, TWO_HOP); + let imported_2hop_result = imported_2hop_observed.result.as_ref().expect("import 2hop"); + assert!((1..=1_000).contains(&row_count(imported_2hop_result))); + let imported_2hop = result_fingerprint(imported_2hop_result); let imported_authority_fingerprint = authority_fingerprint(&imported_graph); assert_eq!( current_generation_uuid(&imported_graph), diff --git a/crates/graphforge-bindings-py/tests/non_cypher_release.py b/crates/graphforge-bindings-py/tests/non_cypher_release.py index 414000c7..c2a0e7a6 100644 --- a/crates/graphforge-bindings-py/tests/non_cypher_release.py +++ b/crates/graphforge-bindings-py/tests/non_cypher_release.py @@ -23,7 +23,7 @@ RUST_MANIFEST = ROOT / "tests/contracts/non-cypher-rust-surface.json" RUST_GATE = ROOT / "scripts/ci/non-cypher-surface-gate.py" PYO3_SOURCE = ROOT / "crates/graphforge-bindings-py/src/lib.rs" -EXPECTED_RUST_DIGEST = "261f99ebab8e9d69a3a3607705d96fd4ad9e00f5102b5062440fa4bcd07ebc44" +EXPECTED_RUST_DIGEST = "00c669a797e086500484d09351f13424739c365cd54e283baf48f1126ca192e6" EXPECTED_RELEASE_DIGEST = "ddbc785e294fbbb2869fcc5721b2cde49bf7cfe1b59370122a6ae1079e257dd1" PYTHON_ONLY_METHODS = frozenset( diff --git a/crates/graphforge-exec/src/demand.rs b/crates/graphforge-exec/src/demand.rs index 5e2e6fef..907017b5 100644 --- a/crates/graphforge-exec/src/demand.rs +++ b/crates/graphforge-exec/src/demand.rs @@ -283,6 +283,25 @@ fn with_capture(update: impl FnOnce(&QueryCapture)) { let _ = ACTIVE_CAPTURE.try_with(|capture| update(capture)); } +/// Explicit query-capture context for physical operators whose streams may be +/// polled by DataFusion tasks that do not inherit Tokio task locals. +#[derive(Clone)] +pub(crate) struct CaptureHandle(Arc); + +pub(crate) fn capture_handle() -> Option { + ACTIVE_CAPTURE + .try_with(|capture| CaptureHandle(Arc::clone(capture))) + .ok() +} + +fn with_handle(handle: Option<&CaptureHandle>, update: impl FnOnce(&QueryCapture)) { + if let Some(handle) = handle { + update(&handle.0); + } else { + with_capture(update); + } +} + pub(crate) fn capture_enabled() -> bool { ACTIVE_CAPTURE.try_with(|_| ()).is_ok() } @@ -349,11 +368,17 @@ impl OperatorActivity { pub(crate) fn expand(edge_var: u32) -> Self { Self::new(OperatorKind::Expand(edge_var)) } + pub(crate) fn expand_with_capture(edge_var: u32, capture: Option) -> Self { + Self::new_with_capture(OperatorKind::Expand(edge_var), capture) + } fn sort() -> Self { Self::new(OperatorKind::Sort) } fn new(kind: OperatorKind) -> Self { - let capture = ACTIVE_CAPTURE.try_with(Arc::clone).ok(); + Self::new_with_capture(kind, capture_handle()) + } + fn new_with_capture(kind: OperatorKind, capture: Option) -> Self { + let capture = capture.map(|handle| handle.0); if let Some(capture) = &capture { match kind { OperatorKind::Expand(_) => &capture.expand_active, @@ -480,7 +505,15 @@ pub(crate) fn record_plan_after( } fn with_hop(edge_var: u32, update: impl FnOnce(&mut HopSnapshot)) { - with_capture(|capture| { + with_hop_handle(None, edge_var, update); +} + +fn with_hop_handle( + handle: Option<&CaptureHandle>, + edge_var: u32, + update: impl FnOnce(&mut HopSnapshot), +) { + with_handle(handle, |capture| { update( capture .snapshot @@ -494,18 +527,36 @@ fn with_hop(edge_var: u32, update: impl FnOnce(&mut HopSnapshot)) { } pub(crate) fn record_input(edge_var: u32, rows: usize) { - with_hop(edge_var, |hop| { + record_input_with_capture(None, edge_var, rows); +} + +pub(crate) fn record_input_with_capture( + capture: Option<&CaptureHandle>, + edge_var: u32, + rows: usize, +) { + with_hop_handle(capture, edge_var, |hop| { hop.input_batches += 1; hop.input_rows += rows as u64; }); } -pub(crate) fn record_candidates(edge_var: u32, rows: usize) { - with_hop(edge_var, |hop| hop.candidates_generated += rows as u64); +pub(crate) fn record_candidates_with_capture( + capture: Option<&CaptureHandle>, + edge_var: u32, + rows: usize, +) { + with_hop_handle(capture, edge_var, |hop| { + hop.candidates_generated += rows as u64 + }); } -pub(crate) fn record_emitted(edge_var: u32, rows: usize) { - with_hop(edge_var, |hop| hop.rows_emitted += rows as u64); +pub(crate) fn record_emitted_with_capture( + capture: Option<&CaptureHandle>, + edge_var: u32, + rows: usize, +) { + with_hop_handle(capture, edge_var, |hop| hop.rows_emitted += rows as u64); } fn record_filter(ordinal: usize, uniqueness: bool, input: bool, rows: usize) { @@ -531,6 +582,7 @@ pub(crate) struct QueryDemand { max_in_flight_reads: AtomicUsize, produced_rows: AtomicUsize, quiescent_waker: AtomicWaker, + capture: Option, } impl QueryDemand { @@ -541,6 +593,7 @@ impl QueryDemand { max_in_flight_reads: AtomicUsize::new(0), produced_rows: AtomicUsize::new(0), quiescent_waker: AtomicWaker::new(), + capture: capture_handle(), } } @@ -549,8 +602,8 @@ impl QueryDemand { } fn cancel(&self) { - if !self.cancelled.swap(true, Ordering::AcqRel) && capture_enabled() { - with_capture(|capture| { + if !self.cancelled.swap(true, Ordering::AcqRel) && self.capture.is_some() { + with_handle(self.capture.as_ref(), |capture| { capture .snapshot .lock() @@ -567,21 +620,25 @@ impl QueryDemand { pub(crate) fn begin_read(self: &Arc, edge_var: u32) -> Option { if self.is_cancelled() { - with_hop(edge_var, |hop| hop.reads_after_cancel += 1); + with_hop_handle(self.capture.as_ref(), edge_var, |hop| { + hop.reads_after_cancel += 1 + }); return None; } let current = self.in_flight_reads.fetch_add(1, Ordering::AcqRel) + 1; self.max_in_flight_reads .fetch_max(current, Ordering::AcqRel); - if capture_enabled() { - with_capture(|capture| { + if self.capture.is_some() { + with_handle(self.capture.as_ref(), |capture| { let mut snapshot = capture.snapshot.lock().expect("query capture lock"); snapshot.max_in_flight_reads = snapshot.max_in_flight_reads.max(current as u64); }); } if self.is_cancelled() { self.finish_read(); - with_hop(edge_var, |hop| hop.reads_after_cancel += 1); + with_hop_handle(self.capture.as_ref(), edge_var, |hop| { + hop.reads_after_cancel += 1 + }); return None; } Some(ReadPermit { @@ -621,24 +678,25 @@ impl Drop for ReadPermit { /// Attributes storage observer events to one fixed-hop edge binding. pub(crate) struct HopReadObserver { edge_var: u32, + capture: Option, } impl HopReadObserver { - pub(crate) fn new(edge_var: u32) -> Self { - Self { edge_var } + pub(crate) fn with_capture(edge_var: u32, capture: Option) -> Self { + Self { edge_var, capture } } } impl graphforge_storage::io_stats::FilteredReadObserver for HopReadObserver { fn read_started(&self, table: graphforge_storage::io_stats::FilteredReadTable) { - with_hop(self.edge_var, |hop| match table { + with_hop_handle(self.capture.as_ref(), self.edge_var, |hop| match table { graphforge_storage::io_stats::FilteredReadTable::Edge => hop.edge_reads_started += 1, graphforge_storage::io_stats::FilteredReadTable::Node => hop.node_reads_started += 1, }); } fn rows_scanned(&self, table: graphforge_storage::io_stats::FilteredReadTable, rows: u64) { - with_hop(self.edge_var, |hop| match table { + with_hop_handle(self.capture.as_ref(), self.edge_var, |hop| match table { graphforge_storage::io_stats::FilteredReadTable::Edge => hop.edge_rows_scanned += rows, graphforge_storage::io_stats::FilteredReadTable::Node => hop.node_rows_scanned += rows, }); @@ -650,7 +708,7 @@ impl graphforge_storage::io_stats::FilteredReadObserver for HopReadObserver { rows: u64, full: bool, ) { - with_hop(self.edge_var, |hop| match table { + with_hop_handle(self.capture.as_ref(), self.edge_var, |hop| match table { graphforge_storage::io_stats::FilteredReadTable::Edge => { hop.edge_reads_completed += 1; hop.edge_rows_returned += rows; @@ -665,7 +723,7 @@ impl graphforge_storage::io_stats::FilteredReadObserver for HopReadObserver { } fn read_failed(&self, table: graphforge_storage::io_stats::FilteredReadTable) { - with_hop(self.edge_var, |hop| match table { + with_hop_handle(self.capture.as_ref(), self.edge_var, |hop| match table { graphforge_storage::io_stats::FilteredReadTable::Edge => hop.edge_reads_failed += 1, graphforge_storage::io_stats::FilteredReadTable::Node => hop.node_reads_failed += 1, }); @@ -679,7 +737,7 @@ impl graphforge_storage::io_stats::FilteredReadObserver for HopReadObserver { if table != graphforge_storage::io_stats::FilteredReadTable::Node { return; } - with_hop(self.edge_var, |hop| { + with_hop_handle(self.capture.as_ref(), self.edge_var, |hop| { match pruning.strategy { graphforge_storage::io_stats::FilteredReadStrategy::DenseRowSelection => { hop.node_dense_row_selection_reads += 1; @@ -1172,6 +1230,28 @@ mod tests { assert!(!left.hops.contains_key(&99)); } + #[tokio::test] + async fn explicit_capture_handle_survives_spawned_operator_task() { + let _guard = OBSERVATION_TEST_LOCK.lock().unwrap(); + let (_, snapshot) = observe(async { + let capture = capture_handle().expect("active query capture"); + tokio::spawn(async move { + record_input_with_capture(Some(&capture), 41, 3); + record_candidates_with_capture(Some(&capture), 41, 5); + record_emitted_with_capture(Some(&capture), 41, 5); + let _activity = OperatorActivity::expand_with_capture(41, Some(capture)); + }) + .await + .unwrap(); + }) + .await; + + assert_eq!(snapshot.hops[&41].input_rows, 3); + assert_eq!(snapshot.hops[&41].candidates_generated, 5); + assert_eq!(snapshot.hops[&41].rows_emitted, 5); + assert!(snapshot.operator_rss.expand_by_hop.contains_key(&41)); + } + #[tokio::test] async fn sampler_is_reaped_when_observation_is_aborted_or_panics() { let _guard = OBSERVATION_TEST_LOCK.lock().unwrap(); diff --git a/crates/graphforge-exec/src/lib.rs b/crates/graphforge-exec/src/lib.rs index bee3faeb..545c410f 100644 --- a/crates/graphforge-exec/src/lib.rs +++ b/crates/graphforge-exec/src/lib.rs @@ -3163,6 +3163,7 @@ pub struct ExpandExec { demand_batch: Option, /// Query-scoped terminal cancellation shared by the bounded hop chain. demand: Option>, + capture: Option, } impl ExpandExec { @@ -3203,6 +3204,7 @@ impl ExpandExec { edge_var: node.edge_var, demand_batch: None, demand: None, + capture: demand::capture_handle(), } } @@ -3227,6 +3229,7 @@ impl ExpandExec { edge_var: self.edge_var, demand_batch: Some(batch_goal), demand: Some(demand), + capture: self.capture.clone(), }) } } @@ -3305,6 +3308,7 @@ impl ExecutionPlan for ExpandExec { edge_var: self.edge_var, demand_batch: self.demand_batch, demand: self.demand.clone(), + capture: self.capture.clone(), })) } @@ -3325,6 +3329,7 @@ impl ExecutionPlan for ExpandExec { edge_var: self.edge_var, demand_batch: self.demand_batch, demand: self.demand.clone(), + capture: self.capture.clone(), })) } @@ -3362,6 +3367,7 @@ impl ExecutionPlan for ExpandExec { provider: self.provider.clone(), edge_var: self.edge_var, demand: self.demand.clone(), + capture: self.capture.clone(), }; let schema = self.schema.clone(); let batch_size = context.session_config().batch_size(); @@ -3378,7 +3384,7 @@ impl ExecutionPlan for ExpandExec { None, batch_size, initial_batch_goal, - demand::OperatorActivity::expand(self.edge_var), + demand::OperatorActivity::expand_with_capture(self.edge_var, self.capture.clone()), ), |( mut input_stream, @@ -3433,7 +3439,11 @@ impl ExecutionPlan for ExpandExec { return Ok(None); }; let input_batch = input_batch?; - demand::record_input(cfg.edge_var, input_batch.num_rows()); + demand::record_input_with_capture( + cfg.capture.as_ref(), + cfg.edge_var, + input_batch.num_rows(), + ); pending = Some((input_batch, SingleHopPosition::default())); } }, @@ -3456,6 +3466,7 @@ struct SingleHopConfig { provider: Arc, edge_var: u32, demand: Option>, + capture: Option, } /// Resumable position within one input batch. Keeping the raw adjacency offset @@ -3542,7 +3553,7 @@ fn expand_single_hop_chunk( if triples.is_empty() { return Ok(RecordBatch::new_empty(cfg.out_schema.clone())); } - demand::record_candidates(cfg.edge_var, triples.len()); + demand::record_candidates_with_capture(cfg.capture.as_ref(), cfg.edge_var, triples.len()); // Edge rows keyed by edge_id, for the edge topology columns — read // lazily for the traversed ids only. @@ -3553,9 +3564,11 @@ fn expand_single_hop_chunk( if cfg.demand.is_some() && edge_permit.is_none() { return Ok(RecordBatch::new_empty(cfg.out_schema.clone())); } - let edge_observer = demand::capture_enabled().then(|| { - Arc::new(demand::HopReadObserver::new(cfg.edge_var)) - as Arc + let edge_observer = (cfg.capture.is_some() || demand::capture_enabled()).then(|| { + Arc::new(demand::HopReadObserver::with_capture( + cfg.edge_var, + cfg.capture.clone(), + )) as Arc }); let edge_batches = graphforge_storage::read_edges_filtered_observed( &cfg.dir, @@ -3592,9 +3605,11 @@ fn expand_single_hop_chunk( if cfg.demand.is_some() && node_permit.is_none() { return Ok(RecordBatch::new_empty(cfg.out_schema.clone())); } - let node_observer = demand::capture_enabled().then(|| { - Arc::new(demand::HopReadObserver::new(cfg.edge_var)) - as Arc + let node_observer = (cfg.capture.is_some() || demand::capture_enabled()).then(|| { + Arc::new(demand::HopReadObserver::with_capture( + cfg.edge_var, + cfg.capture.clone(), + )) as Arc }); let node_batches = graphforge_storage::read_nodes_filtered_observed( &cfg.dir, @@ -3680,7 +3695,7 @@ fn expand_single_hop_chunk( } let output = RecordBatch::try_new(cfg.out_schema.clone(), columns) .map_err(|e| exec_err(e.to_string()))?; - demand::record_emitted(cfg.edge_var, output.num_rows()); + demand::record_emitted_with_capture(cfg.capture.as_ref(), cfg.edge_var, output.num_rows()); Ok(output) } diff --git a/docs/development/perf-g500-ladder.md b/docs/development/perf-g500-ladder.md index ad38eece..292c081b 100644 --- a/docs/development/perf-g500-ladder.md +++ b/docs/development/perf-g500-ladder.md @@ -169,6 +169,17 @@ into a previously absent destination; imported reopen and equivalent queries; and the four bounded negative drills. Its evidence is not a pass unless every phase is present and successful and the source/import fingerprints match. +The one-hop and two-hop observations are rooted at the deterministic published +node for Graph500 vertex 15, then apply global `ORDER BY ... LIMIT 1000` within +that neighborhood. Vertex 15 is present at every configured rung and gives the +pinned S10 seed a non-empty one-hop and two-hop probe without selecting the +generator's pathological highest-degree vertex. This is an ordinary +parameterized Rust-facade query, not a benchmark-only execution path. The root +bound is part of the workload contract: an unrooted two-hop TopK must enumerate +the complete graph's two-hop path result to preserve exact ordering, making +runtime proportional to path cardinality rather than providing a bounded +neighborhood traversal signal. + ### Disposable Fly S20 controller The checked-in Fly harness is @@ -215,6 +226,17 @@ of the Machine, volume, and app in `finally`. Do not use pricing fixtures with execution; `--pricing-html` and `--manifest-json` exist only for deterministic dry-run tests. +During execution the controller prints sanitized JSON progress: every completed +phase, the next phase start, and a heartbeat once per minute. It also writes +each valid journal prefix to `--journal-out`, so an operator stop or timeout +does not discard completed evidence. Phase-aware operational ceilings stop a +stalled or pathologically broad phase with `phase_timeout` before it can consume +the entire 4h30 outer envelope; a journaled product failure stops immediately +with `phase_failed` and its recorded failure code. These ceilings only prevent +runaway spend and silence. They do not turn a partial lifecycle into a pass: +success still requires the exact 17-phase journal and equivalent source/import +evidence described above. The dry-run plan prints the complete timeout table. + ```bash python3 scripts/ci/test-fly-g500-s20.py ``` diff --git a/scripts/ci/test-fly-g500-s20.py b/scripts/ci/test-fly-g500-s20.py index 7b3f8813..19af1adf 100644 --- a/scripts/ci/test-fly-g500-s20.py +++ b/scripts/ci/test-fly-g500-s20.py @@ -115,6 +115,21 @@ def main() -> None: else: raise AssertionError("non-equivalent import must be refused") + assert controller.journal_progress(phases[:3]) == (3, "csr") + failed = [*phases[:2], {"id": "ingest", "status": "fail", "failure_code": "oom"}] + try: + controller.journal_progress(failed) + except controller.ControllerError as error: + assert str(error) == "phase_failed phase=ingest failure_code=oom" + else: + raise AssertionError("typed phase failure must stop the controller") + try: + controller.journal_progress([{"id": "generate", "status": "pass"}]) + except controller.ControllerError as error: + assert str(error).startswith("journal_invalid") + else: + raise AssertionError("out-of-order journal must be refused") + (root / "pricing.html").write_text(pricing_html()) (root / "manifest.json").write_text(child) # Main dry-run exercises argument/config/rate/manifest validation without Fly. @@ -153,6 +168,9 @@ def main() -> None: plan = json.loads(result.stdout) assert plan["mode"] == "dry-run" and plan["hard_ttl_s"] == 16200 assert plan["volume_gb"] == 50 and plan["public_services"] == 0 + assert plan["heartbeat_interval_s"] == 60 + assert plan["phase_timeout_s"]["ingest"] == 3600 + assert plan["phase_timeout_s"]["source_query_2hop"] == 900 source = CONTROLLER.read_text() assert "Authorization" in source and '["auth", "token"]' in source diff --git a/scripts/ci/test-non-cypher-surface-gate.py b/scripts/ci/test-non-cypher-surface-gate.py index 8a9a6f61..20629b4d 100644 --- a/scripts/ci/test-non-cypher-surface-gate.py +++ b/scripts/ci/test-non-cypher-surface-gate.py @@ -29,7 +29,7 @@ def validate(self, manifest: dict) -> list[str]: def test_checked_in_inventory_is_complete(self) -> None: self.assertEqual(GATE.validate(), []) - self.assertEqual(len(GATE.public_methods()), 367) + self.assertEqual(len(GATE.public_methods()), 364) self.assertEqual(len(GATE.algorithm_registry()), 94) def test_new_or_removed_public_method_fails_frozen_digest(self) -> None: diff --git a/scripts/fly-g500-s20.py b/scripts/fly-g500-s20.py index 61f67a2c..625ec8e5 100644 --- a/scripts/fly-g500-s20.py +++ b/scripts/fly-g500-s20.py @@ -41,6 +41,31 @@ "drill_resource_limit", "drill_interrupted_finalization", ] +# Phase ceilings are operational stop conditions, not performance pass criteria. +# They include measured Fly S20 headroom while ensuring a bad workload or plan +# cannot consume the entire four-hour certification envelope without a useful +# typed diagnosis. The outer hard TTL remains authoritative. +PHASE_TIMEOUT_S = { + "preflight": 15 * 60, + "generate": 15 * 60, + "ingest": 60 * 60, + "csr": 20 * 60, + "source_reopen": 15 * 60, + "source_query_1hop": 15 * 60, + "source_query_2hop": 15 * 60, + "export": 45 * 60, + "verify": 30 * 60, + "import": 60 * 60, + "imported_reopen": 15 * 60, + "imported_query_1hop": 15 * 60, + "imported_query_2hop": 15 * 60, + "drill_corruption": 15 * 60, + "drill_cancellation": 15 * 60, + "drill_resource_limit": 15 * 60, + "drill_interrupted_finalization": 15 * 60, +} +POLL_INTERVAL_S = 15 +HEARTBEAT_INTERVAL_S = 60 HARD_TTL_S = 4 * 3600 + 30 * 60 VOLUME_GB = 50 MEMORY_MB = 4096 @@ -51,6 +76,28 @@ class ControllerError(RuntimeError): pass +def emit_progress(event: str, **fields: Any) -> None: + """Emit one sanitized, machine-readable operator update.""" + print(json.dumps({"event": event, **fields}, sort_keys=True), flush=True) + + +def journal_progress(journal: Any) -> tuple[int, str | None]: + """Validate an atomic journal snapshot and identify the active phase.""" + if not isinstance(journal, list) or len(journal) > len(PHASES): + raise ControllerError("journal_invalid invalid phase collection") + for index, phase in enumerate(journal): + if not isinstance(phase, dict) or phase.get("id") != PHASES[index]: + raise ControllerError("journal_invalid phases are not the required ordered prefix") + status = phase.get("status") + if status == "fail": + code = phase.get("failure_code") or "operation_failed" + raise ControllerError(f"phase_failed phase={PHASES[index]} failure_code={code}") + if status != "pass": + raise ControllerError("journal_invalid completed phase has unknown status") + active = PHASES[len(journal)] if len(journal) < len(PHASES) else None + return len(journal), active + + class Flyctl: def run(self, args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: return subprocess.run( @@ -326,15 +373,70 @@ def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: with tempfile.TemporaryDirectory(prefix="graphforge-fly-s20-") as directory: journal_path = Path(directory) / "journal.json" evidence_path = Path(directory) / "evidence.json" + completed = 0 + active_phase = PHASES[0] + phase_started = time.monotonic() + next_heartbeat = phase_started while time.monotonic() < deadline: - retrieve(fly, args.app_name, machine_id, "/work/s20-journal.json", journal_path) + now = time.monotonic() + if retrieve( + fly, args.app_name, machine_id, "/work/s20-journal.json", journal_path + ): + journal = json.loads(journal_path.read_text()) + try: + observed_completed, observed_active = journal_progress(journal) + except ControllerError as error: + if str(error).startswith("phase_failed "): + args.journal_out.write_text( + json.dumps(journal, indent=2, sort_keys=True) + "\n" + ) + raise + if observed_completed < completed: + raise ControllerError("journal_invalid completed phase count regressed") + if observed_completed > completed: + for phase in journal[completed:observed_completed]: + emit_progress( + "phase_complete", + phase=phase["id"], + elapsed_ms=phase.get("elapsed_ms"), + rss_peak_bytes=phase.get("rss_peak_bytes"), + disk_peak_bytes=phase.get("disk_peak_bytes"), + ) + completed = observed_completed + active_phase = observed_active + phase_started = now + next_heartbeat = now + if active_phase is not None: + emit_progress("phase_start", phase=active_phase) + # Preserve the last valid incomplete journal even when a + # controller deadline stops and destroys the Machine. + args.journal_out.write_text( + json.dumps(journal, indent=2, sort_keys=True) + "\n" + ) if retrieve( fly, args.app_name, machine_id, "/work/s20-evidence.json", evidence_path ): break - time.sleep(5) + if active_phase is not None: + phase_elapsed = now - phase_started + if phase_elapsed >= PHASE_TIMEOUT_S[active_phase]: + raise ControllerError( + f"phase_timeout phase={active_phase} " + f"elapsed_s={int(phase_elapsed)} " + f"limit_s={PHASE_TIMEOUT_S[active_phase]}" + ) + if now >= next_heartbeat: + emit_progress( + "phase_heartbeat", + phase=active_phase, + elapsed_s=int(phase_elapsed), + limit_s=PHASE_TIMEOUT_S[active_phase], + completed_phases=completed, + ) + next_heartbeat = now + HEARTBEAT_INTERVAL_S + time.sleep(POLL_INTERVAL_S) else: - raise ControllerError("4h30 hard deadline reached before S20 evidence") + raise ControllerError("run_timeout 4h30 hard deadline reached before S20 evidence") evidence = json.loads(evidence_path.read_text()) journal = json.loads(journal_path.read_text()) validate_evidence(evidence, journal, args.expected_sha) @@ -403,6 +505,8 @@ def main() -> int: "restart": "no", "auto_destroy": True, "hard_ttl_s": HARD_TTL_S, + "phase_timeout_s": PHASE_TIMEOUT_S, + "heartbeat_interval_s": HEARTBEAT_INTERVAL_S, } print(json.dumps(plan, indent=2, sort_keys=True)) if args.execute: diff --git a/tests/contracts/non-cypher-rust-surface.json b/tests/contracts/non-cypher-rust-surface.json index 428a321b..034af0dc 100644 --- a/tests/contracts/non-cypher-rust-surface.json +++ b/tests/contracts/non-cypher-rust-surface.json @@ -1,7 +1,7 @@ { "contract_version": 1, "scope": "Rust non-Cypher public release surface", - "public_method_digest": "261f99ebab8e9d69a3a3607705d96fd4ad9e00f5102b5062440fa4bcd07ebc44", + "public_method_digest": "00c669a797e086500484d09351f13424739c365cd54e283baf48f1126ca192e6", "method_policy": { "receiver_defaults": { "GraphForge": "release-tested", @@ -43,9 +43,6 @@ "crate.composite_receipt_schema": "introspection", "crate.grade_gsi": "introspection", "crate.hold_writer": "internal-helper", - "crate.parse_hub_manifest": "designed-only", - "crate.parse_hub_refs": "designed-only", - "crate.parse_hub_repository_identity": "designed-only", "crate.verify_portable_v2": "designed-only", "GraphForge.new": "introspection", "GraphForge.new_with_options": "introspection", From a0447f2527bfd1c0133d12a3b0fc448df5d672c2 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:00:56 -0600 Subject: [PATCH 08/16] fix(ci): align S20 follow-up gates (#904) --- crates/graphforge-exec/src/demand.rs | 9 +- scripts/fly-g500-s20.py | 4 +- .../drift/cargo_feature_fingerprint.json | 124 +++++++++++++++++- 3 files changed, 127 insertions(+), 10 deletions(-) diff --git a/crates/graphforge-exec/src/demand.rs b/crates/graphforge-exec/src/demand.rs index 907017b5..cc34cf54 100644 --- a/crates/graphforge-exec/src/demand.rs +++ b/crates/graphforge-exec/src/demand.rs @@ -365,6 +365,7 @@ enum OperatorKind { Sort, } impl OperatorActivity { + #[cfg(test)] pub(crate) fn expand(edge_var: u32) -> Self { Self::new(OperatorKind::Expand(edge_var)) } @@ -504,6 +505,7 @@ pub(crate) fn record_plan_after( }); } +#[cfg(test)] fn with_hop(edge_var: u32, update: impl FnOnce(&mut HopSnapshot)) { with_hop_handle(None, edge_var, update); } @@ -526,6 +528,7 @@ fn with_hop_handle( }); } +#[cfg(test)] pub(crate) fn record_input(edge_var: u32, rows: usize) { record_input_with_capture(None, edge_var, rows); } @@ -547,7 +550,7 @@ pub(crate) fn record_candidates_with_capture( rows: usize, ) { with_hop_handle(capture, edge_var, |hop| { - hop.candidates_generated += rows as u64 + hop.candidates_generated += rows as u64; }); } @@ -621,7 +624,7 @@ impl QueryDemand { pub(crate) fn begin_read(self: &Arc, edge_var: u32) -> Option { if self.is_cancelled() { with_hop_handle(self.capture.as_ref(), edge_var, |hop| { - hop.reads_after_cancel += 1 + hop.reads_after_cancel += 1; }); return None; } @@ -637,7 +640,7 @@ impl QueryDemand { if self.is_cancelled() { self.finish_read(); with_hop_handle(self.capture.as_ref(), edge_var, |hop| { - hop.reads_after_cancel += 1 + hop.reads_after_cancel += 1; }); return None; } diff --git a/scripts/fly-g500-s20.py b/scripts/fly-g500-s20.py index 625ec8e5..9d057bc7 100644 --- a/scripts/fly-g500-s20.py +++ b/scripts/fly-g500-s20.py @@ -379,9 +379,7 @@ def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: next_heartbeat = phase_started while time.monotonic() < deadline: now = time.monotonic() - if retrieve( - fly, args.app_name, machine_id, "/work/s20-journal.json", journal_path - ): + if retrieve(fly, args.app_name, machine_id, "/work/s20-journal.json", journal_path): journal = json.loads(journal_path.read_text()) try: observed_completed, observed_active = journal_progress(journal) diff --git a/tools/bazel/drift/cargo_feature_fingerprint.json b/tools/bazel/drift/cargo_feature_fingerprint.json index aedb09d4..4583ea2e 100644 --- a/tools/bazel/drift/cargo_feature_fingerprint.json +++ b/tools/bazel/drift/cargo_feature_fingerprint.json @@ -1,6 +1,6 @@ { "schema": "graphforge.cargo-feature-fingerprint.v1", - "sha256": "6bd46b262dcc95fdb3de2d8fc3945ddee6032426f0e137afcb19728c1c5480d0", + "sha256": "feb208adea6ed174312b3f9be6ae85fbbeafc9863d85bc30a82b0e94fd2be794", "entries": [ { "name": "graphforge-api", @@ -117,6 +117,15 @@ "kind": null, "target": null }, + { + "name": "graphforge-observability", + "req": "^0.5.2", + "features": [], + "optional": false, + "uses_default_features": true, + "kind": null, + "target": null + }, { "name": "graphforge-ontology", "req": "^0.5.2", @@ -538,6 +547,15 @@ "kind": null, "target": null }, + { + "name": "fs4", + "req": "^1.1", + "features": [], + "optional": false, + "uses_default_features": true, + "kind": null, + "target": null + }, { "name": "graphforge-api", "req": "^0.5.2", @@ -547,13 +565,31 @@ "kind": null, "target": null }, + { + "name": "graphforge-discovery", + "req": "^0.5.2", + "features": [], + "optional": false, + "uses_default_features": true, + "kind": null, + "target": null + }, { "name": "graphforge-storage", "req": "^0.5.2", "features": [], "optional": false, "uses_default_features": true, - "kind": "dev", + "kind": null, + "target": null + }, + { + "name": "libc", + "req": "^0.2", + "features": [], + "optional": false, + "uses_default_features": true, + "kind": null, "target": null }, { @@ -600,7 +636,7 @@ "features": [], "optional": false, "uses_default_features": true, - "kind": "dev", + "kind": null, "target": null }, { @@ -609,7 +645,29 @@ "features": [], "optional": false, "uses_default_features": true, - "kind": "dev", + "kind": null, + "target": null + }, + { + "name": "ureq", + "req": "=3.4.0", + "features": [ + "rustls" + ], + "optional": false, + "uses_default_features": false, + "kind": null, + "target": null + }, + { + "name": "url", + "req": "^2", + "features": [ + "serde" + ], + "optional": false, + "uses_default_features": true, + "kind": null, "target": null }, { @@ -1290,6 +1348,64 @@ } ] }, + { + "name": "graphforge-observability", + "version": "0.5.2", + "features": [], + "dependencies": [ + { + "name": "serde", + "req": "^1", + "features": [ + "derive" + ], + "optional": false, + "uses_default_features": true, + "kind": null, + "target": null + }, + { + "name": "serde_json", + "req": "^1", + "features": [], + "optional": false, + "uses_default_features": true, + "kind": null, + "target": null + }, + { + "name": "thiserror", + "req": "^2", + "features": [], + "optional": false, + "uses_default_features": true, + "kind": null, + "target": null + }, + { + "name": "ureq", + "req": "=3.4.0", + "features": [ + "rustls" + ], + "optional": false, + "uses_default_features": false, + "kind": null, + "target": null + }, + { + "name": "url", + "req": "^2", + "features": [ + "serde" + ], + "optional": false, + "uses_default_features": true, + "kind": null, + "target": null + } + ] + }, { "name": "graphforge-ontology", "version": "0.5.2", From cbe53f6aff3c7941f6f48daadefb44dcd9735e4d Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:19:09 -0600 Subject: [PATCH 09/16] fix(bazel): classify exec Tokio as runtime dependency (#904) --- cargo-bazel-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cargo-bazel-lock.json b/cargo-bazel-lock.json index 64fe1c92..3f993ab9 100644 --- a/cargo-bazel-lock.json +++ b/cargo-bazel-lock.json @@ -13207,6 +13207,10 @@ { "id": "thiserror 2.0.20", "target": "thiserror" + }, + { + "id": "tokio 1.53.1", + "target": "tokio" } ], "selects": {} @@ -13224,10 +13228,6 @@ { "id": "tempfile 3.27.0", "target": "tempfile" - }, - { - "id": "tokio 1.53.1", - "target": "tokio" } ], "selects": {} From 2f9a7e6a6804a0175cf3a165b88bf151b327c3c4 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:31:45 -0600 Subject: [PATCH 10/16] fix(bazel): refresh crate-universe lock checksum (#904) --- cargo-bazel-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cargo-bazel-lock.json b/cargo-bazel-lock.json index 3f993ab9..6d0faf60 100644 --- a/cargo-bazel-lock.json +++ b/cargo-bazel-lock.json @@ -1,5 +1,5 @@ { - "checksum": "b21d3280c7ad6cd450e752d4bd6e74303ecf1c990af629ad88bd4f9b6fb2a933", + "checksum": "af18c24dc89f92d4a76a1ff98e3e6979374ca658c34c5e192b323712f1663e63", "crates": { "adler2 2.0.1": { "name": "adler2", From 0a9b2edb56c98efc597627579c3928395a855ebc Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:49:34 -0600 Subject: [PATCH 11/16] fix(rel): push rooted filters below expansion (#904) --- crates/graphforge-rel/src/lowerer.rs | 184 ++++++++++++++++++++++++++- 1 file changed, 180 insertions(+), 4 deletions(-) diff --git a/crates/graphforge-rel/src/lowerer.rs b/crates/graphforge-rel/src/lowerer.rs index 0ab0b4d2..49dab92c 100644 --- a/crates/graphforge-rel/src/lowerer.rs +++ b/crates/graphforge-rel/src/lowerer.rs @@ -34,6 +34,7 @@ use datafusion::functions_aggregate::count::count_all; use datafusion::functions_aggregate::expr_fn::{ array_agg, avg, avg_distinct, count, count_distinct, max, min, sum, sum_distinct, }; +use datafusion::logical_expr::utils::{conjunction, split_conjunction_owned}; use datafusion::logical_expr::{ Expr as DfExpr, ExprFunctionExt, ExprSchemable, Extension, JoinType, LogicalPlanBuilder, SortExpr, logical_plan::LogicalTableSource, @@ -2454,10 +2455,82 @@ fn lower_filter( lowerer: &ExprLowerer<'_>, ) -> Result { let df_pred = lowerer.lower(predicate)?; - LogicalPlanBuilder::from(input) - .filter(df_pred) - .and_then(LogicalPlanBuilder::build) - .map_unsupported_expr() + push_filter_through_expands(df_pred, input) +} + +/// Push source-only filter conjuncts below provider-backed expansions. +/// +/// DataFusion's generic extension-node hook identifies blocked columns by +/// *unqualified name*. That cannot safely distinguish `var_0.node_uuid` (the +/// source) from `var_2.node_uuid` (the destination), so `ExpandNode` keeps the +/// conservative default and this graph-aware rewrite uses the full qualified +/// [`Column`](datafusion::common::Column) instead. A source predicate commutes +/// with expansion and should restrict the frontier before any adjacency I/O; +/// predicates on newly produced edge/destination columns stay above it. +fn push_filter_through_expands( + predicate: DfExpr, + input: LogicalPlan, +) -> Result { + let LogicalPlan::Extension(extension) = input else { + return LogicalPlanBuilder::from(input) + .filter(predicate) + .and_then(LogicalPlanBuilder::build) + .map_unsupported_expr(); + }; + let is_expand = extension.node.as_any().is::() + || extension.node.as_any().is::(); + let Some(child) = extension + .node + .inputs() + .first() + .map(|child| (*child).clone()) + else { + return LogicalPlanBuilder::from(LogicalPlan::Extension(extension)) + .filter(predicate) + .and_then(LogicalPlanBuilder::build) + .map_unsupported_expr(); + }; + if !is_expand { + return LogicalPlanBuilder::from(LogicalPlan::Extension(extension)) + .filter(predicate) + .and_then(LogicalPlanBuilder::build) + .map_unsupported_expr(); + } + + let (push, keep): (Vec<_>, Vec<_>) = + split_conjunction_owned(predicate) + .into_iter() + .partition(|expr| { + !expr.is_volatile() + && expr + .column_refs() + .iter() + .all(|column| child.schema().has_column(column)) + }); + let Some(push) = conjunction(push) else { + return LogicalPlanBuilder::from(LogicalPlan::Extension(extension)) + .filter(conjunction(keep).expect("a non-empty predicate has a kept conjunct")) + .and_then(LogicalPlanBuilder::build) + .map_unsupported_expr(); + }; + + // Recurse so a predicate on the original source crosses every hop in a + // fixed-hop chain, not merely the last Expand. + let rewritten_child = push_filter_through_expands(push, child)?; + let rewritten_node = extension + .node + .with_exprs_and_inputs(extension.node.expressions(), vec![rewritten_child]) + .map_unsupported_expr()?; + let rewritten_expand = LogicalPlan::Extension(Extension { + node: rewritten_node, + }); + match conjunction(keep) { + Some(keep) => LogicalPlanBuilder::from(rewritten_expand) + .filter(keep) + .and_then(LogicalPlanBuilder::build) + .map_unsupported_expr(), + None => Ok(rewritten_expand), + } } fn lower_project( @@ -6162,6 +6235,109 @@ mod tests { (tmp, catalog, plan) } + fn uuid_filter(var: u32) -> DfExpr { + datafusion::logical_expr::col(format!("var_{var}.node_uuid")).eq( + datafusion::logical_expr::lit(datafusion::common::ScalarValue::FixedSizeBinary( + 16, + Some(vec![var as u8; 16]), + )), + ) + } + + #[test] + fn source_filter_is_pushed_below_every_expand_by_qualified_identity() { + let (tmp, catalog, single_hop) = typed_single_hop_fixture(Direction::Out); + let rel_ty = match single_hop.ops[1] { + GraphOp::Expand { rel_ty, .. } => rel_ty, + ref other => panic!("expected Expand, got {other:?}"), + }; + let plan = GraphPlan::builder("openCypher") + .push_op(GraphOp::NodeScan { + var: VarId(0), + ty: None, + }) + .push_op(GraphOp::Expand { + src: VarId(0), + edge: VarId(1), + dst: VarId(2), + rel_ty, + dir: Direction::Out, + min_hops: 1, + max_hops: Some(1), + }) + .push_op(GraphOp::Expand { + src: VarId(2), + edge: VarId(3), + dst: VarId(4), + rel_ty, + dir: Direction::Out, + min_hops: 1, + max_hops: Some(1), + }) + .build(); + let lowerer = + GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict); + let lowered = lowerer.lower_plan(&plan).unwrap(); + let rewritten = push_filter_through_expands(uuid_filter(0), lowered).unwrap(); + + let DfLogicalPlan::Extension(second) = rewritten else { + panic!("source filter must move below the second Expand"); + }; + let DfLogicalPlan::Extension(first) = second.node.inputs()[0] else { + panic!("source filter must move below the first Expand"); + }; + let DfLogicalPlan::Filter(root_filter) = first.node.inputs()[0] else { + panic!("source filter must sit directly above the source scan"); + }; + assert!( + root_filter + .predicate + .to_string() + .contains("var_0.node_uuid"), + "{}", + root_filter.predicate + ); + } + + #[test] + fn destination_filter_stays_above_expand_despite_identity_name_collision() { + let (tmp, catalog, plan) = typed_single_hop_fixture(Direction::Out); + let lowerer = + GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict); + let lowered = lowerer.lower_plan(&plan).unwrap(); + let rewritten = push_filter_through_expands(uuid_filter(2), lowered).unwrap(); + + let DfLogicalPlan::Filter(filter) = rewritten else { + panic!("destination filter must stay above Expand"); + }; + assert!(matches!(filter.input.as_ref(), DfLogicalPlan::Extension(_))); + assert!(filter.predicate.to_string().contains("var_2.node_uuid")); + } + + #[test] + fn mixed_filter_pushes_only_the_source_conjunct() { + let (tmp, catalog, plan) = typed_single_hop_fixture(Direction::Out); + let lowerer = + GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict); + let lowered = lowerer.lower_plan(&plan).unwrap(); + let rewritten = + push_filter_through_expands(uuid_filter(0).and(uuid_filter(2)), lowered).unwrap(); + + let DfLogicalPlan::Filter(residual) = rewritten else { + panic!("destination conjunct must remain above Expand"); + }; + assert!(residual.predicate.to_string().contains("var_2.node_uuid")); + assert!(!residual.predicate.to_string().contains("var_0.node_uuid")); + let DfLogicalPlan::Extension(expand) = residual.input.as_ref() else { + panic!("residual filter must wrap Expand"); + }; + let DfLogicalPlan::Filter(root) = expand.node.inputs()[0] else { + panic!("source conjunct must move below Expand"); + }; + assert!(root.predicate.to_string().contains("var_0.node_uuid")); + assert!(!root.predicate.to_string().contains("var_2.node_uuid")); + } + #[test] fn project_backed_single_hop_emits_expand_extension_node() { use datafusion::logical_expr::UserDefinedLogicalNodeCore; From c06658d597564d518b930902ef5c1137445d768f Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:01:33 -0600 Subject: [PATCH 12/16] fix(rel): push root filter through uniqueness guard (#904) --- crates/graphforge-rel/src/lowerer.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/graphforge-rel/src/lowerer.rs b/crates/graphforge-rel/src/lowerer.rs index 49dab92c..ec3b4f6f 100644 --- a/crates/graphforge-rel/src/lowerer.rs +++ b/crates/graphforge-rel/src/lowerer.rs @@ -2471,6 +2471,22 @@ fn push_filter_through_expands( predicate: DfExpr, input: LogicalPlan, ) -> Result { + // Fixed-hop relationship-isomorphism is itself a Filter between adjacent + // Expand nodes. Deterministic filters commute, so let the later source + // predicate cross it; a volatile filter is an evaluation boundary. + if let LogicalPlan::Filter(existing) = &input { + if predicate.is_volatile() || existing.predicate.is_volatile() { + return LogicalPlanBuilder::from(input) + .filter(predicate) + .and_then(LogicalPlanBuilder::build) + .map_unsupported_expr(); + } + let rewritten = push_filter_through_expands(predicate, (*existing.input).clone())?; + return LogicalPlanBuilder::from(rewritten) + .filter(existing.predicate.clone()) + .and_then(LogicalPlanBuilder::build) + .map_unsupported_expr(); + } let LogicalPlan::Extension(extension) = input else { return LogicalPlanBuilder::from(input) .filter(predicate) @@ -6274,14 +6290,21 @@ mod tests { min_hops: 1, max_hops: Some(1), }) + .push_op(GraphOp::RelationshipUnique { + edge: VarId(3), + prior_edges: vec![VarId(1)], + }) .build(); let lowerer = GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict); let lowered = lowerer.lower_plan(&plan).unwrap(); let rewritten = push_filter_through_expands(uuid_filter(0), lowered).unwrap(); - let DfLogicalPlan::Extension(second) = rewritten else { - panic!("source filter must move below the second Expand"); + let DfLogicalPlan::Filter(relationship_unique) = rewritten else { + panic!("relationship uniqueness must remain above the second Expand"); + }; + let DfLogicalPlan::Extension(second) = relationship_unique.input.as_ref() else { + panic!("source filter must cross relationship uniqueness"); }; let DfLogicalPlan::Extension(first) = second.node.inputs()[0] else { panic!("source filter must move below the first Expand"); From 3f3f3f7c1aa342e7a83022aa2aad1d6551151466 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:12:18 -0600 Subject: [PATCH 13/16] test(rel): accept rooted filter plan order (#904) --- .../logical_plan_golden__filtered_scan.snap | 4 ++-- .../logical_plan_goldens/logical_plan_golden__parameter.snap | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/graphforge-rel/tests/logical_plan_goldens/logical_plan_golden__filtered_scan.snap b/crates/graphforge-rel/tests/logical_plan_goldens/logical_plan_golden__filtered_scan.snap index ed9cbf56..faa72b85 100644 --- a/crates/graphforge-rel/tests/logical_plan_goldens/logical_plan_golden__filtered_scan.snap +++ b/crates/graphforge-rel/tests/logical_plan_goldens/logical_plan_golden__filtered_scan.snap @@ -2,6 +2,6 @@ source: crates/graphforge-rel/tests/logical_plan_golden.rs --- Projection: Int64(1) AS one [one:Int64] - Filter: cypher_cmp_pred(var_0.prop_0, Int64(30), Int8(2)) [node_uuid:FixedSizeBinary(16), node_id:UInt64, type_id:UInt32, type_ids:List(non-null UInt32), created_at:Timestamp(µs, "UTC"), updated_at:Timestamp(µs, "UTC")] - Filter: array_has(var_0.type_ids, UInt32(0)) [node_uuid:FixedSizeBinary(16), node_id:UInt64, type_id:UInt32, type_ids:List(non-null UInt32), created_at:Timestamp(µs, "UTC"), updated_at:Timestamp(µs, "UTC")] + Filter: array_has(var_0.type_ids, UInt32(0)) [node_uuid:FixedSizeBinary(16), node_id:UInt64, type_id:UInt32, type_ids:List(non-null UInt32), created_at:Timestamp(µs, "UTC"), updated_at:Timestamp(µs, "UTC")] + Filter: cypher_cmp_pred(var_0.prop_0, Int64(30), Int8(2)) [node_uuid:FixedSizeBinary(16), node_id:UInt64, type_id:UInt32, type_ids:List(non-null UInt32), created_at:Timestamp(µs, "UTC"), updated_at:Timestamp(µs, "UTC")] TableScan: var_0 [node_uuid:FixedSizeBinary(16), node_id:UInt64, type_id:UInt32, type_ids:List(non-null UInt32), created_at:Timestamp(µs, "UTC"), updated_at:Timestamp(µs, "UTC")] diff --git a/crates/graphforge-rel/tests/logical_plan_goldens/logical_plan_golden__parameter.snap b/crates/graphforge-rel/tests/logical_plan_goldens/logical_plan_golden__parameter.snap index 81d874e8..73d20d68 100644 --- a/crates/graphforge-rel/tests/logical_plan_goldens/logical_plan_golden__parameter.snap +++ b/crates/graphforge-rel/tests/logical_plan_goldens/logical_plan_golden__parameter.snap @@ -2,6 +2,6 @@ source: crates/graphforge-rel/tests/logical_plan_golden.rs --- Projection: Int64(1) AS one [one:Int64] - Filter: cypher_eq(var_0.prop_0, $eid) [node_uuid:FixedSizeBinary(16), node_id:UInt64, type_id:UInt32, type_ids:List(non-null UInt32), created_at:Timestamp(µs, "UTC"), updated_at:Timestamp(µs, "UTC")] - Filter: array_has(var_0.type_ids, UInt32(1)) [node_uuid:FixedSizeBinary(16), node_id:UInt64, type_id:UInt32, type_ids:List(non-null UInt32), created_at:Timestamp(µs, "UTC"), updated_at:Timestamp(µs, "UTC")] + Filter: array_has(var_0.type_ids, UInt32(1)) [node_uuid:FixedSizeBinary(16), node_id:UInt64, type_id:UInt32, type_ids:List(non-null UInt32), created_at:Timestamp(µs, "UTC"), updated_at:Timestamp(µs, "UTC")] + Filter: cypher_eq(var_0.prop_0, $eid) [node_uuid:FixedSizeBinary(16), node_id:UInt64, type_id:UInt32, type_ids:List(non-null UInt32), created_at:Timestamp(µs, "UTC"), updated_at:Timestamp(µs, "UTC")] TableScan: var_0 [node_uuid:FixedSizeBinary(16), node_id:UInt64, type_id:UInt32, type_ids:List(non-null UInt32), created_at:Timestamp(µs, "UTC"), updated_at:Timestamp(µs, "UTC")] From 4007f287a59b5064a878054b3fc16b4ffb93fdd8 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:27:24 -0600 Subject: [PATCH 14/16] fix(ci): allow Fly bulk I/O variance (#904) --- docs/development/perf-g500-ladder.md | 8 ++++++++ scripts/ci/test-fly-g500-s20.py | 3 ++- scripts/fly-g500-s20.py | 11 ++++++----- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/development/perf-g500-ladder.md b/docs/development/perf-g500-ladder.md index 292c081b..6ee04a72 100644 --- a/docs/development/perf-g500-ladder.md +++ b/docs/development/perf-g500-ladder.md @@ -237,6 +237,14 @@ runaway spend and silence. They do not turn a partial lifecycle into a pass: success still requires the exact 17-phase journal and equivalent source/import evidence described above. The dry-run plan prints the complete timeout table. +Ingest and clean import each have a 90-minute ceiling. A Fly S20 ingest remained +responsive and made progress but crossed the former 60-minute boundary by 18 +seconds, with no OOM or disk failure; the Machine, volume, and app then tore down +cleanly. The additional allowance accounts for empirically observed shared-host +storage-I/O variance. It does not weaken correctness: the 4h30 hard run limit, +all other phase ceilings, typed failure handling, exact phase sequence, and +source/import equivalence requirements remain unchanged. + ```bash python3 scripts/ci/test-fly-g500-s20.py ``` diff --git a/scripts/ci/test-fly-g500-s20.py b/scripts/ci/test-fly-g500-s20.py index 19af1adf..4c441dd1 100644 --- a/scripts/ci/test-fly-g500-s20.py +++ b/scripts/ci/test-fly-g500-s20.py @@ -169,7 +169,8 @@ def main() -> None: assert plan["mode"] == "dry-run" and plan["hard_ttl_s"] == 16200 assert plan["volume_gb"] == 50 and plan["public_services"] == 0 assert plan["heartbeat_interval_s"] == 60 - assert plan["phase_timeout_s"]["ingest"] == 3600 + assert plan["phase_timeout_s"]["ingest"] == 5400 + assert plan["phase_timeout_s"]["import"] == 5400 assert plan["phase_timeout_s"]["source_query_2hop"] == 900 source = CONTROLLER.read_text() diff --git a/scripts/fly-g500-s20.py b/scripts/fly-g500-s20.py index 9d057bc7..5e5ada2c 100644 --- a/scripts/fly-g500-s20.py +++ b/scripts/fly-g500-s20.py @@ -42,20 +42,21 @@ "drill_interrupted_finalization", ] # Phase ceilings are operational stop conditions, not performance pass criteria. -# They include measured Fly S20 headroom while ensuring a bad workload or plan -# cannot consume the entire four-hour certification envelope without a useful -# typed diagnosis. The outer hard TTL remains authoritative. +# They include measured Fly S20 headroom and observed shared-host I/O variance +# while ensuring a bad workload or plan cannot consume the entire four-hour +# certification envelope without a useful typed diagnosis. The outer hard TTL +# remains authoritative. PHASE_TIMEOUT_S = { "preflight": 15 * 60, "generate": 15 * 60, - "ingest": 60 * 60, + "ingest": 90 * 60, "csr": 20 * 60, "source_reopen": 15 * 60, "source_query_1hop": 15 * 60, "source_query_2hop": 15 * 60, "export": 45 * 60, "verify": 30 * 60, - "import": 60 * 60, + "import": 90 * 60, "imported_reopen": 15 * 60, "imported_query_1hop": 15 * 60, "imported_query_2hop": 15 * 60, From 755e82655ef58df9a0f50e841324c402cba22b23 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:13:31 -0600 Subject: [PATCH 15/16] fix(ci): preserve failed Fly evidence before teardown (#904) --- docs/development/perf-g500-ladder.md | 9 ++ scripts/ci/test-fly-g500-s20.py | 65 ++++++++++++++- scripts/fly-g500-s20.py | 118 ++++++++++++++++++++++++--- 3 files changed, 180 insertions(+), 12 deletions(-) diff --git a/docs/development/perf-g500-ladder.md b/docs/development/perf-g500-ladder.md index 6ee04a72..72a417bb 100644 --- a/docs/development/perf-g500-ladder.md +++ b/docs/development/perf-g500-ladder.md @@ -237,6 +237,15 @@ runaway spend and silence. They do not turn a partial lifecycle into a pass: success still requires the exact 17-phase journal and equivalent source/import evidence described above. The dry-run plan prints the complete timeout table. +The retrieved final artifact is written to `--evidence-out` **before** pass +validation. Consequently, an incomplete or non-pass artifact remains available +after the disposable app is destroyed instead of being lost with its volume. +All locally preserved JSON is bounded and redacts credential-shaped keys. On a +controller failure, `--diagnostic-out` (default `s20-diagnostic.json`) also +records the controller error plus an allowlisted Machine state and at most 20 +exit/status events captured before teardown. Raw Fly logs, environment values, +network addresses, and unrecognized event fields are never retained. + Ingest and clean import each have a 90-minute ceiling. A Fly S20 ingest remained responsive and made progress but crossed the former 60-minute boundary by 18 seconds, with no OOM or disk failure; the Machine, volume, and app then tore down diff --git a/scripts/ci/test-fly-g500-s20.py b/scripts/ci/test-fly-g500-s20.py index 4c441dd1..b8389fa1 100644 --- a/scripts/ci/test-fly-g500-s20.py +++ b/scripts/ci/test-fly-g500-s20.py @@ -7,6 +7,7 @@ import importlib.util import json from pathlib import Path +import subprocess import tempfile ROOT = Path(__file__).resolve().parents[2] @@ -42,6 +43,7 @@ def args(root: Path) -> argparse.Namespace: manifest_json=root / "manifest.json", evidence_out=root / "evidence.json", journal_out=root / "journal.json", + diagnostic_out=root / "diagnostic.json", execute=False, confirm_disposable=False, ) @@ -130,11 +132,68 @@ def main() -> None: else: raise AssertionError("out-of-order journal must be refused") + unsafe = { + "schema": "incomplete", + "token": "must-not-survive", + "nested": {"password_hint": "must-not-survive", "message": "bounded"}, + } + controller.write_sanitized_json(options.evidence_out, unsafe) + preserved = json.loads(options.evidence_out.read_text()) + assert preserved["schema"] == "incomplete" + assert preserved["token"] == "" + assert preserved["nested"]["password_hint"] == "" + try: + controller.preserve_and_validate_evidence( + unsafe, + phases[:2], + options.expected_sha, + options.evidence_out, + options.journal_out, + ) + except controller.ControllerError: + pass + else: + raise AssertionError("incomplete evidence must not validate") + assert json.loads(options.evidence_out.read_text())["schema"] == "incomplete" + assert len(json.loads(options.journal_out.read_text())) == 2 + + class FakeFly: + def run(self, _arguments, *, check=True): + del check + return subprocess.CompletedProcess( + [], + 0, + json.dumps( + { + "state": "stopped", + "region": "dfw", + "private_ip": "must-not-survive", + "events": [ + { + "type": "exit", + "status": "failed", + "exit_code": 137, + "request": "must-not-survive", + } + ], + } + ), + "", + ) + + diagnostic = controller.machine_diagnostic( + FakeFly(), options.app_name, "machine-id" + ) + assert diagnostic == { + "available": True, + "state": "stopped", + "region": "dfw", + "events": [{"type": "exit", "status": "failed", "exit_code": 137}], + } + (root / "pricing.html").write_text(pricing_html()) (root / "manifest.json").write_text(child) # Main dry-run exercises argument/config/rate/manifest validation without Fly. - import subprocess - result = subprocess.run( [ "python3", @@ -159,6 +218,8 @@ def main() -> None: str(options.evidence_out), "--journal-out", str(options.journal_out), + "--diagnostic-out", + str(options.diagnostic_out), ], cwd=ROOT, check=True, diff --git a/scripts/fly-g500-s20.py b/scripts/fly-g500-s20.py index 5e5ada2c..f00bba83 100644 --- a/scripts/fly-g500-s20.py +++ b/scripts/fly-g500-s20.py @@ -71,6 +71,20 @@ VOLUME_GB = 50 MEMORY_MB = 4096 CPUS = 2 +MAX_DIAGNOSTIC_EVENTS = 20 +SENSITIVE_KEY = re.compile(r"(?:auth|credential|password|secret|token)", re.IGNORECASE) +DIAGNOSTIC_EVENT_KEYS = { + "created_at", + "exit_code", + "oom_killed", + "requested_stop", + "signal", + "source", + "status", + "timestamp", + "type", + "updated_at", +} class ControllerError(RuntimeError): @@ -82,6 +96,30 @@ def emit_progress(event: str, **fields: Any) -> None: print(json.dumps({"event": event, **fields}, sort_keys=True), flush=True) +def sanitize_artifact(value: Any, *, depth: int = 0) -> Any: + """Bound diagnostic artifacts and redact credential-shaped fields.""" + if depth > 12: + return "" + if isinstance(value, dict): + return { + str(key): "" + if SENSITIVE_KEY.search(str(key)) + else sanitize_artifact(item, depth=depth + 1) + for key, item in list(value.items())[:1000] + } + if isinstance(value, list): + return [sanitize_artifact(item, depth=depth + 1) for item in value[:1000]] + if isinstance(value, str): + return value[:4096] + if value is None or isinstance(value, (bool, int, float)): + return value + return "" + + +def write_sanitized_json(path: Path, value: Any) -> None: + path.write_text(json.dumps(sanitize_artifact(value), indent=2, sort_keys=True) + "\n") + + def journal_progress(journal: Any) -> tuple[int, str | None]: """Validate an atomic journal snapshot and identify the active phase.""" if not isinstance(journal, list) or len(journal) > len(PHASES): @@ -180,7 +218,11 @@ def validate_args(args: argparse.Namespace) -> str: raise ControllerError("controller requires the approved $10 ceiling and >=$1 reserve") if args.execute and (not args.confirm_disposable or args.pricing_html or args.manifest_json): raise ControllerError("execution requires confirmation and live official pricing") - if not args.evidence_out.parent.is_dir() or not args.journal_out.parent.is_dir(): + if ( + not args.evidence_out.parent.is_dir() + or not args.journal_out.parent.is_dir() + or not args.diagnostic_out.parent.is_dir() + ): raise ControllerError("local output parents must already exist") return image.group("digest") @@ -298,6 +340,19 @@ def validate_evidence(evidence: dict[str, Any], journal: list[dict[str, Any]], s raise ControllerError(f"S20 lifecycle mismatch: {left}/{right}") +def preserve_and_validate_evidence( + evidence: dict[str, Any], + journal: list[dict[str, Any]], + sha: str, + evidence_out: Path, + journal_out: Path, +) -> None: + """Persist failure evidence before applying the success-only contract.""" + write_sanitized_json(evidence_out, evidence) + write_sanitized_json(journal_out, journal) + validate_evidence(evidence, journal, sha) + + def destroy_and_verify( fly: Flyctl, app: str, machine_id: str | None, volume_id: str | None ) -> None: @@ -334,6 +389,30 @@ def retrieve(fly: Flyctl, app: str, machine: str, remote: str, local: Path) -> b return result.returncode == 0 and local.is_file() +def machine_diagnostic(fly: Flyctl, app: str, machine: str) -> dict[str, Any]: + """Return a small allowlisted status record; never retain raw Machine logs.""" + result = fly.run(["machine", "status", machine, "--app", app, "--json"], check=False) + if result.returncode != 0: + return {"available": False, "status_returncode": result.returncode} + try: + status = json.loads(result.stdout) + except json.JSONDecodeError: + return {"available": False, "status_returncode": 0, "status_json": "invalid"} + events = status.get("events", []) if isinstance(status, dict) else [] + safe_events = [] + for event in events[-MAX_DIAGNOSTIC_EVENTS:] if isinstance(events, list) else []: + if isinstance(event, dict): + safe_events.append( + {key: event[key] for key in DIAGNOSTIC_EVENT_KEYS if key in event} + ) + return { + "available": True, + "state": status.get("state") if isinstance(status, dict) else None, + "region": status.get("region") if isinstance(status, dict) else None, + "events": safe_events, + } + + def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: app_created = False machine_id = volume_id = None @@ -386,9 +465,7 @@ def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: observed_completed, observed_active = journal_progress(journal) except ControllerError as error: if str(error).startswith("phase_failed "): - args.journal_out.write_text( - json.dumps(journal, indent=2, sort_keys=True) + "\n" - ) + write_sanitized_json(args.journal_out, journal) raise if observed_completed < completed: raise ControllerError("journal_invalid completed phase count regressed") @@ -409,9 +486,7 @@ def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: emit_progress("phase_start", phase=active_phase) # Preserve the last valid incomplete journal even when a # controller deadline stops and destroys the Machine. - args.journal_out.write_text( - json.dumps(journal, indent=2, sort_keys=True) + "\n" - ) + write_sanitized_json(args.journal_out, journal) if retrieve( fly, args.app_name, machine_id, "/work/s20-evidence.json", evidence_path ): @@ -438,9 +513,13 @@ def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: raise ControllerError("run_timeout 4h30 hard deadline reached before S20 evidence") evidence = json.loads(evidence_path.read_text()) journal = json.loads(journal_path.read_text()) - validate_evidence(evidence, journal, args.expected_sha) - args.evidence_out.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n") - args.journal_out.write_text(json.dumps(journal, indent=2, sort_keys=True) + "\n") + preserve_and_validate_evidence( + evidence, + journal, + args.expected_sha, + args.evidence_out, + args.journal_out, + ) fly.run( [ "machine", @@ -451,6 +530,22 @@ def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: "touch /work/controller-ack", ] ) + except (ControllerError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as error: + diagnostic: dict[str, Any] = { + "schema": "graphforge-s20-controller-diagnostic/1", + "result": "fail", + "controller_error": str(error), + "git_sha": args.expected_sha, + } + if machine_id: + try: + diagnostic["machine"] = machine_diagnostic( + fly, args.app_name, machine_id + ) + except (OSError, subprocess.SubprocessError, json.JSONDecodeError): + diagnostic["machine"] = {"available": False, "status_error": "query_failed"} + write_sanitized_json(args.diagnostic_out, diagnostic) + raise finally: if app_created: destroy_and_verify(fly, args.app_name, machine_id, volume_id) @@ -471,6 +566,9 @@ def parser() -> argparse.ArgumentParser: result.add_argument("--manifest-json", type=Path, help="dry-run manifest fixture only") result.add_argument("--evidence-out", type=Path, default=Path("s20-evidence.json")) result.add_argument("--journal-out", type=Path, default=Path("s20-journal.json")) + result.add_argument( + "--diagnostic-out", type=Path, default=Path("s20-diagnostic.json") + ) result.add_argument("--execute", action="store_true") result.add_argument("--confirm-disposable", action="store_true") return result From ea3209eba8279f65991e6a98ab567ea192519059 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:16:35 -0600 Subject: [PATCH 16/16] style: format Fly diagnostics controller (#904) --- scripts/ci/test-fly-g500-s20.py | 4 +--- scripts/fly-g500-s20.py | 12 +++--------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/scripts/ci/test-fly-g500-s20.py b/scripts/ci/test-fly-g500-s20.py index b8389fa1..327b18d9 100644 --- a/scripts/ci/test-fly-g500-s20.py +++ b/scripts/ci/test-fly-g500-s20.py @@ -181,9 +181,7 @@ def run(self, _arguments, *, check=True): "", ) - diagnostic = controller.machine_diagnostic( - FakeFly(), options.app_name, "machine-id" - ) + diagnostic = controller.machine_diagnostic(FakeFly(), options.app_name, "machine-id") assert diagnostic == { "available": True, "state": "stopped", diff --git a/scripts/fly-g500-s20.py b/scripts/fly-g500-s20.py index f00bba83..834a20c2 100644 --- a/scripts/fly-g500-s20.py +++ b/scripts/fly-g500-s20.py @@ -402,9 +402,7 @@ def machine_diagnostic(fly: Flyctl, app: str, machine: str) -> dict[str, Any]: safe_events = [] for event in events[-MAX_DIAGNOSTIC_EVENTS:] if isinstance(events, list) else []: if isinstance(event, dict): - safe_events.append( - {key: event[key] for key in DIAGNOSTIC_EVENT_KEYS if key in event} - ) + safe_events.append({key: event[key] for key in DIAGNOSTIC_EVENT_KEYS if key in event}) return { "available": True, "state": status.get("state") if isinstance(status, dict) else None, @@ -539,9 +537,7 @@ def execute(args: argparse.Namespace, fly: Flyctl, digest: str) -> None: } if machine_id: try: - diagnostic["machine"] = machine_diagnostic( - fly, args.app_name, machine_id - ) + diagnostic["machine"] = machine_diagnostic(fly, args.app_name, machine_id) except (OSError, subprocess.SubprocessError, json.JSONDecodeError): diagnostic["machine"] = {"available": False, "status_error": "query_failed"} write_sanitized_json(args.diagnostic_out, diagnostic) @@ -566,9 +562,7 @@ def parser() -> argparse.ArgumentParser: result.add_argument("--manifest-json", type=Path, help="dry-run manifest fixture only") result.add_argument("--evidence-out", type=Path, default=Path("s20-evidence.json")) result.add_argument("--journal-out", type=Path, default=Path("s20-journal.json")) - result.add_argument( - "--diagnostic-out", type=Path, default=Path("s20-diagnostic.json") - ) + result.add_argument("--diagnostic-out", type=Path, default=Path("s20-diagnostic.json")) result.add_argument("--execute", action="store_true") result.add_argument("--confirm-disposable", action="store_true") return result