Repository navigation
Description
Problem
A recursive rule query stops with INT-020 after 1,000 semi-naive rounds. The limit is a hard-coded constant, DEFAULT_MAX_ITERATIONS = 1000 (src/query/datalog/evaluator.rs). The executor always passes it to the recursive operator (src/query/datalog/executor.rs, Limits { max_iterations: DEFAULT_MAX_ITERATIONS, .. }). Neither OpenOptions nor any query keyword can change it.
The #841 shape sweeps (PR #860) measured the effect. When a rule is called with a literal argument, magic-set demand shares the rules' stratum, so a chain takes about two rounds per edge. Recursion with the default limit therefore stops at 500-edge chains: 500 edges answer, 501 return INT-020. Raising max_derived_facts doesn't help, because that limit counts facts, not rounds.
This isn't a correctness bug, since the query fails loudly rather than returning partial rows. It does make deep hierarchies, long provenance chains and long linked lists unqueryable by recursion, with no workaround.
Proposal
Make the round limit configurable at both levels, following the existing max_derived_facts pattern:
-
Open level:
OpenOptions::max_iterations(n), with amax_iterationsfield defaulting to 1000.OpenOptionsis#[non_exhaustive], so the change is additive. -
Query level: a
:max-iterations Nquery keyword that overrides the database value for one query. It is validated like:max-derived-facts(positive integer; existing INT-012..015-style errors, or new codes if more fitting). -
Prepared queries use the database value unless the prepared text sets
:max-iterations, as they do formax_derived_facts(v2.x: prepared queries ignore OpenOptions max_derived_facts / max_results #679). A prepared query also needs a way to set the limit without re-preparing:- a bind slot,
:max-iterations $n, filled per execution like:limit $n/:offset $n(No working result-limiting/pagination mechanism for plain (non-recursive) :find queries #306); - and/or a per-statement setter on
PreparedQuery(e.g.with_max_iterations(n)), exposed in every binding'sPreparedQuery(post-1.0: expose PreparedQuery over UniFFI #181).
Whichever form is chosen should apply equally to
:max-derived-facts. Today that keyword takes only a literal (INT-015for anything else), so a prepared query can't vary it per execution either. - a bind slot,
-
The
INT-020message names which limit was reached (rounds or derived facts) and how to raise it.
Open questions
-
Gating: a query-level
:max-iterationsshould be allowed only whenOpenOptions::allow_overrideslistsMaxIterations(see Audit which OpenOptions settings should be overridable per query (and which already are) #862). -
Should the per-query value be able to raise the limit above the database value, or only lower it?
:max-derived-factscurrently replaces the database value. -
Should the default change? With
max_derived_factsandquery_memory_limit(QRY-012) already bounding work, the round limit mainly catches non-terminating rules, and none should exist after semi-naive dedup. One option is a much higher default. -
Should rounds be counted per stratum or per query? Today the count is per recursive operator.
Scope
- Core:
OpenOptions, the parser (plustests/grammar/grammar.pest,GRAMMAR.md, corpus and verifier TOKENS, per the syntax-change rule), executor and prepared paths, anddocs/ERROR_REFERENCE.mdif any codes change. - Bindings: expose the open option in every binding that exposes
max_derived_facts. - Docs site:
datalog-reference.md(the keyword) andperformance-tuning.md(the 500-edge note from Bench: large-scale shape sweeps and recursive optimization isolation #841, which should then point to the option). - Tests: a 501-edge chain fails at the default limit and succeeds with a raised open-level limit and with a raised query-level limit; a lowered query-level limit fails early.
Found during #841.
Activity
adityamukho commentedon Nov 5, 2026
Related audit of the other OpenOptions overrides: #862.
adityamukho commentedon Nov 6, 2026
Blocked by #862: the query-level
:max-iterationskeyword is gated byOpenOptions::allow_overrides, which #862 introduces.adityamukho commentedon Nov 6, 2026
Decisions (2026-11-06):
PreparedQuerysetters for the existing limit keywords. Make the recursion round limit (max_iterations) configurable per query and in OpenOptions #861 then adds, in one PR,OpenOptions::max_iterations,Overridable::MaxIterations(inDEFAULTS),:max-iterations N/$slot, andwith_max_iterations, all reusing Add :query-memory-limit, bind slots on limit keywords and PreparedQuery limit setters #870's code. Make the recursion round limit (max_iterations) configurable per query and in OpenOptions #861 is now blocked by Add :query-memory-limit, bind slots on limit keywords and PreparedQuery limit setters #870.DEFAULT_MAX_DERIVED_FACTS. Every round that continues derives at least one new fact, so rounds per stratum can't exceed derived facts + 1. With equal defaults, the derived-facts limit always binds first. The option remains for lowering the limit. On v3 this changes answers: chains longer than 500 edges now return rows instead of INT-020. That goes in the CHANGELOG under Breaking changes.src/query/engine/recursive.rsdoes today.:max-derived-facts(Audit which OpenOptions settings should be overridable per query (and which already are) #862 spec §1).:max-iterations/OpenOptions::max_iterations).Merge pull request #875 from project-minigraf/feat/861-max-iterations
docs: :max-iterations and OpenOptions::max_iterations (v3.0.0, #861) (#…
adityamukho commentedon Nov 6, 2026
Done in #875 (v3) and minigraf-docs#93.