diff --git a/crates/graphforge-api/src/embedding_refresh.rs b/crates/graphforge-api/src/embedding_refresh.rs index 5ed0c0b96..da52d5798 100644 --- a/crates/graphforge-api/src/embedding_refresh.rs +++ b/crates/graphforge-api/src/embedding_refresh.rs @@ -266,6 +266,7 @@ impl GraphForge { graph_open_evidence: self.graph_open_evidence.clone(), project_open_recovery: self.project_open_recovery.clone(), tempdir: self.tempdir.clone(), + adjacency_cache_guard: Arc::clone(&self.adjacency_cache_guard), ontology: self.ontology.clone(), ontology_document: self.ontology_document.clone(), runtime_catalog: Arc::clone(&self.runtime_catalog), diff --git a/crates/graphforge-api/src/lib.rs b/crates/graphforge-api/src/lib.rs index 81472a5cc..085217ccb 100644 --- a/crates/graphforge-api/src/lib.rs +++ b/crates/graphforge-api/src/lib.rs @@ -212,7 +212,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, @@ -449,6 +451,8 @@ pub struct GraphForge { dir: PathBuf, /// Keeps the private mutable graph workspace alive for the engine's life. workspace_guard: Arc, + /// Private derived-adjacency namespace on the admitted data volume. + 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). @@ -605,6 +609,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)?); let property_inventory = property_inventory_for_hydrated_generation(&resolved_generation, &dir)?; Ok(Self { @@ -621,10 +626,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)?, @@ -644,6 +652,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)), @@ -747,6 +756,14 @@ impl GraphForge { load_workspace_ontology(&resolved_generation)?; let (dir, workspace, graph_open_evidence) = hydrate_graph_workspace(&resolved_generation, read_only)?; + let adjacency_cache = Arc::new(create_persistent_adjacency_cache( + &container_dir, + &resource_policy, + )?); + let adjacency_source = + graphforge_storage::adjacency::AdjacencySourceIdentity::from_generation( + &resolved_generation, + ); let property_inventory = property_inventory_for_hydrated_generation(&resolved_generation, &dir)?; @@ -828,10 +845,14 @@ 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_authenticated_cache( + dir.clone(), + adjacency_cache.path(), + ontology_mode, + adjacency_source, + ), + ), adjacency_visibility: Arc::new(std::sync::RwLock::new(())), embedding_refresh_scheduler: Arc::new(Mutex::new( embedding_refresh::initialize_embedding_refresh_scheduler(&dir)?, @@ -848,6 +869,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, @@ -1015,6 +1037,177 @@ impl GraphForge { self.execute_with_params(cypher, &HashMap::new()) } + /// Execute a read query and return query-isolated operator evidence. + /// + /// Evidence is returned with both successful and failed execution so a + /// qualification runner never has to infer operator work from process-wide + /// counters. Write queries are rejected: their publication lifecycle has a + /// separate evidence contract. + #[must_use] + pub fn execute_observed(&self, cypher: &str) -> ObservedExecution { + self.execute_with_params_observed(cypher, &HashMap::new()) + } + + /// Parameterized form of [`execute_observed`](Self::execute_observed). + #[must_use] + pub fn execute_with_params_observed( + &self, + cypher: &str, + params: &HashMap, + ) -> ObservedExecution { + match self.prepare_observed_read(cypher, params) { + Ok(observed) => observed, + Err(error) => ObservedExecution { + result: Err(publicize_query_error(error)), + evidence: graphforge_exec::demand::DemandSnapshot::default(), + }, + } + } + + fn adjacency_provider_for_mode( + &self, + execution_mode: OntologyMode, + ) -> Arc { + if self.tempdir.is_some() { + // Mutable workspaces have no immutable generation authority to + // authenticate a cross-query derived cache. Keep their provider + // query-scoped; a coarse generation counter is insufficient when + // one statement performs multiple topology mutations. + return Arc::new( + graphforge_exec::PersistentAdjacencyProvider::new_with_cache( + self.dir.clone(), + self.adjacency_cache_guard.path(), + execution_mode, + ), + ); + } + if execution_mode == self.ontology_mode { + return Arc::clone(&self.adjacency_provider); + } + Arc::new( + graphforge_exec::PersistentAdjacencyProvider::new_with_authenticated_cache( + self.dir.clone(), + self.adjacency_cache_guard.path(), + execution_mode, + graphforge_storage::adjacency::AdjacencySourceIdentity::from_generation( + &self.resolved_generation, + ), + ), + ) + } + + #[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(|error| GfError::Parse { + msg: error.message, + span: error.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 runtime_catalog = self + .runtime_catalog + .lock() + .expect("runtime catalog poisoned"); + let bindings = self + .semantic_storage_bindings + .lock() + .expect("semantic storage binding lock poisoned"); + GraphCatalog::open_authenticated_with_semantic_bindings( + &self.dir, + self.ontology.as_ref(), + &runtime_catalog, + composition + .as_ref() + .map(|(_, candidate, _)| candidate) + .or(bindings.as_ref()), + self.property_inventory_for_session(), + ) + .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 = self.adjacency_provider_for_mode(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 @@ -1381,14 +1574,7 @@ impl GraphForge { // legacy workspace ontology profile remains exploratory. Its writes // must never fall back to `_untyped` host routing. let execution_mode = composition_mode.unwrap_or(self.ontology_mode); - 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, - )) - }; + let adjacency_provider = self.adjacency_provider_for_mode(execution_mode); let session = ExecutionSession::new_with_target_provider_and_resources( catalog, self.ontology.clone(), @@ -3342,10 +3528,16 @@ 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_authenticated_cache( + self.dir.clone(), + self.adjacency_cache_guard.path(), + self.ontology_mode, + graphforge_storage::adjacency::AdjacencySourceIdentity::from_generation( + &self.resolved_generation, + ), + ), + ); } Ok(()) } @@ -3840,6 +4032,33 @@ fn property_inventory_for_hydrated_generation( Ok(Arc::new(admitted)) } +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 create_persistent_adjacency_cache( + project_root: &std::path::Path, + policy: &resource_policy::NormalizedResourcePolicy, +) -> Result { + 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, @@ -4558,6 +4777,26 @@ mod tests { const ABSENT_TARGET_COOKIE: &str = "graphforge-absent-target-open-v1"; const ABSENT_TARGET_DEADLINE: Duration = Duration::from_secs(10); + #[test] + fn derived_adjacency_cache_uses_selected_volume_and_releases_on_drop() { + 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().to_path_buf(); + 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().to_path_buf(); + assert_eq!(spill_path.parent(), Some(spill.path())); + drop(spill_cache); + assert!(!spill_path.exists()); + } + fn publish_compact_graph_workspace(project: &Path, workspace: &Path) { use graphforge_core::canonical::{ CANONICAL_CONTRACT_VERSION, CanonicalDomain, fingerprint, diff --git a/crates/graphforge-api/tests/fixed_hop_limit.rs b/crates/graphforge-api/tests/fixed_hop_limit.rs index 35b7d4623..845752c04 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}; @@ -188,13 +188,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)] @@ -332,16 +331,15 @@ 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(), + observed.evidence, edges, u64::try_from(nodes.div_ceil(WRITE_WINDOW)).unwrap(), ) @@ -408,13 +406,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); } } @@ -427,11 +424,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 @@ -577,6 +573,64 @@ 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_bounded_and_released() { + let _guard = IO_GUARD.lock().unwrap(); + let dir = TempDir::new().unwrap(); + generate_graph(dir.path(), 4_096, FAN_OUT); + let forge = open_forge(dir.path()); + let query = "MATCH ()-[r]->(b) RETURN b.node_id AS id ORDER BY id DESC LIMIT 100"; + let plan = forge.explain(query).unwrap(); + assert!(plan.contains("fetch=100"), "{plan}"); + + let observed = forge.execute_observed(query); + let result = observed.result.unwrap(); + assert_eq!(result.stats.rows_produced, 100); + assert_eq!(observed.evidence.sorts.len(), 1, "{:#?}", observed.evidence); + let sort = &observed.evidence.sorts[0]; + assert_eq!(sort.fetch, Some(100)); + assert_eq!(sort.output_rows, 100); + assert_eq!(sort.spill_count, 0); + assert_eq!(sort.spilled_bytes, 0); + assert_eq!(sort.memory_used_after, 0); + assert!( + observed + .evidence + .memory_reserved_after + .saturating_sub(observed.evidence.memory_reserved_before) + <= observed.evidence.returned_batch_bytes, + "operator allocation did not quiesce: {:#?}", + observed.evidence + ); +} + +#[test] +fn observed_public_surface_is_parameterized_and_query_scoped() { + let _guard = IO_GUARD.lock().unwrap(); + let dir = TempDir::new().unwrap(); + generate_graph(dir.path(), 128, FAN_OUT); + let forge = open_forge(dir.path()); + + let direct = forge.execute_observed(ONE_HOP); + assert_eq!(direct.result.unwrap().stats.rows_produced, LIMIT as u64); + assert_eq!(direct.evidence.hops.len(), 1); + + let params = HashMap::from([("n".to_owned(), IrLiteral::Int(100))]); + let parameterized = forge.execute_with_params_observed( + "MATCH (a)-[r]->(b) RETURN b.node_uuid AS id LIMIT $n", + ¶ms, + ); + assert_eq!(parameterized.result.unwrap().stats.rows_produced, 100); + assert_eq!(parameterized.evidence.hops.len(), 1); + assert!( + parameterized + .evidence + .memory_reserved_after + .saturating_sub(parameterized.evidence.memory_reserved_before) + <= parameterized.evidence.returned_batch_bytes + ); +} + fn env_usize(key: &str, default: usize) -> usize { match std::env::var(key) { Ok(value) => value @@ -615,18 +669,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 84518fa92..9a166d80f 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,27 +1229,30 @@ 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 io = io_stats::snapshot(); - assert_eq!(result.stats.rows_produced, 3); + assert_eq!( + observed + .result + .as_ref() + .expect("fixed-hop execute") + .stats + .rows_produced, + 3 + ); // Small fixture may not cancel upstream reads; still require demand/plan surface. assert!( - !demand_snap.hops.is_empty() + !observed.evidence.operator_rss.expand_by_hop.is_empty() || plan.contains("ExpandExec") || plan.contains("expand") || plan.to_lowercase().contains("limit"), - "expected expansion/demand/limit surface; demand={demand_snap:#?} plan={plan} io={io:#?}" + "expected expansion/demand/limit surface; evidence={:#?} plan={plan} io={io:#?}", + observed.evidence ); } diff --git a/crates/graphforge-api/tests/scale_g500_ladder.rs b/crates/graphforge-api/tests/scale_g500_ladder.rs index 677a0a27b..e59983ede 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}; @@ -31,11 +31,13 @@ use arrow::array::{Array, FixedSizeBinaryArray, Int64Array, StringArray, UInt64A use arrow::record_batch::RecordBatch; use graphforge_api::{ CONSTRUCTION_EDGE_SCHEMA, CONSTRUCTION_NODE_SCHEMA, CancellationToken, - GraphConstructionBudgets, GraphConstructionSession, GraphForge, OperationId, PortableSelection, - PortableV2ExportRequest, PortableV2ImportRequest, PortableV2Limits, PortableV2Mode, - PortableV2Output, PortableV2SelectionProfile, PortableVerifyRequest, verify_portable_v2, + GraphConstructionBudgets, GraphConstructionSession, GraphForge, IrLiteral, ObservedExecution, + OperationId, PortableSelection, PortableV2ExportRequest, PortableV2ImportRequest, + PortableV2Limits, PortableV2Mode, PortableV2Output, PortableV2SelectionProfile, + PortableVerifyRequest, verify_portable_v2, }; use graphforge_core::uuid::Uuid; +use graphforge_exec::demand; use serde::Deserialize; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -55,11 +57,108 @@ 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 ROOTED_ONE_HOP: &str = + "MATCH (a)-[r]->(b) WHERE a.node_uuid = $root RETURN b.node_uuid AS id ORDER BY id LIMIT 1000"; +const ROOTED_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); +fn execute_rooted_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); + 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, + "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, + })).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, + "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_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_evidence_passes( + 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")) + }) + && snapshot + .memory_reserved_after + .saturating_sub(snapshot.memory_reserved_before) + <= snapshot.returned_batch_bytes +} + +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). // --------------------------------------------------------------------------- @@ -571,6 +670,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(), + }, }) } @@ -963,10 +1067,11 @@ fn run_rung( ); } - // ---- reopen + recount ---- + // ---- reopen + independently journaled count/query boundaries ---- let mut node_count = 0u64; let mut edge_count = 0u64; let mut gsi = String::new(); + let mut query_memory_budget_bytes = 0u64; if first_failing_phase.is_none() { persist_phase_journal( profile, @@ -980,10 +1085,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"); @@ -994,7 +1096,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": { "rss_after_bytes": linux_memory_bytes() } })); persist_phase_journal( profile, @@ -1010,47 +1113,165 @@ fn run_rung( first_failing_phase.zip(error_class), ); - // ---- deterministic LIMIT queries ---- + // Every potentially large operation gets a durable boundary. The + // original unrooted ordered LIMIT probes remain the S20 contract; + // rooted probes are additional attribution evidence only. if first_failing_phase.is_none() { - let hop1_started = Instant::now(); persist_phase_journal( profile, rung, completed_rungs, - "query", + "node_count", "running", &steps, None, ); - let hop1 = graph.execute(ONE_HOP).expect("one-hop LIMIT"); - let hop1_rows = row_count(&hop1); + let started = Instant::now(); + node_count = graph.node_count(NODE_LABEL).expect("node_count"); + let expected = 1u64 << rung.scale; + let mut violation = envelope_violation(&env, ladder_started, &project, &spill_dir); + if node_count != expected { + violation = Some("result_mismatch"); + } + if let Some(class) = violation { + first_failing_phase = Some("node_count"); + error_class = Some(class); + } steps.push(json!({ - "id": "cypher_limit_1hop", - "pass": hop1_rows <= 1_000, - "wall_time_s": hop1_started.elapsed().as_secs_f64(), - "detail": { "rows": hop1_rows } + "id": "node_count", "pass": violation.is_none(), + "wall_time_s": started.elapsed().as_secs_f64(), "rss_peak_bytes": rss_value(), + "process_memory": linux_process_memory(), + "detail": { "node_count": node_count, "expected": expected } })); + 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), + ); + } - let hop2_started = Instant::now(); - let hop2 = graph.execute(TWO_HOP).expect("two-hop LIMIT"); - let hop2_rows = row_count(&hop2); + if first_failing_phase.is_none() { + persist_phase_journal( + profile, + rung, + completed_rungs, + "edge_count", + "running", + &steps, + None, + ); + let started = Instant::now(); + let observed = graph.execute_observed(COUNT_EDGES); + edge_count = scalar_count(observed.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 + .evidence + .memory_reserved_after + .saturating_sub(observed.evidence.memory_reserved_before) + > observed.evidence.returned_batch_bytes + { + violation = Some("memory_limit"); + } + if let Some(class) = violation { + first_failing_phase = Some("edge_count"); + error_class = Some(class); + } steps.push(json!({ - "id": "cypher_limit_2hop", - "pass": hop2_rows <= 1_000, - "wall_time_s": hop2_started.elapsed().as_secs_f64(), - "detail": { "rows": hop2_rows } + "id": "edge_count", "pass": violation.is_none(), + "wall_time_s": 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, + "operators": query_operator_evidence(&observed.evidence, graph.resource_policy().memory_budget_bytes) } })); - let query_violation = envelope_violation(&env, ladder_started, &project, &spill_dir); - if let Some(class) = query_violation { - first_failing_phase = Some("query"); + 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), + ); + } + + for (phase, query, rooted_query, expected_hops) in [ + ("one_hop", ONE_HOP, ROOTED_ONE_HOP, 1usize), + ("two_hop", TWO_HOP, ROOTED_TWO_HOP, 2usize), + ] { + if first_failing_phase.is_some() { + break; + } + persist_phase_journal( + profile, + rung, + completed_rungs, + phase, + "running", + &steps, + None, + ); + let started = Instant::now(); + let observed = graph.execute_observed(query); + let rooted = execute_rooted_observed(&graph, rooted_query); + let memory_budget = graph.resource_policy().memory_budget_bytes; + query_memory_budget_bytes = memory_budget; + let failure = observed.result.as_ref().err().map(ToString::to_string); + let rooted_failure = rooted.result.as_ref().err().map(ToString::to_string); + let rows = observed.result.as_ref().map_or(0, row_count); + let rooted_rows = rooted.result.as_ref().map_or(0, row_count); + let mut violation = envelope_violation(&env, ladder_started, &project, &spill_dir); + if failure.is_some() || rooted_failure.is_some() { + violation = Some("execution_failure"); + } else if rows > 1_000 || rooted_rows > 1_000 { + violation = Some("result_mismatch"); + } else if !operator_evidence_passes( + &observed.evidence, + expected_hops, + memory_budget, + env.rss_bytes, + ) || !operator_evidence_passes( + &rooted.evidence, + expected_hops, + memory_budget, + env.rss_bytes, + ) { + violation = Some("memory_limit"); + } + if let Some(class) = violation { + first_failing_phase = Some(phase); error_class = Some(class); } + steps.push(json!({ + "id": phase, "pass": violation.is_none(), + "wall_time_s": started.elapsed().as_secs_f64(), "rss_peak_bytes": rss_value(), + "process_memory": linux_process_memory(), + "detail": { "probe": "unrooted_ordered_limit", "rows": rows, + "execution_failure": failure, "operators": query_operator_evidence(&observed.evidence, memory_budget), + "rooted_additional": { "rows": rooted_rows, "execution_failure": rooted_failure, + "operators": query_operator_evidence(&rooted.evidence, memory_budget) } } + })); persist_phase_journal( profile, rung, completed_rungs, - "query", - if query_violation.is_some() { + phase, + if violation.is_some() { "phase_failed" } else { "phase_completed" @@ -1078,6 +1299,12 @@ fn run_rung( Some((bytes, source)) => (json!(bytes), json!(source)), 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, @@ -1126,6 +1353,17 @@ 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": query_memory_budget_bytes > 0 + && max_operator_working_set_bytes <= query_memory_budget_bytes + && lower_rungs_within_same_budget, + }, }); RungOutcome { passed, evidence } @@ -1758,6 +1996,35 @@ fn ci_rung_public_facade_engineering_green() { 1u64 << ci_rung.scale ); assert!(live > 0, "CI rung must persist a non-empty graph"); + let steps = ev["steps"].as_array().expect("rung steps"); + assert_eq!( + steps + .iter() + .map(|step| step["id"].as_str().expect("step id")) + .collect::>(), + [ + "generate", + "ingest", + "reopen", + "node_count", + "edge_count", + "one_hop", + "two_hop", + ] + ); + for phase in ["one_hop", "two_hop"] { + let step = steps + .iter() + .find(|step| step["id"] == phase) + .expect("query phase"); + assert_eq!(step["detail"]["probe"], "unrooted_ordered_limit"); + assert!(step["detail"]["rooted_additional"].is_object()); + assert_eq!( + step["detail"]["operators"]["operator_memory_quiescent"], + true + ); + } + assert_eq!(ev["operator_memory_contract"]["pass"], true); } /// Provisioned full ladder (SCALE-20 → SCALE-26). Opt-in via @@ -1851,6 +2118,10 @@ 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" + ); let directory = TempDir::new().expect("journal directory"); let path = directory.path().join("journal.json"); 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 dbdd8cf0b..af1d9d877 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": 257, - "releaseSurfaceDigest": "bb7d3f89204eb3115fd425081d45e874251a20de9759355e867d87e005063219", + "releaseSurfaceCount": 259, + "releaseSurfaceDigest": "8c8420d208d71a2990c7b2ecdf810c27e8568054207760e74a999472fe416908", "requiredEquivalent": [ "GraphForge.adopt_ontology", "GraphForge.clear_ontology", diff --git a/crates/graphforge-bindings-py/tests/non_cypher_release.py b/crates/graphforge-bindings-py/tests/non_cypher_release.py index 1d46e3f19..6af870d93 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 = "71c92e47b6e43da553be7288724d522e303da5d4677e60f51e7ea8d9f8a41b99" -EXPECTED_RELEASE_DIGEST = "bb7d3f89204eb3115fd425081d45e874251a20de9759355e867d87e005063219" +EXPECTED_RUST_DIGEST = "5b940b936d8d142221461b8a91896d04d588e032b510bec42a1c2f9b962c6041" +EXPECTED_RELEASE_DIGEST = "8c8420d208d71a2990c7b2ecdf810c27e8568054207760e74a999472fe416908" PYTHON_ONLY_METHODS = frozenset( { @@ -257,7 +257,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) == 257 + assert len(release_methods) == 259 assert _digest(release_methods) == EXPECTED_RELEASE_DIGEST assert set(EVIDENCE) == set(manifest["method_evidence_groups"]) diff --git a/crates/graphforge-exec/BUILD.bazel b/crates/graphforge-exec/BUILD.bazel index 2e17b9c92..401a14bd0 100644 --- a/crates/graphforge-exec/BUILD.bazel +++ b/crates/graphforge-exec/BUILD.bazel @@ -12,6 +12,7 @@ filegroup( ) _EXEC_DEPS = [ + "@crates//:tokio", "//crates/graphforge-core:graphforge_core", "//crates/graphforge-ir:graphforge_ir", "//crates/graphforge-ontology:graphforge_ontology", diff --git a/crates/graphforge-exec/Cargo.toml b/crates/graphforge-exec/Cargo.toml index a42d3161c..b51db097b 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 e53abe865..f9692a00f 100644 --- a/crates/graphforge-exec/src/adjacency.rs +++ b/crates/graphforge-exec/src/adjacency.rs @@ -9,18 +9,20 @@ //! //! - [`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. use std::collections::HashMap; +use std::fmt::Write as _; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -28,8 +30,8 @@ use arrow::record_batch::RecordBatch; 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, + self as csr, ALL_RELATIONS_STEM, AdjacencyManifestRow, AdjacencySourceIdentity, CsrIndex, + CsrRow, ShardedCsrIndex, adjacency_relation_key, is_adjacency_relation_key, }; use graphforge_storage::adjacency_delta::{ CsrDeltaOverlay, DeltaSegment, overlay_delta_segments, read_delta_chain, @@ -46,11 +48,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 +462,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. @@ -606,6 +608,9 @@ enum IndexState { /// read — what [`PersistentAdjacencyProvider::revalidate`] compares /// against for cheap cross-query freshness (#832). generation: u64, + /// Authenticated immutable source identity owning this private cache. + /// `None` is limited to legacy mutable sessions. + source_identity: Option, /// The manifest rows. rows: Vec, /// The delta chain (#765) overlaid on the base CSRs to reach @@ -617,20 +622,22 @@ 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, + source_identity: Option, + 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 +647,64 @@ 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, None) + } + + /// 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, None) + } + + /// A provider for one authenticated immutable project generation. + #[must_use] + pub fn new_with_authenticated_cache( + dir: PathBuf, + cache_root: &std::path::Path, + _mode: OntologyMode, + source_identity: AdjacencySourceIdentity, + ) -> 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 digest = source_identity.generation_manifest_sha256.iter().fold( + String::with_capacity(64), + |mut digest, byte| { + write!(&mut digest, "{byte:02x}").expect("writing to a String cannot fail"); + digest + }, + ); + let artifact_dir = cache_root.join(format!( + "generation-{}-{digest}-provider-{id}", + source_identity.generation_uuid.hyphenated() + )); + Self::with_artifact_dir(dir, artifact_dir, Some(source_identity)) + } + + fn with_artifact_dir( + dir: PathBuf, + artifact_dir: PathBuf, + source_identity: Option, + ) -> Self { + let active_artifact = if csr::adjacency_dir(&dir).exists() { + dir.clone() + } else { + artifact_dir.clone() + }; Self { - scan: ScanBuildAdjacencyProvider::new(dir.clone(), mode), dir, + source_identity, + cache_dir: artifact_dir, + artifact_dir: Mutex::new(active_artifact), + rebuild: Mutex::new(()), state: Mutex::new(None), cache: Mutex::new(HashMap::new()), } @@ -657,26 +718,35 @@ 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, self.source_identity)) .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, + source_identity: Option, + ) -> 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 +754,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()), @@ -700,6 +782,7 @@ impl PersistentAdjacencyProvider { IndexState::Ready { fresh, generation, + source_identity, rows, deltas: Arc::new(deltas), } @@ -718,6 +801,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 +828,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 +905,136 @@ 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, + source_identity, + rows, + deltas, + } = Self::read_state(&self.dir, &active_artifact, self.source_identity) + { + 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, + source_identity, 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, + source_identity: self.source_identity, + 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 +1047,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 @@ -873,8 +1072,12 @@ impl PersistentAdjacencyProvider { Some(IndexState::Ready { fresh: true, generation, + source_identity, .. - }) => !read_topology_generation(&self.dir).is_ok_and(|g| g == *generation), + }) => source_identity.map_or_else( + || !read_topology_generation(&self.dir).is_ok_and(|g| g == *generation), + |observed| Some(observed) != self.source_identity, + ), // 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 @@ -923,7 +1126,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 +1169,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 +1222,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 +1272,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 +1364,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 +1495,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 waiter_view = waiter.join().unwrap().unwrap(); + assert!(Arc::ptr_eq(&built, &waiter_view)); + }); + } + + #[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 { @@ -1320,4 +1750,55 @@ mod tests { assert_eq!(AdjacencyStatus::Hit.as_str(), "hit"); assert_eq!(AdjacencyStatus::Miss.as_str(), "miss"); } + + #[test] + fn same_numeric_generation_and_source_metadata_cannot_alias_authenticated_cache() { + let source = TempDir::new().unwrap(); + let cache = TempDir::new().unwrap(); + write_diamond(source.path()); + let numeric_generation = read_topology_generation(source.path()).unwrap(); + let first = AdjacencySourceIdentity { + generation_uuid: Uuid::from_u128(1), + generation_manifest_sha256: [0x11; 32], + }; + let second = AdjacencySourceIdentity { + generation_uuid: Uuid::from_u128(1), + generation_manifest_sha256: [0x22; 32], + }; + let first_provider = PersistentAdjacencyProvider::new_with_authenticated_cache( + source.path().to_path_buf(), + cache.path(), + OntologyMode::Strict, + first, + ); + let second_provider = PersistentAdjacencyProvider::new_with_authenticated_cache( + source.path().to_path_buf(), + cache.path(), + OntologyMode::Strict, + second, + ); + + assert_eq!(first_provider.source_identity, Some(first)); + assert_eq!(second_provider.source_identity, Some(second)); + assert_eq!( + read_topology_generation(source.path()).unwrap(), + numeric_generation + ); + assert_ne!(first_provider.cache_dir, second_provider.cache_dir); + let first_name = first_provider + .cache_dir + .file_name() + .unwrap() + .to_string_lossy(); + assert!(first_name.contains(&first.generation_uuid.hyphenated().to_string())); + assert!(first_name.contains(&"11".repeat(32))); + + // Both providers see the exact same source pathname, numeric topology + // generation, lengths, and timestamps. A derived publication selected + // under the first authenticated manifest still cannot become visible + // through the second identity's private key. + std::fs::create_dir_all(&first_provider.cache_dir).unwrap(); + std::fs::write(first_provider.cache_dir.join("published"), b"same-metadata").unwrap(); + assert!(!second_provider.cache_dir.join("published").exists()); + } } diff --git a/crates/graphforge-exec/src/demand.rs b/crates/graphforge-exec/src/demand.rs index 1d1e51d2b..cb8e4c24f 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,74 +115,462 @@ 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, + /// 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, } -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, + /// 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. +#[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, +} + +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, + expand_lifetimes: Mutex>, + sort_exclusive: Mutex, + stop: AtomicBool, +} + +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), + 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, + } + } +} -/// 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); +/// 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), + 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) } -/// Disable demand capture without discarding the last snapshot. -#[doc(hidden)] -pub fn disable() { - CAPTURE_ENABLED.store(false, Ordering::SeqCst); +struct SamplerGuard { + capture: Arc, + sampler: Option>, } -/// Copy the current fixed-hop demand counters. -#[must_use] -#[doc(hidden)] -pub fn snapshot() -> DemandSnapshot { - CAPTURE.lock().expect("demand stats lock").clone() +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)); +} + +/// 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 { - CAPTURE_ENABLED.load(Ordering::Relaxed) + ACTIVE_CAPTURE.try_with(|_| ()).is_ok() +} + +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); + 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)); + } + #[cfg(test)] + ACTIVE_SAMPLERS.fetch_sub(1, Ordering::AcqRel); +} + +pub(crate) struct OperatorActivity { + kind: OperatorKind, + capture: Option>, +} +enum OperatorKind { + Expand(u32), + Sort, +} +impl OperatorActivity { + #[cfg(test)] + 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 { + 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, + 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 } + } +} + +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); + 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; + } + } + } + } + } +} + +pub(crate) fn record_memory_before(bytes: usize) { + with_capture(|capture| { + capture + .snapshot + .lock() + .expect("query capture lock") + .memory_reserved_before = bytes as u64; + }); } -fn with_hop(edge_var: u32, update: impl FnOnce(&mut HopSnapshot)) { - if !capture_enabled() { - return; +/// Capture metrics only after collection has dropped every operator stream. +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) + .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; + snapshot.returned_batch_bytes = returned_batch_bytes as u64; + }); } +fn with_hop_handle( + handle: Option<&CaptureHandle>, + edge_var: u32, + update: impl FnOnce(&mut HopSnapshot), +) { + with_handle(handle, |capture| { + update( + capture + .snapshot + .lock() + .expect("query capture lock") + .hops + .entry(edge_var) + .or_default(), + ); + }); +} + +#[cfg(test)] 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) { - 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. @@ -191,6 +580,7 @@ pub(crate) struct QueryDemand { max_in_flight_reads: AtomicUsize, produced_rows: AtomicUsize, quiescent_waker: AtomicWaker, + capture: Option, } impl QueryDemand { @@ -201,6 +591,7 @@ impl QueryDemand { max_in_flight_reads: AtomicUsize::new(0), produced_rows: AtomicUsize::new(0), quiescent_waker: AtomicWaker::new(), + capture: capture_handle(), } } @@ -209,8 +600,14 @@ impl QueryDemand { } fn cancel(&self) { - if !self.cancelled.swap(true, Ordering::AcqRel) && capture_enabled() { - CAPTURE.lock().expect("demand stats lock").cancellations += 1; + if !self.cancelled.swap(true, Ordering::AcqRel) && self.capture.is_some() { + with_handle(self.capture.as_ref(), |capture| { + capture + .snapshot + .lock() + .expect("query capture lock") + .cancellations += 1; + }); } } @@ -221,19 +618,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() { - let mut capture = CAPTURE.lock().expect("demand stats lock"); - capture.max_in_flight_reads = capture.max_in_flight_reads.max(current as u64); + 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 { @@ -273,24 +676,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, }); @@ -302,7 +706,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; @@ -317,7 +721,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, }); @@ -331,7 +735,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; @@ -784,7 +1188,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 +1199,112 @@ mod tests { use super::*; - #[test] - fn capture_accounts_for_every_hop_filter_and_storage_outcome() { - use graphforge_storage::io_stats::{ - FilteredReadObserver, FilteredReadPruning, FilteredReadStrategy, FilteredReadTable, - }; + static OBSERVATION_TEST_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); - // 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, - }, - ); - - 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) - ); + #[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, 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!( - ( - hop.node_exact_rows_selected, - hop.node_metadata_fallbacks, - hop.node_validation_fallbacks - ), - (6, 3, 3) + (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 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(); + 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); + + 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); + } + + #[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!( - ( - captured.filters[&4].input_rows, - captured.filters[&4].output_rows - ), - (7, 2) + snapshot + .operator_rss + .expand_by_hop + .keys() + .copied() + .collect::>(), + [11, 22] ); - - disable(); - record_input(12, 100); - assert_eq!(snapshot(), captured); + 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] diff --git a/crates/graphforge-exec/src/lib.rs b/crates/graphforge-exec/src/lib.rs index d1785a1fd..6b599d13a 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 @@ -3156,6 +3165,7 @@ pub struct ExpandExec { demand_batch: Option, /// Query-scoped terminal cancellation shared by the bounded hop chain. demand: Option>, + capture: Option, } impl ExpandExec { @@ -3196,6 +3206,7 @@ impl ExpandExec { edge_var: node.edge_var, demand_batch: None, demand: None, + capture: demand::capture_handle(), } } @@ -3220,6 +3231,7 @@ impl ExpandExec { edge_var: self.edge_var, demand_batch: Some(batch_goal), demand: Some(demand), + capture: self.capture.clone(), }) } } @@ -3298,6 +3310,7 @@ impl ExecutionPlan for ExpandExec { edge_var: self.edge_var, demand_batch: self.demand_batch, demand: self.demand.clone(), + capture: self.capture.clone(), })) } @@ -3318,6 +3331,7 @@ impl ExecutionPlan for ExpandExec { edge_var: self.edge_var, demand_batch: self.demand_batch, demand: self.demand.clone(), + capture: self.capture.clone(), })) } @@ -3355,6 +3369,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(); @@ -3371,6 +3386,7 @@ impl ExecutionPlan for ExpandExec { None, batch_size, initial_batch_goal, + demand::OperatorActivity::expand_with_capture(self.edge_var, self.capture.clone()), ), |( mut input_stream, @@ -3379,6 +3395,7 @@ impl ExecutionPlan for ExpandExec { mut pending, batch_size, mut next_batch_goal, + activity, )| async move { loop { if remaining == Some(0) @@ -3416,6 +3433,7 @@ impl ExecutionPlan for ExpandExec { pending, batch_size, next_batch_goal, + activity, ), ))); } @@ -3423,7 +3441,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())); } }, @@ -3446,6 +3468,7 @@ struct SingleHopConfig { provider: Arc, edge_var: u32, demand: Option>, + capture: Option, } /// Resumable position within one input batch. Keeping the raw adjacency offset @@ -3532,7 +3555,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. @@ -3543,9 +3566,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, @@ -3582,9 +3607,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, @@ -3670,7 +3697,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) } @@ -5044,6 +5071,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. /// @@ -5065,9 +5108,19 @@ 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; + 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. // Public callers (and DF54-era optimistic publish tests) index diff --git a/crates/graphforge-exec/tests/adjacency_expand.rs b/crates/graphforge-exec/tests/adjacency_expand.rs index c4a9b5083..044f7b6f0 100644 --- a/crates/graphforge-exec/tests/adjacency_expand.rs +++ b/crates/graphforge-exec/tests/adjacency_expand.rs @@ -121,6 +121,14 @@ use arrow::array::Array; async fn explain_shows_stable_expand_exec_across_index_states() { let dir = TempDir::new().unwrap(); let rc = seed(dir.path()).await; + // Seeding executes MATCH-backed writes, which may legitimately build the + // derived adjacency capability. Remove it so this assertion actually + // exercises the missing-index lowering state. + match std::fs::remove_dir_all(dir.path().join("indexes")) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("remove seeded adjacency capability: {error}"), + } let plan = bind( "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN b.name AS bn", &rc, diff --git a/crates/graphforge-exec/tests/explain_snapshots.rs b/crates/graphforge-exec/tests/explain_snapshots.rs index d55d70fe3..15ea1535a 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 7913233c7..811a505bb 100644 --- a/crates/graphforge-exec/tests/persistent_adjacency.rs +++ b/crates/graphforge-exec/tests/persistent_adjacency.rs @@ -37,6 +37,7 @@ fn force_stale_generation_fixture(dir: &Path) { /// 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(); @@ -154,7 +155,7 @@ fn absent_capability_dir_is_building_and_scan_builds() { } #[test] -fn absent_index_scan_build_is_cached_across_stream_batches() { +fn absent_index_bounded_build_is_cached_across_queries() { let dir = TempDir::new().unwrap(); write_diamond(dir.path()); let provider = persistent(dir.path(), OntologyMode::Strict); @@ -163,13 +164,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 derived 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), + "the authenticated unchanged source must retain its bounded derived CSR" ); } @@ -219,11 +220,11 @@ fn fresh_index_with_unknown_rel_scan_builds_without_rebuild() { } // --------------------------------------------------------------------------- -// Corrupt artifacts degrade, never fail +// Corrupt artifacts repair when authority is readable and fail closed when it is not // --------------------------------------------------------------------------- #[test] -fn corrupt_generation_counter_is_miss_without_rebuild() { +fn corrupt_generation_counter_refuses_unbounded_scan_fallback() { let dir = TempDir::new().unwrap(); write_diamond(dir.path()); build_adjacency_index(dir.path(), TS).unwrap(); @@ -238,13 +239,15 @@ 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("unreadable authority must never select the O(E)-memory scan oracle"); + assert!( + error + .to_string() + .contains("bounded adjacency index build failed") + && error.to_string().contains("corrupt"), + "{error}" ); } diff --git a/crates/graphforge-rel/src/lowerer.rs b/crates/graphforge-rel/src/lowerer.rs index a353bf884..dd614742b 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, @@ -2460,10 +2461,98 @@ 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 { + // 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) + .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( @@ -6170,6 +6259,116 @@ 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), + }) + .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::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"); + }; + 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; 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 ed9cbf56b..faa72b856 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 81d874e89..73d20d680 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")] diff --git a/crates/graphforge-storage/src/adjacency.rs b/crates/graphforge-storage/src/adjacency.rs index 78ec71b0d..e3fdf85ec 100644 --- a/crates/graphforge-storage/src/adjacency.rs +++ b/crates/graphforge-storage/src/adjacency.rs @@ -61,6 +61,49 @@ 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) +} + +/// Authenticated identity of the immutable graph generation behind a derived +/// adjacency cache entry. +/// +/// Unlike topology counters, file lengths, or timestamps, both fields come +/// from the lifetime-pinned project generation selected by `CURRENT`. The +/// manifest digest commits the exact graph participant descriptor (and thus +/// its inventory or compact root), so replacing graph bytes while preserving +/// numeric counters cannot alias an existing cache entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AdjacencySourceIdentity { + /// Immutable generation selected by the authenticated project pointer. + pub generation_uuid: uuid::Uuid, + /// SHA-256 of the exact authenticated generation manifest bytes. + pub generation_manifest_sha256: [u8; 32], +} + +impl AdjacencySourceIdentity { + /// Derive an adjacency cache identity from an already pinned generation. + #[must_use] + pub fn from_generation(generation: &crate::ResolvedProjectGeneration) -> Self { + Self { + generation_uuid: generation.generation_uuid(), + generation_manifest_sha256: generation.manifest_sha256(), + } + } +} + /// File name of the adjacency index manifest within `indexes/adjacency/`. pub const MANIFEST_FILE: &str = "index_manifest.parquet"; @@ -682,6 +725,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 +830,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 +1041,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 +1050,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 +1099,37 @@ 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() { + let relation_type = relation_types.value(i).to_owned(); + let relation_name = + (!relation_names.is_null(i)).then(|| relation_names.value(i).to_owned()); + let valid_identity = if relation_type == ALL_RELATIONS_STEM { + relation_name.is_none() + } else { + relation_name + .as_deref() + .is_some_and(|name| adjacency_relation_key(name) == relation_type) + }; + if !valid_identity { + return Err(GfError::Storage( + "adjacency manifest relation key is not bound to its exact name".to_owned(), + )); + } rows.push(AdjacencyManifestRow { - relation_type: relation_types.value(i).to_owned(), + relation_type, + relation_name, direction: Direction::parse(directions.value(i))?, topology_generation: generations.value(i), built_at_micros: built_ats.value(i), @@ -1186,10 +1255,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 +1383,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 +1524,7 @@ struct EntryGroup { out_runs: Vec, in_runs: Vec, label: String, + relation_name: Option, } impl EntryGroup { @@ -1465,6 +1535,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 +1918,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(()) }, @@ -1939,8 +2022,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)] @@ -1986,10 +2068,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(()) })?; @@ -2102,6 +2182,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 { @@ -2113,7 +2198,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; @@ -2134,13 +2219,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(), }), @@ -2402,17 +2487,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, @@ -2870,7 +2944,8 @@ mod tests { let dir = TempDir::new().unwrap(); let rows = vec![ AdjacencyManifestRow { - relation_type: "WORKS_AT".to_owned(), + relation_type: adjacency_relation_key("WORKS_AT"), + relation_name: Some("WORKS_AT".to_owned()), direction: Direction::Out, topology_generation: 7, built_at_micros: TS, @@ -2878,7 +2953,8 @@ mod tests { edge_count: 250, }, AdjacencyManifestRow { - relation_type: "WORKS_AT".to_owned(), + relation_type: adjacency_relation_key("WORKS_AT"), + relation_name: Some("WORKS_AT".to_owned()), direction: Direction::In, topology_generation: 7, built_at_micros: TS, @@ -2886,7 +2962,8 @@ mod tests { edge_count: 250, }, AdjacencyManifestRow { - relation_type: "OWNS".to_owned(), + relation_type: adjacency_relation_key("OWNS"), + relation_name: Some("OWNS".to_owned()), direction: Direction::Out, topology_generation: 7, built_at_micros: TS + 1, @@ -2895,6 +2972,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, @@ -2912,11 +2990,43 @@ mod tests { assert_eq!(read_manifest(dir.path()).unwrap(), Vec::new()); } + #[test] + fn adjacency_source_identity_uses_pinned_generation_authority() { + let dir = TempDir::new().unwrap(); + let generation = crate::open_or_initialize_ephemeral_project(dir.path()).unwrap(); + let identity = AdjacencySourceIdentity::from_generation(&generation); + assert_eq!(identity.generation_uuid, generation.generation_uuid()); + assert_eq!( + identity.generation_manifest_sha256, + generation.manifest_sha256() + ); + } + + #[test] + fn manifest_rejects_relation_key_not_bound_to_exact_name() { + let dir = TempDir::new().unwrap(); + let rows = vec![AdjacencyManifestRow { + relation_type: adjacency_relation_key("KNOWS"), + relation_name: Some("OWNS".to_owned()), + direction: Direction::Out, + topology_generation: 1, + built_at_micros: 0, + node_count: 2, + edge_count: 1, + }]; + write_manifest(dir.path(), &rows).unwrap(); + assert!(matches!( + read_manifest(dir.path()), + Err(GfError::Storage(_)) + )); + } + #[test] fn write_manifest_replaces_existing() { let dir = TempDir::new().unwrap(); let first = vec![AdjacencyManifestRow { - relation_type: "KNOWS".to_owned(), + relation_type: adjacency_relation_key("KNOWS"), + relation_name: Some("KNOWS".to_owned()), direction: Direction::Out, topology_generation: 1, built_at_micros: 0, @@ -2926,7 +3036,8 @@ mod tests { write_manifest(dir.path(), &first).unwrap(); let second = vec![AdjacencyManifestRow { - relation_type: "KNOWS".to_owned(), + relation_type: adjacency_relation_key("KNOWS"), + relation_name: Some("KNOWS".to_owned()), direction: Direction::Out, topology_generation: 2, built_at_micros: 1, @@ -2977,7 +3088,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") @@ -3044,7 +3159,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); @@ -3156,32 +3273,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] @@ -3702,8 +3830,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 641b7e323..2a38d1ad9 100644 --- a/crates/graphforge-storage/src/adjacency_delta.rs +++ b/crates/graphforge-storage/src/adjacency_delta.rs @@ -30,9 +30,17 @@ 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, 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; @@ -236,8 +244,9 @@ 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 -/// hostile relation name can never be materialized at a per-relation path). +/// whose exact relation key matches `stem`. Raw names are normalized for +/// compatibility; callers address the literal `_all` relation through its +/// encoded key because `_all` itself remains the wildcard selector. /// /// Implementation: reconstruct the base `(src, edge, dst)` entries from the CSR, /// concatenate the filtered delta entries, and re-run @@ -253,10 +262,11 @@ 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 { - 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)); } } @@ -344,13 +354,14 @@ 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; 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/graph_construction.rs b/crates/graphforge-storage/src/graph_construction.rs index de2dcdb68..a9671ae37 100644 --- a/crates/graphforge-storage/src/graph_construction.rs +++ b/crates/graphforge-storage/src/graph_construction.rs @@ -729,10 +729,19 @@ pub struct GraphConstructionSession { parent_catalog: RuntimeCatalog, compact_parent: Option, semantic_authority: Option, - _session_lock: File, + session_lock: File, _reservation: ProcessReservation, } +impl Drop for GraphConstructionSession { + fn drop(&mut self) { + // Release the descriptor lock before the process reservation. Relying + // on platform-specific close semantics leaves a window where an + // immediate authenticated resume can observe the retired owner. + let _ = crate::file_lock::unlock(&self.session_lock); + } +} + impl GraphConstructionSession { /// Durably bind the sealed private inventory to the one target that the /// existing project publisher will stage. This records replay authority; @@ -1619,7 +1628,7 @@ impl GraphConstructionSession { parent_catalog, compact_parent, semantic_authority, - _session_lock: session_lock, + session_lock, _reservation: reservation, }; recover_shape_intent(&session.root, &mut session.checkpoint)?; diff --git a/crates/graphforge-storage/src/schemas.rs b/crates/graphforge-storage/src/schemas.rs index 40b120ed3..cd301a790 100644 --- a/crates/graphforge-storage/src/schemas.rs +++ b/crates/graphforge-storage/src/schemas.rs @@ -331,11 +331,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"), @@ -716,6 +718,7 @@ mod tests { names, [ "relation_type", + "relation_name", "direction", "topology_generation", "built_at", @@ -723,7 +726,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); } @@ -735,7 +738,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/development/perf-g500-ladder.md b/docs/development/perf-g500-ladder.md index 450682478..7994dbedb 100644 --- a/docs/development/perf-g500-ladder.md +++ b/docs/development/perf-g500-ladder.md @@ -18,7 +18,7 @@ edges. | Pinned `github.com/graph500/graph500` generator | No — bounded bench-local Kronecker in the test file | | Graph500 BFS kernel / harmonic-mean TEPS | Non-goal (`teps` is `null`) | | One-billion-live-edge product certification | No — that is #745 | -| Engineering green (generate → ingest → reopen → GSI → `LIMIT 1000`) | Yes, through one resumable `GraphConstructionSession` + `execute` | +| Engineering green (generate → ingest → reopen → counts → one/two-hop `LIMIT 1000`) | Yes, through one resumable `GraphConstructionSession` + observed public execution | ## What is new versus the #710 SCALE-20 client @@ -97,11 +97,13 @@ 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 / -elapsed time against the envelope. On the **first** violation it records the -failing phase and `error_class` (`oom` | `disk_exhaustion` | `timeout`) and -stops — no larger rung is attempted and no SCALE-26 pass is claimed. +`run_ladder` walks the provisioned rungs in increasing scale and gives every +potentially large operation its own atomic boundary: `generate`, `ingest`, +`reopen`, `node_count`, `edge_count`, `one_hop`, and `two_hop`. After each it +compares RSS, disk, elapsed time, and operator-release evidence with the +declared envelope. On the **first** violation it records that exact phase and a +typed error class 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 > (a true high-water mark) and falls back to sampled `ps` RSS otherwise (an @@ -109,6 +111,20 @@ stops — no larger rung is attempted and no SCALE-26 pass is claimed. > (`vmhwm` | `ps_sampled`) so a `ps_sampled` value is read as a floor. Run > provisioned certification rungs on **Linux**. +The one-hop and two-hop qualification probes remain the original unrooted, +ordered `LIMIT 1000` queries. This preserves the historical S20 workload and +prevents a selective root from silently replacing it. A deterministic rooted +variant is also measured inside each query boundary to attribute expansion work +to a bounded neighborhood, but it is labeled `rooted_additional` and is never +reported as the unrooted result. + +Observed execution owns a query-local capture. Evidence records expansion and +TopK rows/work, per-operator RSS lifetime boundaries, memory-pool reservation +before and after execution, returned Arrow bytes, and whether operator memory +quiesced. Process-wide compatibility counters are not accepted as operator +attribution. A phase cannot pass when its query-local allocation remains live +after the result boundary. + Provisioned runs must set `GF_G500_LADDER_WORKSPACE` to a stable run directory. If omitted, the runner derives `workspace/` beside `GF_G500_LADDER_JOURNAL_OUT`; it refuses a provisioned rung when neither path is @@ -170,6 +186,10 @@ One object per attempted rung (schema - `first_failing_phase`, `error_class`, `pass`, `reconciles`. - `input_fingerprint` (deterministic SHA-256 of the sorted live edge set), `rss_peak_bytes`, `disk_used_bytes`, `wall_time_s`, per-phase `steps`. +- Query steps include the unrooted result, separately labeled additional rooted + result, query-local expand/TopK work, RSS lifetimes, and allocation-release + evidence. `operator_memory_contract` records the configured budget, maximum + observed working set, headroom, and lower-rung plateau decision. - `machine_envelope` (128 GiB / 1 TiB / 4 h fail-safe), `sut`, `generator`. - `track` and `teps` are always `null`. diff --git a/scripts/ci/test-non-cypher-surface-gate.py b/scripts/ci/test-non-cypher-surface-gate.py index 958edb164..d1155b813 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()), 372) + self.assertEqual(len(GATE.public_methods()), 374) 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 a3f59e7f8..4528c1fec 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": "71c92e47b6e43da553be7288724d522e303da5d4677e60f51e7ea8d9f8a41b99", + "public_method_digest": "5b940b936d8d142221461b8a91896d04d588e032b510bec42a1c2f9b962c6041", "method_policy": { "receiver_defaults": { "GraphForge": "release-tested", @@ -806,6 +806,7 @@ "ids": [ "GraphForge.clear", "GraphForge.execute", + "GraphForge.execute_observed", "GraphForge.execute_with_composition", "GraphForge.execute_stream", "GraphForge.execute_stream_owned", @@ -815,6 +816,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", @@ -845,6 +847,10 @@ "path": "crates/graphforge-api/tests/facade_methods.rs", "symbol": "explain_is_side_effect_free_on_the_shared_catalog" }, + { + "path": "crates/graphforge-api/tests/fixed_hop_limit.rs", + "symbol": "observed_public_surface_is_parameterized_and_query_scoped" + }, { "path": "crates/graphforge-api/src/lib.rs", "symbol": "parameter_binding_filters_by_value" diff --git a/tools/bazel/drift/cargo_feature_fingerprint.json b/tools/bazel/drift/cargo_feature_fingerprint.json index 28fcf6833..8a07c5e32 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": "faf566b1023d302f374da985eedf1d2faa6e580fb9caa5dccc17840db4c50023", + "sha256": "4b1e075639759ea703eae13167e07501b4f623a3a7fe76d58a3efdcbbb1230e8", "entries": [ { "name": "graphforge-api", @@ -1058,7 +1058,7 @@ ], "optional": false, "uses_default_features": true, - "kind": "dev", + "kind": null, "target": null } ]