You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Severity: Low (side-effect count and stderr ordering; delivered values are correct)
Summary
#2693 stopped recurse from
evaluating f at a node the consumer was already satisfied by — limit(1; recurse(f)) went
from running f at 10000 nodes to running it at none. What it did not change: at the node
the traversal is at, f runs to completion before any of its outputs is descended into.
jq's def r: ., (f | r); r; interleaves instead: the first output of f has its whole subtree
traversed before f is asked for its second. So jq stops betweenf's outputs, and
succinctly cannot.
Repro
Confirmed live against jq 1.7.1 and succinctly at 81209f1ab (post-#2693). Values match in
every row; only stderr differs.
Under a bound — one node's fan-out too many (it was the whole remaining tree before #2693):
And once through ?, an extra f run at the node that errors:
$ echo'{"a":1}'| jq -c '[recurse(.[]?, ("x"|stderr|error("f")))?]'x # succinctly: xx
Both evaluators are affected identically (each_recurse_walk is shared), and resolve_recurse_sink has the same residual on the path side — path(limit(2; recurse((.a|debug), (.b|debug)))) on {"a":1,"b":2} writes one DEBUG line in
jq and two here.
Why it is not a small fix
The traversal is an explicit LIFO stack of materialized nodes, in all three walkers. To stop
between f's outputs the stack would have to hold f's suspended generator at each level,
and f is driven by a push-based sink that cannot be resumed. The natural expression is
recursion inside f's own sink callback — child N's subtree runs before f yields child N+1 —
but that makes native stack depth equal to traversal depth, and RECURSE_MAX_ITEMS allows
10000. This repo has measured numbers for that hazard: MAX_EXPR_DEPTH's note records the parser — a much smaller frame — aborting at around 96 levels in debug on cargo's 2 MiB test
thread.
So the options are materially different and none is obviously right:
Severity: Low (side-effect count and stderr ordering; delivered values are correct)
Summary
#2693 stopped
recursefromevaluating
fat a node the consumer was already satisfied by —limit(1; recurse(f))wentfrom running
fat 10000 nodes to running it at none. What it did not change: at the nodethe traversal is at,
fruns to completion before any of its outputs is descended into.jq's
def r: ., (f | r); r;interleaves instead: the first output offhas its whole subtreetraversed before
fis asked for its second. So jq stops betweenf's outputs, andsuccinctly cannot.
Repro
Confirmed live against jq 1.7.1 and
succinctlyat81209f1ab(post-#2693). Values match inevery row; only stderr differs.
Under a bound — one node's fan-out too many (it was the whole remaining tree before #2693):
Unbounded, it shows as stderr ordering, because a node's whole fan-out is evaluated before
its first child is descended into:
And once through
?, an extrafrun at the node that errors:Both evaluators are affected identically (
each_recurse_walkis shared), andresolve_recurse_sinkhas the same residual on the path side —path(limit(2; recurse((.a|debug), (.b|debug))))on{"a":1,"b":2}writes one DEBUG line injq and two here.
Why it is not a small fix
The traversal is an explicit LIFO stack of materialized nodes, in all three walkers. To stop
between
f's outputs the stack would have to holdf's suspended generator at each level,and
fis driven by a push-based sink that cannot be resumed. The natural expression isrecursion inside
f's own sink callback — child N's subtree runs beforefyields child N+1 —but that makes native stack depth equal to traversal depth, and
RECURSE_MAX_ITEMSallows10000. This repo has measured numbers for that hazard:
MAX_EXPR_DEPTH's note records theparser — a much smaller frame — aborting at around 96 levels in debug on cargo's 2 MiB test
thread.
So the options are materially different and none is obviously right:
use today's collecting walk past D. Exact for every realistic shape (a fan-out deeper than
~13 exceeds
RECURSE_MAX_ITEMSanyway), but adds a second traversal shape to each of thethree walkers — each of which carries jq: recurse/recurse(f) drop a null child and flatten a single array-valued child #490/jq: recurse(f)/recurse(f;cond) use breadth-first queue order, diverging from jq's depth-first definition #635/jq: recurse(f; cond) silently prunes on a cond/f error instead of propagating it like jq does #636/jq: recurse(f) drops f's own partial fan-out when a later f output errors (value + path evaluators) #842/jq: recurse(f; cond) drops cond's already-approved siblings on a later cond error #854's rules — which is the
"duplicated predicates diverge silently" hazard CLAUDE.md names.
suspended rather than only stopped. This closes the whole class at once — this issue,
jq: path()'s own consumer side still collects, so a bound outside path() can't stop the fold (#2694 residual) #2908, and
resolve_recurse_sink's own half — but is architecture-level (new ADR,invasive).
ordering.
Filing rather than choosing, since option 2's scope is a different tier from this issue's.