Repository navigation
Description
Summary
Every stage of query evaluation materialises its full input and output in memory, and the only protection is a hard cap that turns a large result into an error. This proposal replaces that with a streaming engine: a pull-based operator pipeline over batches of bindings, bounded memory per operator with spill where an operator must materialise, a cursor API that delivers results incrementally across the FFI, and a transaction path that streams large writes to the WAL. It targets v3.0.0 because the public QueryResult shape and the FFI surface change, and 3.0.0 is the breaking release.
Where the engine materialises today (branch v3 @ 02d66e15, 2026-09-27; identical on main @ dac151c0)
| stage | code | what is held in memory |
|---|---|---|
| clause match | matcher.rs:71 match_pattern -> Vec<Bindings>, :244 match_patterns, :429 join_with_pattern |
every binding of every clause; each join step builds the full intermediate relation before the next clause runs |
| index lookup | matcher.rs:335 lookup_with_hint |
the full candidate set for the hinted pattern |
| rules | evaluator.rs:113 evaluate_recursive_rules -> FactStorage, :129 BTreeSet seen_facts, :636 evaluate -> FactStorage |
all derived facts of every stratum, plus a canonical-encoded set of every fact seen for the semi-naive delta |
| aggregation / window | executor.rs:1338 "unified post-processing" |
the complete binding set, then the aggregate over it |
| result | executor.rs:91 QueryResults { vars, results: Vec<Vec<Value>> } |
the whole answer as one vector, handed to the caller in one go |
| limits | OpenOptions::max_results (1,000,000), max_derived_facts (1,000,000) |
exceeding either raises INT-020 evaluator: iteration or result limit exceeded; there is no truncation, no paging, no cursor |
| FFI | execute(datalog) -> String (JSON of the whole result) |
the same vector serialised once more |
Consequences observed in production use (temporal_reasoning, a 4-9 GB graph of ~4M facts):
- a
[:find ?e ?d :where [?e :description ?d]]returned a single 4.7 MB JSON blob; acountover:entity-typewith an audit dry run held the server thread for 21+ minutes with RSS growing 4.2 to 5.7 GB and no way to interrupt (temporal_reasoning notes 2026-09-03); LIMIT-shaped questions ("first 10 modules matching X") cost the same as the full answer;- any aggregation over a large intermediate (
count,maxover a join) is bounded by RAM, not by the size of the answer; - the sidecar rebuild and the proposed offline migration (enhancement: streaming fact-log iterator with full history (tx, asserted, valid bounds) and coarse filters #430, temporal_reasoning#378) cannot read the graph without going through this path.
Goals
- Bounded memory per operator. Memory use is a function of the operator's state (a hash table of groups, a sort run), never of the input size, and is capped by a configurable budget with spill to a temp file when exceeded.
- Incremental delivery. Results reach the caller as batches as soon as the pipeline produces them;
LIMIT nterminates early; a caller can stop consuming and the pipeline stops working. - Streaming aggregation.
count,sum,min,max,avg, and grouped aggregates run in one pass over the input stream; grouped aggregation uses a hash table with a budget and spill, or a sort-based path when the input is already ordered on the group key. - Consistent snapshots. A cursor reads at a fixed transaction bound (
as-ofthe counter at open), so writes that commit while a cursor is open do not change its answer. The store is append-only and bitemporal, so this is a bound ontx, not a copy. - Cancellation and backpressure. A cursor can be closed early; across the FFI the consumer pulls batches, so a slow client cannot make the server buffer the answer.
- Streaming transactions. A large
transact/retract(ingestion writes tens of thousands of facts per commit) is parsed and appended to the WAL in chunks rather than parsed into oneVec<Fact>first; aWriteTransactionreads its own uncommitted chunks through the same operator interface.
Non-goals: changing Datalog semantics, changing the file format beyond what v8 already does, parallel execution (fits later; the operator model does not preclude it).
Design
Operator model. Pull-based (Volcano) iterators with open() / next_batch() -> Option<Batch> / close(), where a Batch is a fixed-capacity columnar block of bindings (say 1,024 rows) so per-row virtual-call overhead is amortised. Push-based would work too; pull is chosen because LIMIT, early close and cursor semantics fall out of it directly.
Operators.
Scan: an index range on EAVT/AEVT (VAET if added) with the:as-ofbound and:valid-at/:any-valid-timepredicates pushed into the range, yielding facts in index order. This is also the primitive enhancement: streaming fact-log iterator with full history (tx, asserted, valid bounds) and coarse filters #430 asks for, exposed directly.IndexNestedLoopJoin: for each batch of the outer, probe the index for the inner pattern; replacesjoin_with_pattern.HashJoin: when neither side has a usable index; build side under the memory budget, spill by partition (Grace hash join) beyond it.Filter,Project,Distinct(hash with spill),Union,NotJoin/anti-join (hash on the right side, budgeted).Sort: external merge sort when the run exceeds the budget; feedsLimitwith top-k when both are present.Limit/Offset: early termination propagatesclose()upstream.Aggregate: streaming for ungrouped; hash-grouped with spill; sort-grouped when the child is ordered on the key.Recursive(semi-naive fixpoint per stratum): the delta and the accumulated relation live in a budgeted temp relation (in-memory table that spills to a temp file keyed like the index), replacingFactStorage+BTreeSet seen_facts;max_derived_factsbecomes a safety limit on the accumulated relation only. Magic-sets and stratification stay as rewrites in front of the planner.
Planner. The existing optimizer.rs/prepared.rs produce the clause order and index hints today; they become the producer of an operator tree instead of a plan for the matcher. The profiler (#185) gets per-operator counters for free.
API.
pub fn query(&self, datalog: &str) -> Result<Cursor, MinigrafError>;
impl Cursor {
pub fn vars(&self) -> &[String];
pub fn next_batch(&mut self, max_rows: usize) -> Result<Option<Batch>, MinigrafError>;
pub fn close(self);
}
// convenience, unchanged signature, implemented as `query(..).collect()` under max_results:
pub fn execute(&self, input: &str) -> Result<QueryResult, MinigrafError>;OpenOptions gains query_memory_budget: usize (bytes; default a few hundred MB) and spill_dir. max_results keeps its meaning for execute only; a cursor has no result cap. INT-020 is raised only by the recursive operator's accumulated-relation limit.
FFI. A Cursor object with vars(), next_batch(n) -> String (JSON rows) and close(); uniffi cannot carry an iterator, so this is the shape #430 already proposes. Bindings (python, node, wasm, java, swift, c) add a thin iterator over it. The MCP server in temporal_reasoning can then page results to the model and yield its event loop between batches.
Transactions. transact/retract take the EDN through a streaming parser that emits facts in chunks; each chunk is validated and appended to the WAL under the open write lock; the transaction's tx is assigned once at commit. A WriteTransaction's reads see its own chunks via a Scan over the uncommitted tail. Schema validation and the v8 multi-value fix (#371, #287) are unaffected.
Acceptance tests (release gate)
[:find ?e :where [?e :entity-type :type/function] :limit 10]over 10M facts completes in time and memory proportional to 10, not 10M.countover 10M facts runs in constant memory; groupedcountwith 1M groups completes under a 256 MB budget by spilling.- A recursive rule deriving 5M facts completes under budget;
max_derived_factsstill stops a runaway rule. - A cursor opened before a
transactreturns the pre-transaction answer to the end. - Closing a cursor after the first batch stops all upstream work (verify via operator counters).
- A 1M-fact
transactnever holds more than one chunk of parsed facts. - FFI round trip through the python binding with batches of 1,000; wall time and RSS recorded in the perf: benchmark queries at 100K–10M facts and publish a performance envelope #394 envelope.
- Existing
execute-based tests pass unchanged.
Relationship to the v3.0.0 roadmap
ROADMAP.md scopes v3.0.0 to format v8 and data integrity and explicitly keeps additive features out of it. This item is not additive: the QueryResult shape, the cursor type and the FFI surface are breaking, and 3.0.0 is the only breaking window before the 12-month v2.x support period. Two ways to reconcile:
- Full engine in 3.0.0 (this proposal as written).
- API in 3.0.0, operators incrementally. Land
query() -> Cursor, the batch type and the FFI cursor in 3.0.0 with the current materialising evaluator behind them (a cursor over aVec), then replace operators one by one in 3.x without further breaking changes. This keeps the bug: same-transaction multi-valued facts read back as ONE value, differing between EAVT and AEVT; a batched retract can leave retracted values visible #371 fix unblocked and still makes 3.0.0 the release after which no client has to change.
Either way, #430 (fact-log iterator), #429 (read-only open) and #431 (raw writer) are the Scan operator and its write-side counterpart and should be designed as part of this rather than separately. #379 (O(live) point queries) and #394 (performance envelope) are the measurements this must not regress.
Activity
adityamukho commentedon Oct 1, 2026
The memory-bounding design here needs a revisit before implementation. Spilling to temp files (Grace hash join, external sort, spilled recursive relations,
spill_dir) is not settled as the right approach:.graphfile, plus new configuration (spill_dir,query_memory_budget), which pulls against the single-file and zero-configuration principles.Alternatives to evaluate: pushing more work into index-ordered streaming (sort-based grouping and merge joins over EAVT/AEVT/AVET order) so fewer operators need to materialise, scratch pages inside the
.graphfile, or failing clearly when an operator exceeds its budget. The cursor API and incremental delivery (goals 2, 4 and 5) do not depend on this decision.Merge pull request #461 from project-minigraf/feat/432-cursor-api
adityamukho commentedon Oct 7, 2026
Progress: the Rust cursor API (option 2) is merged on
v3in #461:Minigraf::query/PreparedQuery::query→ owned,SendCursorwithnext_batch, a fixed snapshot at open, andAPI-012for non-queries. For now the current evaluator still computes the answer when the cursor opens. Binding cursors are tracked in #462. Still open here: streaming operators and how they bound their memory (see the comment above). One correction to the issue text:max_resultstoday bounds recursive rule evaluation only and never caps a plain query's rows, so the cursor needed no change to it.adityamukho commentedon Oct 7, 2026
Status: the cursor API (#461) is now exposed in every binding (#462, closed): python#6, java#9, android#6, swift#6, node#5, c#4, wasm#4, plus
BrowserDb.query(#468). This issue stays open for the streaming, bounded-memory operator pipeline that its acceptance tests require. The cursor API will not change when that lands.adityamukho commentedon Oct 7, 2026
Closing as split: the v3.0.0 part is done.
v3): the cursor API (Minigraf::query/PreparedQuery::query→ owned,SendCursor; feat: cursor API — Minigraf::query and PreparedQuery::query (#432) #461). It is in every binding (enhancement (v3.0.0): expose the query cursor in every binding #462: python#6, java#9, android#6, swift#6, node#5, c#4, wasm#4) and inBrowserDb.query(feat: binding support — API-017/API-018 and BrowserDb cursor (#462, #465, #467) #468). This is the breaking part, now frozen. Of the acceptance tests, 'a cursor opened before atransactreturns the pre-transaction answer' and 'existingexecute-based tests pass unchanged' are met.