Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
858 changes: 858 additions & 0 deletions docs/superpowers/plans/2026-06-04-per-query-limits.md

Large diffs are not rendered by default.

53 changes: 50 additions & 3 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ pub struct OpenOptions {
/// Number of pages to hold in the LRU page cache. Default: 256 (= 1MB at 4KB pages).
pub page_cache_size: usize,
/// Maximum facts that can be derived per recursive rule iteration.
/// Defaults to 100_000. Use to prevent runaway recursive rules.
/// Defaults to 1_000_000. Use to prevent runaway recursive rules.
pub max_derived_facts: usize,
/// Maximum total query results. Defaults to 1_000_000.
pub max_results: usize,
Expand Down Expand Up @@ -102,8 +102,9 @@ impl OpenOptions {

/// Set the maximum facts that can be derived per recursive rule iteration.
///
/// Defaults to 100_000. Use lower values to prevent runaway recursive rules
/// from consuming excessive memory.
/// Defaults to 1_000_000. Use lower values to prevent runaway recursive rules
/// from consuming excessive memory. Can be overridden per-query using
/// `:max-derived-facts N` in the query vector.
pub fn max_derived_facts(mut self, n: usize) -> Self {
self.max_derived_facts = n;
self
Expand Down Expand Up @@ -1809,6 +1810,52 @@ mod tests {
assert!(result.is_ok(), "Query should succeed with higher limit");
}

#[test]
fn test_per_query_max_derived_facts_via_execute() {
let db = OpenOptions::new()
.max_derived_facts(1_000_000)
.open_memory()
.unwrap();

db.execute("(rule [(reachable ?x ?y) [?x :edge ?y]])")
.unwrap();
db.execute("(rule [(reachable ?x ?z) [?x :edge ?y] (reachable ?y ?z)])")
.unwrap();
db.execute(r#"(transact [[:a :edge :b] [:b :edge :c]])"#)
.unwrap();

// Per-query limit of 1 — too tight, must fail
let result =
db.execute("(query [:find ?x ?y :where (reachable ?x ?y) :max-derived-facts 1])");
assert!(result.is_err(), "per-query limit of 1 should fail");

// Per-query limit of 1M — should succeed
let result =
db.execute("(query [:find ?x ?y :where (reachable ?x ?y) :max-derived-facts 1000000])");
assert!(result.is_ok(), "per-query limit of 1M should succeed");

// No per-query limit — should fall back to OpenOptions default (1M) and succeed
let result = db.execute("(query [:find ?x ?y :where (reachable ?x ?y)])");
assert!(
result.is_ok(),
"no per-query limit should use database default"
);
}

#[test]
fn test_per_query_max_results_via_execute() {
let db = Minigraf::in_memory().unwrap();
db.execute(r#"(transact [[:a :v 1] [:b :v 2] [:c :v 3]])"#)
.unwrap();

// Confirms the field parses cleanly and the query succeeds
let result = db.execute("(query [:find ?e :where [?e :v ?v] :max-results 1])");
assert!(
result.is_ok(),
"query with :max-results should parse and execute"
);
}

// ── read-only handle drop must not modify the file ────────────────────────

#[test]
Expand Down
2 changes: 1 addition & 1 deletion src/query/datalog/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ use std::sync::{Arc, RwLock};
#[allow(dead_code)]
pub const DEFAULT_MAX_ITERATIONS: usize = 1000;
/// Default maximum facts that can be derived per iteration
pub const DEFAULT_MAX_DERIVED_FACTS: usize = 100_000;
pub const DEFAULT_MAX_DERIVED_FACTS: usize = 1_000_000;
/// Default maximum total query results
pub const DEFAULT_MAX_RESULTS: usize = 1_000_000;

Expand Down
107 changes: 105 additions & 2 deletions src/query/datalog/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -930,14 +930,18 @@ impl DatalogExecutor {
filtered_storage.load_fact(fact)?;
}

// Compute effective limits: per-query override takes precedence over executor default.
let effective_max_derived = query.max_derived_facts.unwrap_or(self.max_derived_facts);
let effective_max_results = query.max_results.unwrap_or(self.max_results);

// Create StratifiedEvaluator — handles negation, stratification, and positive-only rules
let evaluator = StratifiedEvaluator::new(
filtered_storage,
self.rules.clone(),
self.functions.clone(),
1000, // max iterations
self.max_derived_facts,
self.max_results,
effective_max_derived,
effective_max_results,
);

let derived_storage = evaluator.evaluate(&predicates)?;
Expand Down Expand Up @@ -2340,8 +2344,11 @@ pub(crate) fn apply_expr_clauses(
#[cfg(test)]
mod tests {
use super::*;
use crate::query::datalog::functions::FunctionRegistry;
use crate::query::datalog::parser::parse_datalog_command;
use crate::query::datalog::rules::RuleRegistry;
use crate::query::datalog::types::WhereClause;
use std::sync::{Arc, RwLock};
use uuid::Uuid;

#[test]
Expand Down Expand Up @@ -2877,6 +2884,8 @@ mod tests {
as_of: Some(AsOf::Counter(1)),
valid_at: Some(ValidAt::AnyValidTime),
with_vars: Vec::new(),
max_derived_facts: None,
max_results: None,
}))
.unwrap();

Expand Down Expand Up @@ -2933,6 +2942,8 @@ mod tests {
as_of: None,
valid_at: Some(ValidAt::AnyValidTime),
with_vars: Vec::new(),
max_derived_facts: None,
max_results: None,
}))
.unwrap();

Expand Down Expand Up @@ -3740,6 +3751,8 @@ mod tests {
as_of: None,
valid_at: Some(ValidAt::AnyValidTime),
with_vars: vec![],
max_derived_facts: None,
max_results: None,
};

let facts = executor.filter_facts_for_query(&query).unwrap();
Expand Down Expand Up @@ -3793,6 +3806,8 @@ mod tests {
as_of: None,
valid_at: Some(ValidAt::Timestamp(1500_i64)),
with_vars: vec![],
max_derived_facts: None,
max_results: None,
};
let facts_inside = executor.filter_facts_for_query(&query_inside).unwrap();
assert_eq!(facts_inside.len(), 2, "both facts visible at t=1500");
Expand All @@ -3804,6 +3819,8 @@ mod tests {
as_of: None,
valid_at: Some(ValidAt::Timestamp(3000_i64)),
with_vars: vec![],
max_derived_facts: None,
max_results: None,
};
let facts_outside = executor.filter_facts_for_query(&query_outside).unwrap();
assert_eq!(
Expand Down Expand Up @@ -3841,6 +3858,88 @@ mod tests {
"bound entity + attribute query should fetch only the bound entity's facts"
);
}

#[test]
fn test_per_query_max_derived_facts_overrides_executor_default() {
let storage = FactStorage::new();
let rules = Arc::new(RwLock::new(RuleRegistry::new()));
let functions = Arc::new(RwLock::new(FunctionRegistry::with_builtins()));
let mut executor = DatalogExecutor::new_with_rules_and_functions(
storage,
rules.clone(),
functions.clone(),
);
executor.set_limits(1_000_000, 1_000_000);

executor
.execute(parse_datalog_command(r#"(rule [(reachable ?x ?y) [?x :edge ?y]])"#).unwrap())
.unwrap();
executor
.execute(
parse_datalog_command(
r#"(rule [(reachable ?x ?z) [?x :edge ?y] (reachable ?y ?z)])"#,
)
.unwrap(),
)
.unwrap();
executor
.execute(parse_datalog_command(r#"(transact [[:a :edge :b] [:b :edge :c]])"#).unwrap())
.unwrap();

// Per-query limit of 1 — too tight, must fail
let result = executor.execute(
parse_datalog_command(
"(query [:find ?x ?y :where (reachable ?x ?y) :max-derived-facts 1])",
)
.unwrap(),
);
assert!(result.is_err(), "per-query limit of 1 should fail");

// Per-query limit of 1M — should succeed
let result = executor.execute(
parse_datalog_command(
"(query [:find ?x ?y :where (reachable ?x ?y) :max-derived-facts 1000000])",
)
.unwrap(),
);
assert!(result.is_ok(), "per-query limit of 1M should succeed");
}

#[test]
fn test_per_query_limit_does_not_bleed_into_next_query() {
let storage = FactStorage::new();
let rules = Arc::new(RwLock::new(RuleRegistry::new()));
let functions = Arc::new(RwLock::new(FunctionRegistry::with_builtins()));
let executor = DatalogExecutor::new_with_rules_and_functions(
storage,
rules.clone(),
functions.clone(),
);

executor
.execute(parse_datalog_command(r#"(rule [(reachable ?x ?y) [?x :edge ?y]])"#).unwrap())
.unwrap();
executor
.execute(parse_datalog_command(r#"(transact [[:a :edge :b]])"#).unwrap())
.unwrap();

// First query: tight limit, expect failure (ignore result)
let _ = executor.execute(
parse_datalog_command(
"(query [:find ?x ?y :where (reachable ?x ?y) :max-derived-facts 1])",
)
.unwrap(),
);

// Second query: no per-query limit — must use executor default (1M) and succeed
let result = executor.execute(
parse_datalog_command("(query [:find ?x ?y :where (reachable ?x ?y)])").unwrap(),
);
assert!(
result.is_ok(),
"next query should not inherit the tight per-query limit"
);
}
}

#[cfg(test)]
Expand Down Expand Up @@ -4694,6 +4793,8 @@ mod expr_eval_tests {
as_of: None,
valid_at: Some(ValidAt::Timestamp(946684800000)), // 2000-01-01
with_vars: vec![],
max_derived_facts: None,
max_results: None,
};
let r_ts = executor.execute_query_with_rules(q_ts);
assert!(
Expand All @@ -4709,6 +4810,8 @@ mod expr_eval_tests {
as_of: None,
valid_at: Some(ValidAt::AnyValidTime),
with_vars: vec![],
max_derived_facts: None,
max_results: None,
};
let r_any = executor.execute_query_with_rules(q_any);
assert!(
Expand Down
Loading
Loading