use std::collections::HashMap;
use compact_str::CompactString;
use crate::error::{GraphError, TraverserError, TraverserResult};
use crate::graph::{ExecutionPolicy, GraphStorage};
use crate::value::ElementProperty;
pub(crate) const MEMORY_LIMIT: &str = "memory.limit";
pub(crate) const MEMORY_HEADROOM: &str = "memory.headroom";
pub(crate) const MEMORY_LIMIT_AUTO: &str = "auto";
pub(crate) const MIN_DEFAULT_HEADROOM: u64 = 64 * 1024 * 1024;
#[cfg(not(target_arch = "wasm32"))]
const CGROUP_UNLIMITED_FROM: u64 = 1 << 62;
const MIN_ELEMENT_BYTES: u64 = 128;
pub(crate) fn default_headroom(limit: u64) -> u64 {
(limit / 10).max(MIN_DEFAULT_HEADROOM).min(limit / 2)
}
pub(crate) fn auto_budget(cgroup: Option<u64>, headroom: Option<u64>) -> Option<u64> {
let limit = cgroup?;
let headroom = headroom.unwrap_or_else(|| default_headroom(limit));
Some(limit.saturating_sub(headroom).max(1))
}
#[cfg(not(target_arch = "wasm32"))]
fn parse_cgroup_value(text: &str) -> Option<u64> {
let text = text.trim();
if text == "max" {
return None;
}
text.parse::<u64>()
.ok()
.filter(|limit| *limit < CGROUP_UNLIMITED_FROM)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn cgroup_limit_at(root: &std::path::Path) -> Option<u64> {
if let Ok(text) = std::fs::read_to_string(root.join("memory.max")) {
return parse_cgroup_value(&text);
}
std::fs::read_to_string(root.join("memory").join("memory.limit_in_bytes"))
.ok()
.and_then(|text| parse_cgroup_value(&text))
}
fn cgroup_limit() -> Option<u64> {
#[cfg(not(target_arch = "wasm32"))]
{
static LIMIT: std::sync::OnceLock<Option<u64>> = std::sync::OnceLock::new();
*LIMIT.get_or_init(|| cgroup_limit_at(std::path::Path::new("/sys/fs/cgroup")))
}
#[cfg(target_arch = "wasm32")]
{
None
}
}
pub(crate) fn resolve_auto(headroom: Option<u64>) -> Option<u64> {
auto_budget(cgroup_limit(), headroom)
}
#[inline]
fn tracked_live_bytes() -> Option<u64> {
#[cfg(feature = "alloc-tracking")]
{
let live = crate::alloc_tracking::global_live_bytes();
if live > 0 {
return Some(live);
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct MemoryBudget {
pub(crate) limit: u64,
tracked: bool,
base: u64,
elements_at_start: u64,
element_bytes: u64,
live_at_start: u64,
}
impl MemoryBudget {
pub(crate) fn new(limit: u64) -> Self {
Self {
limit,
tracked: false,
base: 0,
elements_at_start: 0,
element_bytes: MIN_ELEMENT_BYTES,
live_at_start: 0,
}
}
pub(crate) fn start<S: GraphStorage + ?Sized>(self, storage: &S) -> Self {
if let Some(live) = tracked_live_bytes() {
return Self {
tracked: true,
live_at_start: live,
..self
};
}
let base = storage.memory_footprint_bytes().unwrap_or(0);
let elements = (storage.vertex_count() as u64).saturating_add(storage.edge_count() as u64);
let element_bytes = base
.checked_div(elements)
.unwrap_or(0)
.max(MIN_ELEMENT_BYTES);
Self {
tracked: false,
base,
elements_at_start: elements,
element_bytes,
..self
}
}
#[inline]
fn in_use(&self) -> u64 {
if self.tracked {
tracked_live_bytes().unwrap_or(0)
} else {
self.base
}
}
#[inline]
pub(crate) fn check(&self, pending: u64) -> TraverserResult<()> {
let total = self.in_use().saturating_add(pending);
if total > self.limit {
return Err(memory_limit_error(self.limit, total));
}
Ok(())
}
#[inline]
pub(crate) fn check_boundary<S: GraphStorage>(
&self,
storage: &S,
alive: usize,
) -> TraverserResult<()> {
if self.tracked {
return self.check(0);
}
let traversers = (alive as u64).saturating_mul(std::mem::size_of::<
crate::traversal::traverser::Traverser<S>,
>() as u64);
self.check(traversers.saturating_add(self.grown_bytes(storage)))
}
#[inline]
fn grown_bytes<S: GraphStorage + ?Sized>(&self, storage: &S) -> u64 {
if self.tracked {
return 0;
}
let elements = (storage.vertex_count() as u64).saturating_add(storage.edge_count() as u64);
elements
.saturating_sub(self.elements_at_start)
.saturating_mul(self.element_bytes)
}
pub(crate) fn unit_check<S: GraphStorage + ?Sized>(
&self,
storage: &S,
operation: &'static str,
) -> Result<(), GraphError> {
let (total, grew) = if self.tracked {
let live = tracked_live_bytes().unwrap_or(0);
(live, live > self.live_at_start)
} else {
let grown = self.grown_bytes(storage);
(self.base.saturating_add(grown), grown > 0)
};
if grew && total > self.limit {
return Err(unit_limit_error(operation, self.limit, total));
}
Ok(())
}
}
pub(crate) const UNIT_CHECK_INTERVAL: u32 = 4096;
#[derive(Debug, Clone, Copy)]
pub(crate) struct UnitBudget {
budget: MemoryBudget,
operation: &'static str,
countdown: u32,
}
impl UnitBudget {
pub(crate) fn start<S: GraphStorage + ?Sized>(
storage: &S,
presets: Option<&HashMap<CompactString, ElementProperty>>,
operation: &'static str,
) -> Option<Self> {
let limit = unit_limit(&storage.execution_policy(), presets)?;
Some(Self {
budget: MemoryBudget::new(limit).start(storage),
operation,
countdown: UNIT_CHECK_INTERVAL,
})
}
#[inline]
pub(crate) fn tick<S: GraphStorage + ?Sized>(&mut self, storage: &S) -> Result<(), GraphError> {
self.countdown = self.countdown.saturating_sub(1);
if self.countdown > 0 {
return Ok(());
}
self.countdown = UNIT_CHECK_INTERVAL;
self.check(storage)
}
pub(crate) fn check<S: GraphStorage + ?Sized>(&self, storage: &S) -> Result<(), GraphError> {
self.budget.unit_check(storage, self.operation)
}
}
#[inline]
pub(crate) fn tick<S: GraphStorage + ?Sized>(
budget: &mut Option<UnitBudget>,
storage: &S,
) -> Result<(), GraphError> {
match budget {
Some(budget) => budget.tick(storage),
None => Ok(()),
}
}
#[inline]
pub(crate) fn check<S: GraphStorage + ?Sized>(
budget: &Option<UnitBudget>,
storage: &S,
) -> Result<(), GraphError> {
match budget {
Some(budget) => budget.check(storage),
None => Ok(()),
}
}
pub(crate) fn unit_limit(
policy: &ExecutionPolicy,
presets: Option<&HashMap<CompactString, ElementProperty>>,
) -> Option<u64> {
let configured = |map: &HashMap<CompactString, ElementProperty>| map.contains_key(MEMORY_LIMIT);
if !configured(&policy.defaults) && !presets.is_some_and(configured) {
return None;
}
let empty = HashMap::new();
match crate::traversal::context::resolve_memory_limit(presets.unwrap_or(&empty), policy) {
Ok(limit) => limit,
Err(err) => {
tracing::warn!(%err, "invalid memory budget configuration; the unit runs without one");
None
}
}
}
#[cold]
pub(crate) fn unit_limit_error(operation: &'static str, limit: u64, observed: u64) -> GraphError {
GraphError::ResourceLimitExceeded {
operation,
limit: MEMORY_LIMIT.to_string(),
max: Some(limit),
observed: Some(observed),
}
}
#[cold]
pub(crate) fn memory_limit_error(limit: u64, observed: u64) -> Box<TraverserError> {
Box::new(TraverserError::ResourceLimitExceeded {
limit: MEMORY_LIMIT.to_string(),
max: Some(limit),
observed: Some(observed),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::Transactional;
const MIB: u64 = 1024 * 1024;
#[test]
fn default_headroom_is_a_tenth_with_bounds() {
assert_eq!(default_headroom(8 * 1024 * MIB), 8 * 1024 * MIB / 10);
assert_eq!(default_headroom(512 * MIB), 64 * MIB);
assert_eq!(default_headroom(100 * MIB), 50 * MIB);
}
#[test]
fn auto_budget_subtracts_the_headroom() {
assert_eq!(auto_budget(None, None), None);
assert_eq!(auto_budget(Some(512 * MIB), None), Some(448 * MIB));
assert_eq!(
auto_budget(Some(512 * MIB), Some(12 * MIB)),
Some(500 * MIB)
);
assert_eq!(auto_budget(Some(512 * MIB), Some(0)), Some(512 * MIB));
assert_eq!(auto_budget(Some(MIB), Some(2 * MIB)), Some(1));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn cgroup_values_parse() {
assert_eq!(parse_cgroup_value("536870912\n"), Some(536_870_912));
assert_eq!(parse_cgroup_value("max\n"), None);
assert_eq!(parse_cgroup_value("9223372036854771712\n"), None);
assert_eq!(parse_cgroup_value("garbage"), None);
}
#[cfg(not(target_arch = "wasm32"))]
fn fake_root(name: &str) -> std::path::PathBuf {
let root =
std::env::temp_dir().join(format!("graphersal-cgroup-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
root
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn cgroup_v2_file_wins() {
let root = fake_root("v2");
std::fs::write(root.join("memory.max"), "1073741824\n").unwrap();
std::fs::create_dir_all(root.join("memory")).unwrap();
std::fs::write(root.join("memory").join("memory.limit_in_bytes"), "1\n").unwrap();
assert_eq!(cgroup_limit_at(&root), Some(1_073_741_824));
std::fs::write(root.join("memory.max"), "max\n").unwrap();
assert_eq!(cgroup_limit_at(&root), None, "v2 'max' is no limit");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn cgroup_v1_is_the_fallback() {
let root = fake_root("v1");
assert_eq!(cgroup_limit_at(&root), None, "no file: no limit");
std::fs::create_dir_all(root.join("memory")).unwrap();
std::fs::write(
root.join("memory").join("memory.limit_in_bytes"),
"268435456\n",
)
.unwrap();
assert_eq!(cgroup_limit_at(&root), Some(268_435_456));
std::fs::write(
root.join("memory").join("memory.limit_in_bytes"),
"9223372036854771712\n",
)
.unwrap();
assert_eq!(cgroup_limit_at(&root), None, "v1 unlimited sentinel");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn estimated_footprint_is_walked_once_per_commit_position() {
use std::sync::atomic::Ordering;
const LIMIT: i64 = 1 << 40;
let mut graph = crate::TraversalGraph::tinkerpop_modern();
let walks = |graph: &crate::TraversalGraph| graph.footprint.walks.load(Ordering::Relaxed);
let read = |graph: &crate::TraversalGraph| {
let names = graph
.traversal()
.with(MEMORY_LIMIT, LIMIT)
.v(None)
.values("name")
.to_list()
.unwrap();
assert_eq!(names.len(), 6);
};
assert_eq!(walks(&graph), 0);
read(&graph);
read(&graph);
assert_eq!(walks(&graph), 1, "two reads, one walk");
graph
.traversal_mut()
.with(MEMORY_LIMIT, LIMIT)
.add_v("extra")
.to_list()
.unwrap();
assert_eq!(walks(&graph), 1, "the write's own start reuses the figure");
read(&graph);
assert_eq!(walks(&graph), 2, "a committed write: walked again");
read(&graph);
assert_eq!(walks(&graph), 2);
let failed: Result<(), GraphError> = graph.transaction(|g| {
g.add_vertex(Some("gone".into()), vec!["x".into()], Default::default())?;
Err(GraphError::Unsupported {
operation: "test rollback",
})
});
assert!(failed.is_err());
read(&graph);
assert_eq!(walks(&graph), 2, "a rolled-back unit keeps the figure");
let mark = graph.begin_unit();
graph
.add_vertex(Some("open".into()), vec!["x".into()], Default::default())
.unwrap();
let seq = graph.last_commit_seq();
graph
.traversal_mut()
.with(MEMORY_LIMIT, LIMIT)
.v(None)
.count()
.to_list()
.unwrap();
graph
.traversal_mut()
.with(MEMORY_LIMIT, LIMIT)
.v(None)
.count()
.to_list()
.unwrap();
assert_eq!(walks(&graph), 4, "uncommitted changes are never cached");
graph.rollback_unit(mark, &crate::graph::RollbackReason::failed(&"test"));
assert_eq!(graph.last_commit_seq(), seq);
read(&graph);
assert_eq!(
walks(&graph),
4,
"after the rollback the cached figure applies again"
);
graph
.add_vertex(Some("direct".into()), vec!["x".into()], Default::default())
.unwrap();
read(&graph);
assert_eq!(walks(&graph), 5, "changed element counts: walked again");
}
#[test]
fn estimated_budget_counts_base_traversers_and_growth() {
let mut graph = crate::TraversalGraph::tinkerpop_modern();
let base = graph.memory_usage().unwrap().total_bytes();
let budget = MemoryBudget::new(base + 1000).start(&graph);
assert!(!budget.tracked);
assert!(budget.check(1000).is_ok());
let err = budget.check(1001).unwrap_err();
assert!(matches!(
*err,
TraverserError::ResourceLimitExceeded { ref limit, max: Some(max), observed: Some(observed) }
if limit == MEMORY_LIMIT && max == base + 1000 && observed == base + 1001
));
assert!(budget.check_boundary(&graph, 1).is_ok());
assert!(budget.check_boundary(&graph, 1000).is_err());
let per = budget.element_bytes;
assert!(per >= MIN_ELEMENT_BYTES);
let room = 1000 / per + 1;
for i in 0..room {
graph
.add_vertex(
Some(format!("extra{i}").into()),
vec!["x".into()],
Default::default(),
)
.unwrap();
}
assert!(budget.check_boundary(&graph, 0).is_err());
}
}