use super::super::super::{CsrBase, CsrShardSet, MutableCsrTrait};
use super::EdgeStore;
use crate::edge::VertexFragmentation;
use linkrs_core::types::Timestamp;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DirectionUsageSnapshot {
pub out_reads: u64,
pub in_reads: u64,
}
impl DirectionUsageSnapshot {
pub fn total(&self) -> u64 {
self.out_reads.saturating_add(self.in_reads)
}
pub fn in_share(&self) -> Option<f64> {
let total = self.total();
if total == 0 {
None
} else {
Some(self.in_reads as f64 / total as f64)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectionNarrowingSuggestion {
pub current: crate::edge::StorageDirection,
pub target: crate::edge::StorageDirection,
pub basis: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DirectionMigrationStats {
pub edges_dropped: u64,
pub groups_dropped: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct EdgeStorageBreakdown {
pub out_bytes: usize,
pub in_bytes: usize,
pub authority_bytes: usize,
pub owner_bytes: usize,
pub property_bytes: usize,
pub index_bytes: usize,
pub total_bytes: usize,
}
impl EdgeStore {
pub fn vertex_fragmentation(&self, vid: u32, cutoff: Timestamp) -> VertexFragmentation {
let (out_live, out_dead, out_cap) = self.out_csr.vertex_census(vid);
let (in_live, in_dead, in_cap) = self.in_csr.vertex_census(vid);
VertexFragmentation {
vertex: vid,
live_edges: out_live + in_live,
dead_entries: out_dead + in_dead,
capacity: out_cap + in_cap,
reclaimable: self.out_csr.reclaimable_count(vid, cutoff)
+ self.in_csr.reclaimable_count(vid, cutoff),
}
}
pub fn storage_breakdown(&self) -> EdgeStorageBreakdown {
let out_bytes = self.out_csr.used_memory_size();
let in_bytes = self.in_csr.used_memory_size();
let authority_bytes = self.mvcc.edge_timestamps.memory_bytes();
let owner_bytes = self.edge_owner.memory_bytes();
let property_bytes = self.properties.used_memory_size();
let cache_bytes = self.property_index_cache.len()
* (std::mem::size_of::<String>() + std::mem::size_of::<usize>());
let index_bytes = self
.property_index
.as_ref()
.map(|index| index.memory_usage() as usize)
.unwrap_or(0)
+ cache_bytes;
EdgeStorageBreakdown {
out_bytes,
in_bytes,
authority_bytes,
owner_bytes,
property_bytes,
index_bytes,
total_bytes: out_bytes
+ in_bytes
+ authority_bytes
+ owner_bytes
+ property_bytes
+ index_bytes,
}
}
pub fn topology_write_amplification(&self) -> u32 {
match self.schema.storage_direction() {
crate::edge::StorageDirection::Both => 2,
crate::edge::StorageDirection::OutOnly | crate::edge::StorageDirection::InOnly => 1,
}
}
pub fn bytes_per_edge_breakdown(&self) -> (f64, f64, f64, f64) {
let live = self.out_csr.edge_count().max(1) as f64;
let topo = (self.out_csr.used_memory_size() + self.in_csr.used_memory_size()) as f64 / live;
let authority = self.mvcc.edge_timestamps.memory_bytes() as f64 / live;
let (mapping, rows) = self.properties.mapping_row_bytes();
(topo, authority, mapping as f64 / live, rows as f64 / live)
}
pub fn suggest_direction_narrowing(
&self,
min_observations: u64,
) -> Option<DirectionNarrowingSuggestion> {
use crate::edge::StorageDirection;
let current = self.schema.storage_direction();
if current != StorageDirection::Both {
return None;
}
let snapshot = self.direction_usage_snapshot();
if snapshot.total() < min_observations {
return None;
}
let basis = format!(
"direction usage out_reads={} in_reads={} (min_observations={}); Both pays 2x topology writes, single direction pays 1x",
snapshot.out_reads, snapshot.in_reads, min_observations,
);
if snapshot.in_reads == 0 && snapshot.out_reads > 0 {
return Some(DirectionNarrowingSuggestion {
current,
target: StorageDirection::OutOnly,
basis,
});
}
if snapshot.out_reads == 0 && snapshot.in_reads > 0 {
return Some(DirectionNarrowingSuggestion {
current,
target: StorageDirection::InOnly,
basis,
});
}
None
}
pub fn migrate_storage_direction(
&mut self,
target: crate::edge::StorageDirection,
) -> linkrs_core::StorageResult<DirectionMigrationStats> {
use crate::edge::{EdgeStrategy, StorageDirection};
if !self.is_open {
return Err(linkrs_core::StorageError::storage_not_open());
}
let current = self.schema.storage_direction();
if current == target {
return Ok(DirectionMigrationStats {
edges_dropped: 0,
groups_dropped: 0,
});
}
let (drop_out, drop_in) = match (current, target) {
(StorageDirection::Both, StorageDirection::OutOnly) => (false, true),
(StorageDirection::Both, StorageDirection::InOnly) => (true, false),
_ => {
return Err(linkrs_core::StorageError::invalid_operation(format!(
"storage-direction migration supports narrowing Both to OutOnly/InOnly only (current {:?}, target {:?}); widening needs a reimport, see storage_direction/is_direction_available",
current, target,
)));
}
};
if self.pending_add_column.is_some()
|| self.pending_drop_column.is_some()
|| self.pending_rename_column.is_some()
{
return Err(linkrs_core::StorageError::invalid_operation(
"storage-direction migration rejects a pending schema change".to_string(),
));
}
let record_form = self.schema.record_form;
let node_group_bits = self.config.node_group_bits;
let overflow_chunk_edges = self.config.overflow_chunk_edges;
let mut stats = DirectionMigrationStats {
edges_dropped: 0,
groups_dropped: 0,
};
if drop_in {
stats.edges_dropped = self.in_csr.edge_count();
stats.groups_dropped = self.in_csr.group_count();
self.schema.ie_strategy = EdgeStrategy::None;
self.schema.validate_resolved()?;
self.in_csr = CsrShardSet::new(
EdgeStrategy::None,
node_group_bits,
overflow_chunk_edges,
record_form,
)?;
}
if drop_out {
stats.edges_dropped = self.out_csr.edge_count();
stats.groups_dropped = self.out_csr.group_count();
self.schema.oe_strategy = EdgeStrategy::None;
self.schema.validate_resolved()?;
self.out_csr = CsrShardSet::new(
EdgeStrategy::None,
node_group_bits,
overflow_chunk_edges,
record_form,
)?;
}
self.segment_stats.clear();
self.property_column_dirt.clear();
self.mark_properties_dirty();
self.out_csr.mark_all_dirty();
self.in_csr.mark_all_dirty();
log::info!(
"edge table '{}' narrowed storage direction {:?} to {:?}: dropped {} edges in {} groups; reverse reads on the dropped leg are empty",
self.label_name,
current,
target,
stats.edges_dropped,
stats.groups_dropped,
);
Ok(stats)
}
pub fn memory_size(&self) -> usize {
self.used_memory_size()
}
pub fn used_memory_size(&self) -> usize {
let mut total = 0;
total += self.out_csr.used_memory_size();
total += self.in_csr.used_memory_size();
total += self.mvcc.edge_timestamps.memory_bytes();
total += self.edge_owner.memory_bytes();
total += self.properties.used_memory_size();
total += self.property_index_cache.len()
* (std::mem::size_of::<String>() + std::mem::size_of::<usize>());
if let Some(ref index) = self.property_index {
total += index.memory_usage() as usize;
}
total
}
pub fn estimate_memory_usage(&self) -> usize {
let out_edges = self.out_csr.edge_count() as usize;
let in_edges = self.in_csr.edge_count() as usize;
let out_bytes_per_edge = self.out_csr.bytes_per_edge();
let in_bytes_per_edge = self.in_csr.bytes_per_edge();
out_edges * out_bytes_per_edge + in_edges * in_bytes_per_edge
}
pub fn check_and_apply_write_backpressure(&mut self, _current_ts: Timestamp) -> bool {
if self.config.max_mutable_csr_bytes == 0 {
return false; }
let mutable_size = self.estimate_memory_usage();
if mutable_size > self.config.max_mutable_csr_bytes {
return true;
}
false
}
pub fn needs_background_maintenance(&self) -> bool {
self.config.max_mutable_csr_bytes > 0
&& self.estimate_memory_usage() > self.config.max_mutable_csr_bytes
}
pub fn maybe_run_auto_maintenance(&mut self) -> usize {
let bound = self.mvcc.min_active_snapshot_ts;
self.run_auto_maintenance_pass(bound)
}
fn run_auto_maintenance_pass(&mut self, bound: Timestamp) -> usize {
let cfg = self.config.auto_maintenance;
let mut maintenance_ran = 0;
if bound != Timestamp::MAX {
let removed = self.properties.gc_property_versions(bound);
if removed > 0 {
maintenance_ran += 1;
}
}
if cfg.property_compact_ratio > 0.0 && bound != Timestamp::MAX {
let prop_stats = self.properties.compaction_stats();
if prop_stats.fragmentation_ratio() >= cfg.property_compact_ratio as f64 {
self.compact_properties(bound);
maintenance_ran += 1;
}
}
if self.run_vertex_reclaim_pass(bound) {
maintenance_ran += 1;
}
self.emit_index_lag_metrics();
if self.property_index.is_some() {
let threshold = cfg.index_rebuild_failure_threshold;
let max_stale = cfg.index_max_stale_secs;
match self.rebuild_index_if_needed(threshold, max_stale) {
Ok(true) => maintenance_ran += 1,
Ok(false) => {}
Err(e) => {
log::debug!("automatic index rebuild skipped: {}", e);
self.maintenance_index_skips = self.maintenance_index_skips.saturating_add(1);
}
}
}
maintenance_ran
}
pub fn maintenance_skip_counts(&self) -> (u64, u64, u64) {
(
self.maintenance_reclaim_skips,
self.maintenance_migrate_skips,
self.maintenance_index_skips,
)
}
pub fn maybe_run_auto_maintenance_with_watermarks(
&mut self,
watermarks: &linkrs_transaction::MvccWatermarks,
margin: Timestamp,
) -> usize {
let bound = watermarks.safe_gc_timestamp_with_margin(margin);
if bound != Timestamp::MAX {
self.out_csr.refresh_tombstone_reuse_cutoff(bound);
self.in_csr.refresh_tombstone_reuse_cutoff(bound);
} else {
self.out_csr.clear_tombstone_reuse_cutoff();
self.in_csr.clear_tombstone_reuse_cutoff();
}
let mut ran = self.run_auto_maintenance_pass(bound);
match self.reclaim_authority_with_watermarks(watermarks, margin) {
Ok(reclaimed) => {
if reclaimed > 0 {
ran += 1;
}
}
Err(e) => {
log::warn!("authority reclaim refused on audit drift: {}", e);
self.maintenance_reclaim_skips = self.maintenance_reclaim_skips.saturating_add(1);
}
}
if self.config.auto_migrate_record_form {
match self.auto_migrate_record_form_if_beneficial() {
Ok(Some(stats)) => {
log::info!(
"automatic record-form migration on '{}': {} edges moved",
self.label_name,
stats.edges_moved
);
ran += 1;
}
Ok(None) => {}
Err(e) => {
log::debug!("automatic record-form migration skipped: {}", e);
self.maintenance_migrate_skips =
self.maintenance_migrate_skips.saturating_add(1);
}
}
}
ran
}
pub fn group_density(&self, gid: usize, outgoing: bool) -> f32 {
let csr = if outgoing {
&self.out_csr
} else {
&self.in_csr
};
csr.group_stats(gid)
.map(|stats| stats.density)
.unwrap_or(1.0)
}
pub fn partition_inserts_by_owner(&self, srcs_dsts: &[(u32, u32)]) -> Vec<(u32, Vec<usize>)> {
use std::collections::BTreeMap;
let mut by_owner: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
for (idx, (src, dst)) in srcs_dsts.iter().enumerate() {
by_owner
.entry(self.owner_gid_for(*src, *dst))
.or_default()
.push(idx);
}
by_owner.into_iter().collect()
}
pub fn group_write_count(&self, gid: u32) -> u64 {
self.group_write_counts.get(&gid).copied().unwrap_or(0)
}
pub fn hot_groups(&self, limit: usize) -> Vec<(u32, u64)> {
let mut groups: Vec<(u32, u64)> = self
.group_write_counts
.iter()
.map(|(gid, count)| (*gid, *count))
.collect();
groups.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
if limit > 0 && groups.len() > limit {
groups.truncate(limit);
}
groups
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::edge::edge_table::config::EdgeTableConfig;
use crate::edge::{EdgeSchema, EdgeStrategy, RecordForm, StorageDirection};
fn both_schema() -> EdgeSchema {
EdgeSchema {
label_id: 0,
label_name: "link".into(),
src_label: 0,
dst_label: 0,
properties: Vec::new(),
oe_strategy: EdgeStrategy::Multiple,
ie_strategy: EdgeStrategy::Multiple,
schema_version: 1,
record_form: RecordForm::Columnar,
}
}
#[test]
fn narrowing_needs_minimum_observations() {
let table = EdgeStore::with_config(both_schema(), EdgeTableConfig::default())
.expect("table builds");
assert!(table.suggest_direction_narrowing(10).is_none());
}
#[test]
fn out_only_traffic_suggests_out_only() {
let table = EdgeStore::with_config(both_schema(), EdgeTableConfig::default())
.expect("table builds");
for _ in 0..5 {
table.observe_direction_read(true, 1);
}
let suggestion = table
.suggest_direction_narrowing(5)
.expect("out-only traffic suggests");
assert_eq!(suggestion.current, StorageDirection::Both);
assert_eq!(suggestion.target, StorageDirection::OutOnly);
assert!(suggestion.basis.contains("out_reads=5"));
}
#[test]
fn mixed_traffic_suggests_nothing() {
let table = EdgeStore::with_config(both_schema(), EdgeTableConfig::default())
.expect("table builds");
for _ in 0..5 {
table.observe_direction_read(true, 1);
table.observe_direction_read(false, 1);
}
assert!(table.suggest_direction_narrowing(5).is_none());
}
#[test]
fn narrowing_drops_one_leg_and_serves_the_other() {
let mut table = EdgeStore::with_config(both_schema(), EdgeTableConfig::default())
.expect("table builds");
table.insert_edge(0, 1, 0, &[], 100).expect("insert");
table.insert_edge(0, 2, 0, &[], 100).expect("insert");
assert_eq!(table.topology_write_amplification(), 2);
let stats = table
.migrate_storage_direction(StorageDirection::OutOnly)
.expect("narrow to out");
assert_eq!(stats.edges_dropped, 2);
assert_eq!(table.storage_direction(), StorageDirection::OutOnly);
assert_eq!(table.topology_write_amplification(), 1);
assert_eq!(table.out_edges(0, 200).len(), 2);
assert!(table.in_edges(1, 200).is_empty());
assert!(table.direction_note(false).is_some());
assert!(table.suggest_direction_narrowing(1).is_none());
}
#[test]
fn widening_is_rejected() {
let mut table = EdgeStore::with_config(both_schema(), EdgeTableConfig::default())
.expect("table builds");
table
.migrate_storage_direction(StorageDirection::OutOnly)
.expect("narrow");
assert!(table
.migrate_storage_direction(StorageDirection::Both)
.is_err());
assert_eq!(table.storage_direction(), StorageDirection::OutOnly);
}
#[test]
fn same_direction_migration_is_noop_via_observe() {
let mut table = EdgeStore::with_config(both_schema(), EdgeTableConfig::default())
.expect("table builds");
let stats = table
.migrate_storage_direction(StorageDirection::Both)
.expect("noop");
assert_eq!(stats.edges_dropped, 0);
assert_eq!(table.storage_direction(), StorageDirection::Both);
}
#[test]
fn direct_observe_drives_suggestion() {
let table = EdgeStore::with_config(both_schema(), EdgeTableConfig::default())
.expect("table builds");
for _ in 0..5 {
table.observe_direction_read(true, 1);
table.observe_direction_read(false, 1);
}
assert!(table.suggest_direction_narrowing(5).is_none());
}
}
#[cfg(test)]
mod direction_migration_tests {
use super::*;
use crate::edge::edge_table::config::EdgeTableConfig;
use crate::edge::{EdgeSchema, EdgeStrategy, RecordForm, StorageDirection};
fn both_schema() -> EdgeSchema {
EdgeSchema {
label_id: 0,
label_name: "link".into(),
src_label: 0,
dst_label: 0,
properties: Vec::new(),
oe_strategy: EdgeStrategy::Multiple,
ie_strategy: EdgeStrategy::Multiple,
schema_version: 1,
record_form: RecordForm::Columnar,
}
}
fn both_table() -> EdgeStore {
EdgeStore::with_config(both_schema(), EdgeTableConfig::default())
.expect("dual-direction table builds")
}
#[test]
fn unused_reverse_leg_suggests_out_only() {
let mut table = both_table();
table.insert_edge(0, 1, 0, &[], 100).expect("insert");
for _ in 0..5 {
let _ = table.out_edges(0, 200);
}
let suggestion = table
.suggest_direction_narrowing(3)
.expect("unused in leg suggests narrowing");
assert_eq!(suggestion.current, StorageDirection::Both);
assert_eq!(suggestion.target, StorageDirection::OutOnly);
assert!(suggestion.basis.contains("in_reads=0"));
}
#[test]
fn used_both_legs_suggest_nothing() {
let mut table = both_table();
table.insert_edge(0, 1, 0, &[], 100).expect("insert");
let _ = table.out_edges(0, 200);
let _ = table.in_edges(1, 200);
assert!(table.suggest_direction_narrowing(1).is_none());
}
#[test]
fn too_few_observations_suggest_nothing() {
let mut table = both_table();
table.insert_edge(0, 1, 0, &[], 100).expect("insert");
let _ = table.out_edges(0, 200);
assert!(table.suggest_direction_narrowing(100).is_none());
}
#[test]
fn narrowing_drops_one_leg_and_keeps_writes() {
let mut table = both_table();
table.insert_edge(0, 1, 0, &[], 100).expect("insert");
table.insert_edge(0, 2, 0, &[], 100).expect("insert");
let stats = table
.migrate_storage_direction(StorageDirection::OutOnly)
.expect("narrowing succeeds");
assert_eq!(stats.edges_dropped, 2);
assert_eq!(table.storage_direction(), StorageDirection::OutOnly);
assert_eq!(table.topology_write_amplification(), 1);
assert_eq!(table.out_edges(0, 200).len(), 2);
assert!(table.in_edges(1, 200).is_empty());
assert!(table.direction_note(false).is_some());
table
.insert_edge(0, 3, 0, &[], 150)
.expect("writes continue on the kept leg");
assert_eq!(table.out_edges(0, 200).len(), 3);
}
#[test]
fn widening_is_rejected() {
let mut table = both_table();
table
.migrate_storage_direction(StorageDirection::OutOnly)
.expect("narrow first");
assert!(table
.migrate_storage_direction(StorageDirection::Both)
.is_err());
assert!(table
.migrate_storage_direction(StorageDirection::InOnly)
.is_err());
assert_eq!(table.storage_direction(), StorageDirection::OutOnly);
}
#[test]
fn inline_tables_reject_rank_up_front() {
use crate::edge::{is_bundled_eligible, RecordFormPreference};
use crate::types::StoragePropertyDef;
use linkrs_core::types::DataType;
use linkrs_core::Value;
let schema = EdgeSchema {
properties: vec![StoragePropertyDef {
name: "weight".into(),
data_type: DataType::Double,
nullable: false,
default_value: Some(Value::Double(0.0)),
}],
..both_schema()
};
assert!(is_bundled_eligible(
&schema.properties,
schema.oe_strategy,
schema.ie_strategy
));
let mut table = EdgeStore::with_config(
schema,
EdgeTableConfig {
record_form: RecordFormPreference::Bundled,
..Default::default()
},
)
.expect("bundled table builds");
assert!(table.can_accept_rank(0));
assert!(!table.can_accept_rank(3));
let err = table
.insert_edge(0, 1, 3, &[("weight".into(), Value::Double(1.0))], 100)
.expect_err("nonzero rank on bundled must fail");
assert!(err.to_string().contains("columnar record form"));
}
#[test]
fn gate_share_advice_prefers_batch_above_threshold() {
use crate::engine::graph_storage::context::WriteGateStats;
let before = WriteGateStats {
acquisitions: 0,
wait_nanos: 0,
};
let after = WriteGateStats {
acquisitions: 100,
wait_nanos: 800_000_000,
};
let share = after.share_since(&before, std::time::Duration::from_secs(1), 4);
assert!((share - 0.20).abs() < 1e-9);
assert!(WriteGateStats::prefer_batch_commits(share));
let idle = WriteGateStats {
acquisitions: 100,
wait_nanos: 10_000_000,
};
let low = idle.share_since(&before, std::time::Duration::from_secs(1), 4);
assert!(!WriteGateStats::prefer_batch_commits(low));
}
}