8000 enhancement: streaming, pull-based operator pipeline with bounded memory behind the cursor API (follow-up to #432) · Issue #473 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content

enhancement: streaming, pull-based operator pipeline with bounded memory behind the cursor API (follow-up to #432) #473

Closed
9 of 9 issues completed

Description

@adityamukho

Follow-up to #432, split off once its v3.0.0 part shipped. This issue is the streaming, pull-based operator pipeline with bounded memory, behind the cursor API that #432 already froze.

What already exists (v3.0.0, on v3)

Behind the API today, the existing evaluator computes the whole answer when the cursor opens (a cursor over a Vec). This issue replaces that one operator at a time, with no change to the public API or the bindings.

Open question first: what happens when an operator's state grows large?

Proposed direction (2026-10-11, see the comment below; to be settled in the spec): no memory budget by default and no spilling: index order first (1), plus optional accounting with a coded error (3). Option 2 is out.

#432 proposed spilling to temp files in a spill_dir. That was set aside on 2026-10-01: separate spill files break the single-file rule (PHILOSOPHY.md), add configuration, have no filesystem in the browser, and suit mobile poorly. Settle the design before building any spilling operator. The candidates:

  1. Index-ordered streaming: use the covering indexes' order (sort-grouped aggregation, merge joins) so most plans need no large state.
  2. Scratch pages inside the .graph file: spill to free-list pages that are never referenced by a meta page and are reclaimed on close or reopen.
  3. A clear error when over budget: a new coded error naming the operator and the budget, so the caller can narrow the query.

They can be combined, for example 1 where the plan allows and 3 otherwise.

Goals

  1. Memory follows operator state, not input size. Pipelined operators hold one batch at a time under backpressure. Only the stateful operators (Distinct, Sort, grouped Aggregate, the HashJoin build side, the NotJoin right side, Recursive) hold more, and the planner avoids that state where index order allows. What happens when that state grows large is the open question above.
  2. Incremental delivery. Results reach the caller as batches as soon as the pipeline produces them; LIMIT n terminates early; a caller can stop consuming and the pipeline stops working.
  3. Streaming aggregation. count, sum, min, max, avg, and grouped aggregates run in one pass over the input stream; grouped aggregation uses a hash table, or a sort-based path when the input is already ordered on the group key.
  4. Consistent snapshots. A cursor reads at a fixed transaction bound (as-of the 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 on tx, not a copy.
  5. 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.
  6. 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 one Vec<Fact> first; a WriteTransaction reads 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 any of the four covering indexes (EAVT, AEVT, AVET, VAET; AVET for attribute+value patterns, see perf: attribute+value patterns scan the whole attribute; AVET index is never read #518, parked on perf/518-avet-lookup with CommittedReader reads this operator should use) with the :as-of bound and :valid-at/:any-valid-time predicates 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; replaces join_with_pattern.
  • HashJoin: when neither side has a usable index; builds a hash table on the smaller side.
  • Filter, Project, Distinct (hash, or skipped when index order makes rows unique), Union, NotJoin/anti-join (hash on the right side).
  • Sort: in-memory sort, avoided when index order already gives the order; feeds Limit with top-k when both are present.
  • Limit/Offset: early termination propagates close() upstream.
  • Aggregate: streaming for ungrouped; hash-grouped; sort-grouped when the child is ordered on the key.
  • Recursive (semi-naive fixpoint per stratum): the delta and the accumulated relation live in an in-memory relation keyed like the index, replacing FactStorage + BTreeSet seen_facts; max_derived_facts becomes 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.

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.

Options. No required memory option. If the spec adopts the optional cap, OpenOptions (non-exhaustive) gains it, off by default; it gains no spill_dir. max_results keeps its meaning for execute only, and a cursor has no result cap. INT-020 is raised only by the recursive operator's accumulated-relation limit. Bindings expose the new options through their existing OpenOptions records. In the UniFFI shim counts are signed, and a usize::MAX sentinel needs the same i64::MAX mapping as wal_checkpoint_threshold (see the binding spec, decision 7).

Acceptance tests

Carried over from #432; the two items #432 already met are not repeated.

#379 (O(live) point queries) and #394 (performance envelope) are the measurements this must not regress.

Activity

  1. added this to the v3.1.0 milestone on Oct 7, 2026
  2. added
    enhancementNew feature or request
    rustPull requests that update rust code
    performanceBenchmark performance regressions
    on Oct 7, 2026
  3. modified the milestones: v3.1.0, v3.0.0 on Oct 9, 2026
  4. adityamukho commented on Oct 11, 2026

    @adityamukho
    CollaboratorAuthor

    Data from the #394 envelope for this design: selective joins scale with their widest attribute, because each pattern without a bound entity is fetched in full by attribute before the join (selective_fact_fetch). At 1M facts [:p83333 :follows ?f] [?f :name ?n] (1 row) takes 0.61 s and a 10-row 3-clause join 1.24 s, against 12 µs for a point lookup; at 10M facts, 6.5 s and 13.5 s. Per-row HashMap<String, Value> bindings with SipHash (insert, lookup, drop) are about 25% of the attribute scan and the 3-clause join. IndexNestedLoopJoin and columnar batches remove both. The envelope (examples/perf_envelope.rs) is the acceptance measurement, and so is the Python round trip (bench/envelope.py in minigraf-python).

  5. adityamukho commented on Oct 11, 2026

    @adityamukho
    CollaboratorAuthor

    Proposed direction on memory (2026-10-11), to be settled in the spec

    No memory budget by default, and no spilling.

    Why no budget. With pull-based backpressure, pipelined operators (Scan, Filter, Project, IndexNestedLoopJoin) hold one batch at a time. A required, tuned budget goes against zero-configuration.

    Why not "use all memory and fail on OOM". For an embedded library, an out-of-memory condition is never an error the caller can handle. It kills the host process:

    • Rust: a failed allocation aborts (handle_alloc_error).
    • Linux (overcommit): allocations don't fail; the OOM killer sends SIGKILL later.
    • iOS / Android: jetsam and the low-memory killer terminate the app.
    • Browser: a failed memory.grow traps the module.

    SQLite has sqlite3_hard_heap_limit64 returning SQLITE_NOMEM for this reason.

    The stateful operators are more common than they look.

    Direction

    1. Index order first. Merge joins and sort-grouped aggregation, so stateful operators are rare.
    2. Cheap byte accounting in stateful operators, growing with try_reserve. A real allocation failure then becomes a coded error where the platform reports one (wasm, no-overcommit systems) instead of an abort.
    3. An optional cap, off by default, that raises a coded error naming the operator. It's for apps that know their limits, such as mobile, and follows max_results / max_derived_facts.

    These are options 1 and 3 of the open question. Option 2 (scratch pages in the .graph) is out.

    Body edits in this pass

  6. adityamukho commented on Oct 11, 2026

    @adityamukho
    CollaboratorAuthor

    Spec written: docs/superpowers/specs/2026-10-11-streaming-engine-design.md (PR above). It settles the open large-state question: no default budget and no spill; byte accounting, try_reserve, and an optional OpenOptions::query_memory_limit that raises QRY-012.

    Decisions taken in the brainstorm:

    One change to the Transactions section above: a transaction with at least wal_checkpoint_threshold facts writes copy-on-write pages and commits as a checkpoint. It does not append chunks to the WAL. Appending chunks would still leave every fact in the in-memory pending store after commit. Its tx_id is fixed when it switches.

    Sub-issues, in order: #543 snapshots/quarantine, #544 operator core + Scan + oracle, #545 joins, #546 not/or/Distinct, #547 Aggregate/Sort/Window, #548 Recursive, #549 memory cap, #550 streaming transactions, #551 removal/measurement/docs.

  7. adityamukho commented on Oct 24, 2026

    @adityamukho
    CollaboratorAuthor

    The streaming engine is done on v3: sub-issues #543–#551 are all closed, and #551's last PR merged as #727 (949813c). Every acceptance item is ticked with a link, except :limit. That item is #306, which is tracked in #383 and is not engine work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestperformanceBenchmark performance regressionsrustPull requests that update rust code

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0