Per-Instance Query Limits

Introduced under GROVE_V4 (path_query_methods.per_instance_query_limits).

SizedQuery::limit is one global budget shared by a whole query traversal. That cannot express the most common bounded-fan-out read there is: "for each parent, return its first k children". It also made limited path queries unmergeable — a single counter cannot mean "5 rows from query A plus 7 from query B".

The per-instance limit fixes both. Query::limit is a cap carried by a query node, and it is applied per execution instance: a query node runs once for every parent key it is reached under (through a default or conditional subquery branch), and each of those runs gets a fresh budget of limit result rows for everything originating in that instance's subtree — its own pushed elements plus all descendant results.

#![allow(unused)]
fn main() {
// Top 2 documents per owner, at most 100 rows in total.
let mut docs = Query::new_range_full();
docs.limit = Some(2);                       // per-instance: fresh for every owner

let mut owners = Query::new_range_full();
owners.set_subquery(docs);

let query = PathQuery::new(
    vec![b"owners".to_vec()],
    SizedQuery::new(owners, Some(100), None), // global: shared by the whole walk
);
}

Semantics

Caps compose by min. An instance's effective budget is the smaller of its own Query::limit and whatever remains of every enclosing budget — ancestor instances and the global SizedQuery::limit. An ancestor's cap keeps bounding everything below it: a group capped at 3 returns at most 3 rows even if its children's caps would allow 6.

The root node executes exactly once, so Query::limit on the root query is equivalent to SizedQuery::limit; setting both means the smaller wins. The global limit is the level-0 instance limit.

Rows count; traversal charges don't. The empty-subtree charge (decrease_limit_on_range_with_no_sub_elements on reads, decrease_limit_on_empty_sub_query_result in proofs) keeps consuming the global budget only — that charge exists to bound walks across many empty subtrees, while per-instance caps bound result rows. Trusted reads can opt empty-subtree charges into instance budgets too with QueryOptions::decrease_instance_limits_on_range_with_no_sub_elements (default false; proofs always use the default, since the V1 verifier runs with ProveOptions::default()).

Offset skips don't consume instance budgets. A global SizedQuery::offset skips rows before they are pushed; a skipped row charges no budget, so an instance still returns its full k rows after the skip window passes through it.

Some(0) is malformed. A node that may select nothing is rejected (InvalidQuery) at every serving entry point, not treated as an empty result.

Merging — the lift

Merging limited path queries used to be refused outright. From GROVE_V4 (path_query_methods.merge = 1), PathQuery::merge merges them by lifting: a merged input's global SizedQuery::limit becomes its branch-root query's Query::limit. The lift is exact, because the branch instance executes exactly once (its path is a concrete key chain), so "at most N rows from this whole input" and "at most N rows per instance" coincide. Inputs that already carry per-instance limits keep them on their branches.

Budgets never blend, so limits merge only as exclusive grafts:

  • a limited input whose whole path is the common path lands at the merged root, where its query body would merge with the other inputs' — refused with a typed error;
  • two limited inputs whose branches collide on the same key — refused;
  • limit-free inputs merge exactly as before, on the same code path.

prove_query_many therefore serves limited path queries now, and the verifier re-derives the identical merged query at the same grove version.

Proofs

The limit is not part of the proof wire format — like the global limit, it is an input supplied identically to prover and verifier, changing only which nodes the prover emits as values versus hashes. The V1 prover and verifier thread a shared global budget plus a frame-local instance budget per layer; each layer's merk walk runs under min(global, instance), and the merk-level "proof returns more data than limit" check is what rejects a proof that over-delivers against an instance cap — a proof built for wider caps does not verify under tighter ones.

What fails closed

SurfaceBehavior
Grove versions before GROVE_V4NotSupported at every read / prove / verify entry point; on the wire, a limit-carrying query encodes its node as Query encoding version 3, which pre-field decoders reject outright
V0 prover / verifierNotSupported at dispatch — V0 is a frozen wire format whose accounting predates instance caps
Absence-proof verificationNotSupported — the terminal-keys projection reports every expected key the proof did not carry as absent, and which keys an instance-capped walk carries is data-dependent; keys beyond a cap would masquerade as proven-absent
query_keys_optional / query_raw_keys_optionalNotSupported — same false-absence hazard as absence proofs
Read-mode shapes (axis / sum-budget)InvalidQuery at classify — read modes carry their own entry caps
Aggregate leaf/carrier and count-offset-paginated shapesInvalidQuery — those shapes have their own result semantics
Colliding or root-landing limited mergesNotSupported with a typed error naming the collision

Wire encoding

A Query node carrying a per-instance limit encodes as version 3: version byte 3, then a flags byte (bit 0 = read mode present, bit 1 = per-instance limit — always set; the encoding is canonical), the standard fields, then the appended payloads. Every query without a per-instance limit keeps its historical version-1/2 bytes unchanged, so existing callers round-trip byte-identically, and decoders that predate the field fail closed on exactly the queries they cannot execute.

Known limitation

Parent tree rows added by add_parent_tree_on_subquery still do not count against any budget (the M6 known limitation). Fixing it requires charging the prover at exactly the points the verifier pushes those rows, across every descent flavor — tracked as follow-up work.